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
تم إعداد البيئة مسبقًا باستخدام lit-element وTypeScript. ما عليك سوى تثبيت التبعيات:
npm i
بالنسبة إلى مستخدمي VS Code، ثبِّت المكوّن الإضافي lit-plugin للحصول على ميزة الإكمال التلقائي والتحقّق من النوع والتحليل باستخدام أداة Lint في قوالب 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>s في الفتحات موضعًا مطلقًا، وترجمتها وفقًا لفهرسها. يمكننا استهداف العنصر <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 في lit-element. سيتم تشغيل طريقة التعديل كلما تغيّرت إحدى خصائص هذا المكوّن التي يتم رصدها. عادةً، نطلب الحصول على الفتحة ونكرّر 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>s لدينا الآن كلها في صف واحد. لا يزال بإمكانك استخدامها مع عناصر أخرى كعناصر فرعية، طالما أنّك تحرص على تنسيقها بشكل مناسب:
<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) التي يمكننا استخدامها.
في ما يلي كيفية استخدام Hammer لرصد عمليات التحريك وتعديل العنصر تلقائيًا كلّما حدثت عملية تحريك:
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، نطلب منها رصد إيماءة "التحريك"، ونضبط معلومات التحريك على السمة _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 لاستخدام دالة ضبط، ما يوفّر مسارًا مناسبًا للرمز البرمجي لإرسال الأحداث:
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 أو على قيمة فارغة.
في ملف 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.