یک کامپوننت داستان با عنصر روشن بسازید

۱. مقدمه

داستان‌ها این روزها یک کامپوننت رابط کاربری محبوب هستند. برنامه‌های اجتماعی و خبری آنها را در فیدهای خود ادغام می‌کنند. در این آزمایشگاه کد، ما یک کامپوننت داستان با 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 از کامپوننت story ما استفاده کند. این برای برنامه‌نویسان و غیربرنامه‌نویسان به طور یکسان عالی است و در هر جایی که HTML کار می‌کند، کار می‌کند: سیستم‌های مدیریت محتوا، فریم‌ورک‌ها و غیره.

پیش‌نیازها

  • یک پوسته که می‌توانید در آن git و npm را اجرا کنید
  • یک ویرایشگر متن

۲. راه‌اندازی

با کپی کردن این مخزن شروع کنید: story-viewer-starter

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

محیط از قبل با lit-element و TypeScript تنظیم شده است. فقط وابستگی‌ها را نصب کنید:

npm i

کاربران VS Code، برای دریافت تکمیل خودکار، بررسی نوع و linting قالب‌های lit-html، افزونه lit-plugin را نصب کنند.

محیط توسعه را با اجرای دستور زیر شروع کنید:

npm run dev

شما آماده شروع کدنویسی هستید!

۳. کامپوننت <story-card>

هنگام ساخت کامپوننت‌های مرکب، گاهی اوقات آسان‌تر است که با زیرکامپوننت‌های ساده‌تر شروع کنیم و آن‌ها را گسترش دهیم. بنابراین، بیایید با ساخت <story-card> شروع کنیم. این کامپوننت باید بتواند یک ویدیو یا یک تصویر را با حاشیه کامل نمایش دهد. کاربران باید بتوانند آن را بیشتر سفارشی کنند، مثلاً با متن روی آن.

اولین قدم تعریف کلاس کامپوننت ما است که LitElement ارث‌بری می‌کند. دکوراتور customElement وظیفه ثبت عنصر سفارشی را برای ما بر عهده دارد. اکنون زمان مناسبی است که مطمئن شوید دکوراتورها را در tsconfig خود با پرچم experimentalDecorators فعال کرده‌اید (اگر از مخزن starter استفاده می‌کنید، از قبل فعال شده است).

کد زیر را در story-card.ts قرار دهید:

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

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

اکنون <story-card> یک عنصر سفارشی قابل استفاده است، اما هنوز چیزی برای نمایش وجود ندارد. برای تعریف ساختار داخلی عنصر، متد render instance را تعریف کنید. در اینجا با استفاده از تگ html مربوط به lit-html، قالب عنصر را ارائه خواهیم داد.

چه چیزهایی باید در قالب این کامپوننت وجود داشته باشد؟ کاربر باید بتواند دو چیز ارائه دهد: یک عنصر رسانه‌ای و یک پوشش. بنابراین، برای هر یک از آنها یک <slot> اضافه خواهیم کرد.

اسلات‌ها (slots) روشی هستند که ما مشخص می‌کنیم فرزندان یک عنصر سفارشی چگونه باید رندر شوند. برای اطلاعات بیشتر، در اینجا یک راهنمای عالی در مورد استفاده از اسلات‌ها (slots) ارائه شده است.

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>
    `;
  }
}

جدا کردن عنصر رسانه در جایگاه مخصوص به خود، به ما کمک می‌کند تا آن عنصر را برای مواردی مانند اضافه کردن استایل full-bleed و پخش خودکار ویدیوها هدف قرار دهیم. جایگاه دوم (جایگاه مربوط به overlay های سفارشی) را درون یک عنصر container قرار دهید تا بتوانیم بعداً مقداری padding پیش‌فرض ارائه دهیم.

اکنون می‌توان از کامپوننت <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 برمی‌گردیم تا پخش خودکار ویدیوها را پیاده‌سازی کنیم.

۴. کامپوننت <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 از lit-element اعمال کنیم. متد 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 بروید و بقیه عناصر story-card را از حالت کامنت خارج کنید . حالا، بیایید کاری کنیم که بتوانیم به آنها دسترسی پیدا کنیم!

۵. نوار پیشرفت و ناوبری

در مرحله بعد، روشی برای پیمایش بین کارت‌ها و یک نوار پیشرفت اضافه خواهیم کرد.

بیایید چند تابع کمکی برای پیمایش داستان به 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 grid برای استایل‌دهی به کادرهای کوچک، یکی برای هر کارت داستان، استفاده خواهیم کرد. می‌توانیم از ویژگی 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;
}

نوار ناوبری و نوار پیشرفت تکمیل شد. حالا بیایید کمی جلوه و زیبایی به آن اضافه کنیم!

۶. کشیدن انگشت

برای پیاده‌سازی سوایپ، از کتابخانه کنترل حرکات Hammer.js استفاده خواهیم کرد. Hammer حرکات خاص مانند pan را تشخیص می‌دهد و رویدادها را با اطلاعات مرتبط (مانند delta X) که می‌توانیم استفاده کنیم، ارسال می‌کند.

در اینجا نحوه استفاده از Hammer برای تشخیص panها و به‌روزرسانی خودکار عنصرمان هر زمان که یک رویداد pan رخ می‌دهد، آورده شده است:

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 است. سپس، با استفاده از API مربوط به Hammer، به آن می‌گوییم که حرکت "pan" را تشخیص دهد و اطلاعات pan را روی یک ویژگی جدید _panData قرار دهد.

با اضافه کردن خاصیت _panData به @state ، LitElement تغییرات در _panData را مشاهده کرده و به‌روزرسانی را انجام می‌دهد، اما هیچ ویژگی HTML مرتبطی برای این خاصیت وجود نخواهد داشت.

در مرحله بعد، بیایید منطق update را برای استفاده از داده‌های pan تقویت کنیم:

// 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;
}

حالا ما سوایپ نرم و روان داریم:

پیمایش بین کارت‌های داستان با کشیدن روان

۷. پخش خودکار

آخرین ویژگی که اضافه خواهیم کرد، پخش خودکار ویدیوها است. وقتی یک کارت داستانی وارد فوکوس می‌شود، می‌خواهیم ویدیوی پس‌زمینه، در صورت وجود، پخش شود. وقتی یک کارت داستانی از فوکوس خارج می‌شود، باید ویدیوی آن را متوقف کنیم.

ما این کار را با ارسال رویدادهای سفارشی 'entered' و 'exited' به فرزندان مربوطه، هر زمان که شاخص تغییر کند، پیاده‌سازی خواهیم کرد. در 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;
  }
}

برای تکمیل قابلیت پخش خودکار، شنونده‌های رویداد «وارد شده» و «خروج شده» را در سازنده StoryCard اضافه می‌کنیم که ویدیو را پخش و متوقف می‌کنند.

به یاد داشته باشید که کاربر کامپوننت ممکن است به <story-card> یک عنصر ویدیویی در بخش رسانه بدهد یا ندهد. حتی ممکن است اصلاً عنصری در بخش رسانه ارائه ندهد. باید مراقب باشیم که play روی یک تصویر یا روی 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;
}

پخش خودکار کامل شد. ✅

۸. ترازو را سنگین کنید

حالا که همه ویژگی‌های ضروری را داریم، بیایید یک ویژگی دیگر اضافه کنیم: یک افکت مقیاس‌بندی جذاب. بیایید یک بار دیگر به متد 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 مراجعه کنید.