إنشاء أحد عناصر القصة باستخدام عناصر مضاءة

1. مقدمة

القصص هي عنصر شائع في واجهة المستخدم هذه الأيام. وتعمل تطبيقات الأخبار الاجتماعية وتطبيقات الأخبار على دمجهما في خلاصاتها. في هذا الدرس التطبيقي حول الترميز، سننشئ مكوّن قصة باستخدام lit-element وTypeScript.

في ما يلي شكل مكوّن القصة في النهاية:

مكوّن عارض قصص مكتمل يعرض ثلاث صور للقهوة

يمكننا التفكير في "قصة" وسائل التواصل الاجتماعي أو الأخبار كمجموعة من البطاقات التي يتم تشغيلها بشكل تسلسلي، مثل عرض الشرائح. في الواقع، القصص هي عرض شرائح بمعنى الكلمة. عادةً ما يتم عرض صورة أو فيديو يتم تشغيله تلقائيًا على البطاقات، ويمكن أن تحتوي على نص إضافي في الأعلى. في ما يلي ما سننشئه:

قائمة الميزات

  • البطاقات التي تتضمّن صورة أو فيديو في الخلفية
  • يمكن التمرير سريعًا إلى اليمين أو اليسار للتنقّل في القصة.
  • تشغيل الفيديوهات تلقائيًا
  • القدرة على إضافة نص أو تخصيص بطاقات أخرى

فيما يتعلق بتجربة مطور هذا المكون، سيكون من الجيد تحديد بطاقات القصص في ترميز HTML العادي، على النحو التالي:

<story-viewer>
  <story-card>
    <img slot="media" src="some/image.jpg" />
    <h1>Title</h1>
  </story-card>
  <story-card>
    <video slot="media" src="some/video.mp4" loop playsinline></video>
    <h1>Whatever</h1>
    <p>I want!</p>
  </story-card>
</story-viewer>

لذا دعنا نضيف ذلك أيضًا إلى قائمة الميزات.

قائمة الميزات

  • اقبل سلسلة من البطاقات في ترميز HTML.

بهذه الطريقة، يمكن لأي شخص استخدام مكوّن القصة من خلال كتابة HTML. يعتبر هذا مفيدًا للمبرمجين وغير المبرمجين على حد سواء، كما يمكن استخدام هذا البرنامج في أي مكان تستخدمه لغة HTML: أنظمة إدارة المحتوى وأُطر العمل وغيرها.

المتطلبات الأساسية

  • بيئة سطر أوامر يمكنك من خلالها تشغيل git وnpm
  • محرر النصوص

2. الإعداد

ابدأ بنسخ هذا المستودع: story-viewer-starter.

git clone git@github.com:PolymerLabs/story-viewer-starter.git

تم إعداد البيئة من قبل باستخدام عنصر إضاءة وTypeScript. ما عليك سوى تثبيت التبعيات:

npm i

بالنسبة إلى مستخدمي رمز VS، يجب تثبيت الإضافة lit-plugin للحصول على ميزات الإكمال التلقائي والتحقّق من الكتابة ووضع الروابط النصية في نماذج lit-html.

لبدء بيئة التطوير من خلال تنفيذ:

npm run dev

أنت جاهز لبدء الترميز!

3- المكوّن <story-card>

عند إنشاء مكونات مركبة، يكون من الأسهل أحيانًا البدء بالمكونات الفرعية الأسهل ثم التوسّع. لنبدأ إذًا بإنشاء <story-card>. ويجب أن يكون بالإمكان عرض صورة أو فيديو لا يتجاوز الهوامش. يجب أن يتمكّن المستخدمون من تخصيصه بشكل أكبر، على سبيل المثال، باستخدام نص متراكب.

الخطوة الأولى هي تحديد فئة المكون، والتي تمتد إلى LitElement. يتولى العنصر الزخرفي customElement تسجيل العنصر المخصّص بالنيابة عنا. حان الوقت الآن للتأكّد من تفعيل أدوات التصميم في تنسيق tsconfig باستخدام العلامة experimentalDecorators (إذا كنت تستخدم مستودع أدوات التفعيل، هذا يعني أنّه سبق أن تم تفعيله).

ضع الرمز التالي في story-card.ts:

import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-card')
export class StoryCard extends LitElement {
}

