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

1. مقدمة

تشكّل القصص مكوّنًا رائجًا في واجهة المستخدم في الوقت الحالي. وتعمل تطبيقات الأخبار الاجتماعية وتطبيقات الأخبار على دمج هذه المواد في خلاصاتها. سننشئ في هذا الدرس التطبيقي حول الترميز مكوّنًا للقصة باستخدام عنصر lit- و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 Code، يجب تثبيت الإضافة lit-Plugins للحصول على ميزات الإكمال التلقائي والتحقّق من الكتابة ووضع الروابط النصية في نماذج lit-html.

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

npm run dev

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

3- <بطاقة القصة> المكوّن

عند بناء المكونات المركبة، يكون من الأسهل في بعض الأحيان البدء بالمكونات الفرعية الأكثر بساطة والبناء. إذًا، هيا بنا نبدأ بإنشاء <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>

لكن، يبدو الأمر مرعبًا:

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

جارٍ إضافة النمط

لنضيف بعض الأنماط. في ما يتعلّق بالعنصر المضاء، يمكننا إجراء ذلك من خلال تحديد سمة 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. ترصد المطرقة الإيماءات الخاصة، مثل المقالي، وترسل الأحداث التي تتضمن معلومات ذات صلة (مثل دلتا 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 مكانًا رائعًا آخر لإرفاق أدوات معالجة الأحداث على العنصر المضيف نفسه. تأخذ الدالة الإنشائية المطرقة عنصرًا لاكتشاف الإيماءات عليها. وفي هذه الحالة، هي StoryViewer نفسها أو this. وبعد ذلك، وباستخدام واجهة برمجة تطبيقات 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، سنستلم هذه الأحداث ونشغّل أي فيديوهات حالية أو نوقفها مؤقتًا. لماذا نختار إرسال الأحداث إلى الأطفال بدلاً من استدعاء "دخل الأطفال" و"خروج" المثيل المحدد في 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;
  }
}

لإنهاء ميزة التشغيل التلقائي، سنضيف أدوات معالجة الأحداث في الحقل "تم الدخول" و"خرجت" في الدالة الإنشائية StoryCard التي تعمل على تشغيل الفيديو وإيقافه مؤقتًا.

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

مرة أخرى في 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.