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

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

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

npm i

برای کاربران VS Code، افزونه 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 instance را تعریف کنید. اینجاست که با استفاده از تگ 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 با سایه 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> s است. مسئول قرار دادن کارت ها به صورت افقی و اجازه دادن به ما بین آنها خواهد بود. ما آن را به همان روشی که برای 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 اعمال کنیم. هر زمان که ویژگی مشاهده شده این مؤلفه تغییر کند، روش به روز رسانی اجرا می شود. معمولاً شکاف را پرس و جو می کنیم و روی 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) که می توانیم مصرف کنیم ارسال می کند.

در اینجا نحوه استفاده از 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's API، به آن می‌گوییم ژست «pan» را تشخیص دهد و اطلاعات پان را روی ویژگی _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 را برای استفاده از یک تنظیم کننده، که یک مسیر کد مناسب برای ارسال رویدادها فراهم می کند، اصلاح کنیم:

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 روی 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 انجام می شود. برای فرزند فعال و minScale برابر با 1.0 خواهد بود، در غیر این صورت، بین این دو مقدار نیز درون یابی می شود.

حلقه را در متد 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 دیدن کنید.