أصبح <story-card> الآن عنصرًا مخصّصًا قابلاً للاستخدام، ولكن ما مِن عناصر لعرضها حتى الآن. لتحديد البنية الداخلية للعنصر، حدِّد طريقة مثيل render. سنقدّم في هذا المكان نموذج العنصر باستخدام علامة html في lit-html.

ماذا يجب أن يكون في قالب هذا المكون؟ يجب أن يكون المستخدم قادرًا على تقديم شيئين: عنصر وسائط وتراكب. لذا، سنضيف <slot> واحدًا لكل منها.

وتكون الفتحات هي الطريقة التي نحدّد بها كيفية عرض العناصر الفرعية لعنصر مخصّص. لمزيد من المعلومات، إليك جولة تفصيلية رائعة حول استخدام آلات الخانة.

import { html } from 'lit';

export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media">
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}

سيساعدنا فصل عنصر الوسائط في خانته الخاصة على استهداف هذا العنصر لإجراء إجراءات مثل إضافة تصميم ملء الشاشة وتشغيل الفيديوهات تلقائيًا. ضع الخانة الثانية (الخانة الخاصة بالتراكبات المخصصة) داخل عنصر الحاوية حتى نتمكن من توفير بعض المساحة المتروكة الافتراضية لاحقًا.

يمكن الآن استخدام المكوّن <story-card> على النحو التالي:

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>

ولكن يبدو أنّه سيء:

مُشاهد قصة بلا نمط يعرض صورة قهوة

إضافة نمط

لنضيف بعض الأنماط. باستخدام lit-element، ننفّذ ذلك من خلال تحديد سمة styles ثابتة وعرض سلسلة نموذج تم وضع علامة عليها باستخدام css. لا ينطبق أي رمز CSS مكتوب هنا إلا على العنصر المخصّص. ومن السهل استخدام CSS مع shadow DOM بهذه الطريقة.

لنصمِّم عنصر الوسائط المخصّص للإعلانات التي تُعرَض في مساحات محددة ليغطّي <story-card>. بينما نحن هنا، يمكننا توفير بعض التنسيق الرائع للعناصر في الخانة الثانية. بهذه الطريقة، يمكن لمستخدمي المكوّنات إضافة بعض <h1> أو <p> أو أيّ عنصر آخر، وسيظهر لهم عنصر جميل تلقائيًا.

import { css } from 'lit';

export class StoryCard extends LitElement {
  static styles = css`
    #media {
      height: 100%;
    }
    #media ::slotted(*) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    /* Default styles for content */
    #content {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      padding: 48px;
      font-family: sans-serif;
      color: white;
      font-size: 24px;
    }
    #content > slot::slotted(*) {
      margin: 0;
    }
  `;
}

عارض قصص مُصمّم يعرض صورة قهوة

لدينا الآن بطاقات قصص تتضمّن وسائط في الخلفية، ويمكننا وضع ما نريد فوقها. أحسنت. وسنعود بعد قليل إلى الفئة StoryCard لتنفيذ الفيديوهات التي يتم تشغيلها تلقائيًا.

4. مكوِّن <story-viewer>

عنصر <story-viewer> هو العنصر الرئيسي لعناصر <story-card>. سيكون مسئولاً عن تخطيط البطاقات أفقيًا والسماح لنا بالتمرير بينها. سنُطلق الإعداد نفسه الذي اتّبعناه مع "StoryCard". نريد إضافة بطاقات القصص كعناصر ثانوية للعنصر <story-viewer>، لذا أضِف خانة لهؤلاء الأطفال.

ضع الرمز التالي في story-viewer.ts:

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-viewer')
export class StoryViewer extends LitElement {
  render() {
    return html`<slot></slot>`;
  }
}

في ما يلي تخطيط أفقي. يمكننا معالجة ذلك من خلال منح جميع <story-card> المُدرَجة في الفتحات موضعًا مطلقًا وترجمتها وفقًا لفهرسها. يمكننا استهداف عنصر <story-viewer> نفسه باستخدام أداة اختيار :host.

static styles = css`
  :host {
    display: block;
    position: relative;
    /* Default size */
    width: 300px;
    height: 800px;
  }
  ::slotted(*) {
    position: absolute;
    width: 100%;
    height: 100%;
  }`;

يمكن للمستخدم التحكّم في حجم بطاقات القصص من خلال إلغاء الارتفاع والعرض التلقائيَين على المضيف. مثال:

story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}

لتتبُّع البطاقة المعروضة حاليًا، لنضيف متغيّر مثيل index إلى فئة StoryViewer. سيؤدي تزيينه باستخدام @property من LitElement إلى إعادة عرض المكوّن كلما تغيّرت قيمته.

import { property } from 'lit/decorators.js';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

يجب ترجمة كل بطاقة أفقيًا إلى موضعها. لنطبّق هذه الترجمات في update طريقة دورة حياة عنصر الإضاءة. سيتم تشغيل طريقة التحديث كلما تغيرت خاصية تم رصدها لهذا المكون. عادةً ما نبحث عن الخانة ونكرّر العملية slot.assignedElements() مرة. ومع ذلك، بما أنّ لدينا خانة واحدة فقط بدون اسم، هذا يعادل استخدام this.children. لنستخدم this.children لتسهيل المحادثة.

import { PropertyValues } from 'lit';

export class StoryViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    const width = this.clientWidth;
    Array.from(this.children).forEach((el: Element, i) => {
      const x = (i - this.index) * width;
      (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
    });
    super.update(changedProperties);
  }
}

تم ترتيب <story-card> الآن في صف واحد. لا يزال هذا الأسلوب يعمل مع العناصر الأخرى كعناصر ثانوية، ما دام قد تم الاعتناء بتصميمها بشكل مناسب:

<story-viewer>
  <!-- A regular story-card child... -->
  <story-card>
    <video slot="media" src="some/video.mp4"></video>
    <h1>This video</h1>
    <p>is so cool.</p>
  </story-card>
  <!-- ...and other elements work too! -->
  <img style="object-fit: cover" src="some/img.png" />
</story-viewer>

انتقِل إلى build/index.html وأزِل التعليق من بقية عناصر بطاقة القصة. الآن، دعنا نجعلها حتى نتمكن من الانتقال إليها!

5- شريط التقدم والتنقل

بعد ذلك، سنضيف طريقة للتنقّل بين البطاقات وشريط تقدّم.

لنضيف بعض الدوالّ المساعِدة إلى StoryViewer للتنقّل في القصة. سيقومون بضبط الفهرس لنا مع تثبيته على نطاق صالح.

في story-viewer.ts، في الصف StoryViewer، أضِف:

/** Advance to the next story card if possible **/
next() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index + 1));
}

/** Go back to the previous story card if possible **/
previous() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index - 1));
}

لتوفير ميزة التنقّل للمستخدم النهائي، سنضيف زرَّي "السابق" و"التالي" إلى <story-viewer>. عند النقر على أي زر، نريد استدعاء الوظيفة المساعدة next أو previous. ويسهِّل lit-html إضافة برامج معالجة الأحداث إلى العناصر، حيث يمكننا عرض الأزرار وإضافة أداة معالجة نقرات في الوقت نفسه.

عدِّل طريقة render إلى ما يلي:

export class StoryViewer extends LitElement {
  render() {
    return html`
      <slot></slot>

      <svg id="prev" viewBox="0 0 10 10" @click=${() => this.previous()}>
        <path d="M 6 2 L 4 5 L 6 8" stroke="#fff" fill="none" />
      </svg>
      <svg id="next" viewBox="0 0 10 10" @click=${() => this.next()}>
        <path d="M 4 2 L 6 5 L 4 8" stroke="#fff" fill="none" />
      </svg>
    `;
  }
}

اطّلِع على كيفية إضافة مستمعي الأحداث مضمّنةً في أزرار svg الجديدة، مباشرةً في طريقة render. وينطبق ذلك على أيّ حدث. ما عليك سوى إضافة ارتباط للنموذج @eventname=${handler} إلى عنصر.

أضِف ما يلي إلى السمة static styles لضبط أسلوب الأزرار:

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}

بالنسبة إلى شريط التقدم، سنستخدم شبكة CSS لتصميم مربّعات صغيرة، واحدة لكل بطاقة قصة. يمكننا استخدام السمة index لإضافة فئات بشكل مشروط إلى المربّعات للإشارة إلى ما إذا تمت "تمّت رؤيتها" أم لا. يمكننا استخدام تعبير شرطي مثل i <= this.index : 'watched': ''، ولكن قد تصبح الأمور مفصّلة إذا أضفنا المزيد من الفئات. لحسن الحظ، توفّر مكتبة lit-html توجيهًا يُسمى classMap للمساعدة في حلّ هذه المشكلة. أولاً، استيراد classMap:

import { classMap } from 'lit/directives/class-map';

أضِف الترميز التالي أسفل طريقة render:

<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${() => this.index = i}
    ></div>`
  )}
</div>

أضفنا أيضًا المزيد من عناصر معالجة النقرات حتى يتمكّن المستخدمون من الانتقال مباشرةً إلى بطاقة قصة معيّنة إذا أرادوا ذلك.

في ما يلي الأنماط الجديدة التي يمكن إضافتها إلى static styles:

::slotted(*) {
  position: absolute;
  width: 100%;
  /* Changed this line! */
  height: calc(100% - 20px);
}

#progress {
  position: relative;
  top: calc(100% - 20px);
  height: 20px;
  width: 50%;
  margin: 0 auto;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  grid-gap: 10px;
  align-content: center;
}
#progress > div {
  background: grey;
  height: 4px;
  transition: background 0.3s linear;
  cursor: pointer;
}
#progress > div.watched {
  background: white;
}

اكتمال شريط التنقّل وشريط التقدّم الآن لنضيف بعض التمهيد!

6- التمرير السريع

لتنفيذ التمرير السريع، سنستخدم مكتبة التحكم بالإيماءات Hammer.js. يرصد Hammer إيماءات خاصة، مثل التمرير السريع، ويرسل الأحداث التي تتضمّن معلومات ذات صلة (مثل دلتا X) يمكننا استخدامها.

إليك كيفية استخدام المطرقة لاكتشاف التحريك، وتحديث العنصر تلقائيًا عند حدوث تحريك:

import { state } from 'lit/decorators.js';
import 'hammerjs';

export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @state() _panData: {isFinal?: boolean, deltaX?: number} = {};

  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', (e: HammerInput) => this._panData = e);
  }
}

إنّ مُنشئ فئة LitElement هو مكان رائع آخر لإرفاق أدوات معالجة الأحداث بالعنصر المضيف نفسه. يأخذ مُنشئ Hammer عنصرًا لرصد الإيماءات عليه. وفي هذه الحالة، هي StoryViewer نفسها أو this. بعد ذلك، باستخدام واجهة برمجة تطبيقات Hammer، نطلب من Hammer رصد إيماءة "تمرير سريع" وضبط معلومات التمرير السريع على سمة _panData جديدة.

من خلال تزيين السمة _panData باستخدام @state، سيلاحظ LitElement التغييرات في _panData ويجري تعديلاً، ولكن لن يتم ربط سمة HTML للسمة.

بعد ذلك، لنوسّع منطق update لاستخدام بيانات التمرير:

// Update is called whenever an observed property changes.
update(changedProperties: PropertyValues) {
  // deltaX is the distance of the current pan gesture.
  // isFinal is whether the pan gesture is ending.
  let { deltaX = 0, isFinal = false } = this._panData;
  // When the pan gesture finishes, navigate.
  if (!changedProperties.has('index') && isFinal) {
    deltaX > 0 ? this.previous() : this.next();
  }
  // We don't want any deltaX when releasing a pan.
  deltaX = isFinal ? 0 : deltaX;
  const width = this.clientWidth;
  Array.from(this.children).forEach((el: Element, i) => {
    // Updated this line to utilize deltaX.
    const x = (i - this.index) * width + deltaX;
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
  });

  // Don't forget to call super!
  super.update(changedProperties);
}

يمكننا الآن سحب بطاقات القصص ذهابًا وإيابًا. لتسهيل الأمور، دعنا نعود إلى static get styles ونضيف transition: transform 0.35s ease-out; إلى أداة اختيار ::slotted(*):

::slotted(*) {
  ...
  transition: transform 0.35s ease-out;
}

أصبح بإمكانك الآن التمرير بسلاسة:

التنقّل بين بطاقات القصص من خلال التمرير السريع بسلاسة

7- تشغيل تلقائي

الميزة الأخيرة التي سنضيفها هي التشغيل التلقائي للفيديوهات. عندما تظهر بطاقة قصة في المقدّمة، نريد تشغيل الفيديو في الخلفية، إذا كان متوفّرًا. عندما تركّز بطاقة قصة على تركيز الفيديو، يجب أن نوقف الفيديو مؤقتًا.

وسننفّذ ذلك من خلال إرسال الحدثَين المخصّصَين "تم الدخول" و"تم الخروج" إلى الصفحات الفرعية المناسبة عند تغيير الفهرس. في StoryCard، سنتلقّى هذه الأحداث وسنشغّل أي فيديوهات حالية أو نوقفها مؤقتًا. لماذا نختار إرسال الأحداث إلى العناصر الثانوية بدلاً من استدعاء طرق المثيل "entered" و"exited" المحددة في StoryCard؟ باستخدام الطرق، لن يكون لدى مستخدمي المكون خيار سوى كتابة عنصر مخصص إذا أرادوا كتابة بطاقة قصة خاصة بهم باستخدام الرسوم المتحركة المخصصة. وباستخدام الأحداث، يمكنهم فقط إرفاق مستمع للحدث!

لنعيد ضبط هذه السمة في السمة index في StoryViewer لاستخدام دالة setter توفّر مسارًا مناسبًا لإرسال الأحداث:

class StoryViewer extends LitElement {
  @state() private _index: number = 0
  get index() {
    return this._index
  }
  set index(value: number) {
    this.children[this._index].dispatchEvent(new CustomEvent('exited'));
    this.children[value].dispatchEvent(new CustomEvent('entered'));
    this._index = value;
  }
}

لإكمال ميزة التشغيل التلقائي، سنضيف مستمعي أحداث "entered" و"exited" في أداة إنشاء StoryCard التي تشغّل الفيديو وتوقّفه مؤقتًا.

تذكَّر أنّ مستخدم المكوِّن قد يعطي أو لا يمنح <story-card> عنصر فيديو في خانة الوسائط. وقد لا توفِّر حتى عنصرًا أو عنصرًا في خانة الوسائط على الإطلاق. يجب أن نحرص على عدم استدعاء play على img أو على null.

في ملف story-card.ts، أضِف ما يلي:

import { query } from 'lit/decorators.js';

class StoryCard extends LitElement {
  constructor() {
    super();

    this.addEventListener("entered", () => {
      if (this._slottedMedia) {
        this._slottedMedia.currentTime = 0;
        this._slottedMedia.play();
      }
    });

    this.addEventListener("exited", () => {
      if (this._slottedMedia) {
        this._slottedMedia.pause();
      }
    });
  }

 /**
  * The element in the "media" slot, ONLY if it is an
  * HTMLMediaElement, such as <video>.
  */
 private get _slottedMedia(): HTMLMediaElement|null {
   const el = this._mediaSlot && this._mediaSlot.assignedNodes()[0];
   return el instanceof HTMLMediaElement ? el : null;
 }

  /**
   * @query(selector) is shorthand for
   * this.renderRoot.querySelector(selector)
   */
  @query("slot[name=media]")
  private _mediaSlot!: HTMLSlotElement;
}

اكتملت عملية التشغيل التلقائي. ✅

8. تحكَّم في الميزان

بعد أن أضفنا جميع الميزات الأساسية، لنضيف تأثيرًا آخر: تأثير تكبير/تصغير رائع. لنعود مرة أخرى إلى طريقة update في StoryViewer. يتم إجراء بعض العمليات الحسابية للحصول على القيمة في ثابت scale. ستكون مساوية لـ 1.0 للعنصر الفرعي النشط وminScale في غير ذلك، مع إجراء الاستقراء بين هاتين القيمتَين أيضًا.

غيِّر التكرار الحلقي في طريقة update في story-viewer.ts ليصبح:

update(changedProperties: PropertyValues) {
  // ...
  const minScale = 0.8;
  Array.from(this.children).forEach((el: Element, i) => {
    const x = (i - this.index) * width + deltaX;

    // Piecewise scale(deltaX), looks like: __/\__
    const u = deltaX / width + (i - this.index);
    const v = -Math.abs(u * (1 - minScale)) + 1;
    const scale = Math.max(v, minScale);
    // Include the scale transform
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0) scale(${scale})`;
  });
  // ...
}

لا يوجد المزيد. لقد غطّينا في هذه المشاركة الكثير من المواضيع، بما في ذلك بعض ميزات LitElement وlit-html وعناصر خانات HTML والتحكّم في الإيماءات.

للحصول على إصدار كامل من هذا المكوّن، يُرجى الانتقال إلى: https://github.com/PolymerLabs/story-viewer.