בניית Chrometober!

איך ספר הגלילה מתעורר לחיים בעקבות שיתוף טיפים וטריקים כיפיים ומפחידים ב-Chrometober הזה.

בהמשך ל-designcember, רצינו לפתח את Chrometober השנה כדי להדגיש ולשתף תוכן מהאינטרנט מהקהילה והצוות של Chrome. ב-Designcember הוצג השימוש בשאילתות של מאגרים, אבל השנה אנחנו מציגים את ממשק ה-API של אנימציות מקושרות לגלילה ב-CSS.

אפשר לבדוק את חוויית הקריאה בספרים בweb.dev/chrometober-2022.

סקירה כללית

מטרת הפרויקט הייתה לספק חוויה שובבה שמציגה את ממשק ה-API של האנימציות המקושרות לגלילה. עם זאת, בנוסף למראה השובב, החוויה צריכה להיות גם רספונסיבית ונגישה. הפרויקט גם שימש כדרך מצוינת לבדוק את ה-polyfill של ה-API שנמצא בפיתוח פעיל, וגם לנסות שילובים של שיטות וכלים שונים. והכול בנושא חגיגי של האלווין!

מבנה הצוות שלנו נראה כך:

ליצור טיוטה של גלילה

הרעיונות ל-Chrometober החלו לזרום במהלך הפגישה הראשונה של הצוות מחוץ למשרדים, במאי 2022. אוסף של שרבוטים גרם לנו לחשוב על דרכים שבהן משתמש יכול לגלול את הדרך על פני לוח סטוריבורד. בהשראת משחקי וידאו, הצענו חוויית גלילה דרך סצנות כמו בתי קברות ובתים רדופים.

מחברות שוכבת על שולחן עם שרבוטים שונים שקשורים לפרויקט.

היה מרגש לקבל את החופש היצירתי להוביל את הפרויקט הראשון שלי ב-Google לכיוון בלתי צפוי. זה היה אב-טיפוס מוקדם של האופן שבו משתמש יכול לנווט בתוכן.

כשהמשתמש גולל לצדדים, הבלוק מסתובב ומתכווץ. אבל החלטתי לוותר על הרעיון הזה כי לא ידעתי איך נוכל להפוך את החוויה הזו למושלמת למשתמשים במכשירים בכל הגדלים. במקום זאת, התמקדתי בעיצוב של משהו שיצרתי בעבר. בשנת 2020, הייתה לי הזדמנות לקבל גישה ל-ScrollTrigger של GreenSock כדי ליצור הדגמות של גרסאות זמינות.

אחת מהדמואים שיצרתי הייתה ספר ב-3D-CSS שבו הדפים נפתחים בזמן הגלילה, וזה הרגיש הרבה יותר מתאים למה שרצינו ל-Chrometober. ממשק ה-API של אנימציות שמקושרות לגלילה הוא תחליף מושלם לפונקציונליות הזו. הוא עובד טוב גם עם scroll-snap, כפי שאפשר לראות.

המאייר שלנו בפרויקט, טיילר ריד (Tyler Reed), הצליח לשנות את העיצוב בהתאם לשינויים ברעיונות שלנו. טיילר עשה עבודה נהדרת בהפיכת כל הרעיונות היצירתיים שהוצעו לו למציאות. נהניתי מאוד מסיעור המוחות המשותף. חלק גדול מהאופן שבו רצינו שהדבר יפעל היה לפצל את התכונות לקטעים מבודדים. כך יכולנו ליצור מהם סצנות ולבחור מה נעלה.

אחת מהסצנות של הקומפוזיציה, שמציגה נחש, ארון מתים עם זרועות שמגיחות ממנו, שועל עם שרביט ליד קדירה, עץ עם פנים מפחידות ודרקון מפלצתי עם נר דלוק בדלעת.

הרעיון המרכזי היה שככל שהמשתמש יתקדם בספר, הוא יוכל לגשת לקטעי תוכן. הם גם יכלו לקיים אינטראקציה עם פריטים קטנים ומצחיקים, כולל הביצים החבויות שכללנו בחוויה. לדוגמה, דיוקן בבית רדוף שמעיניו עוקבות אחרי הסמן שלכם, או אנימציות עדינות שמופעלות על ידי שאילתות מדיה. הרעיונות והתכונות האלה יוצגו באנימציה בזמן הגלילה. אחת מהרעיונות הראשונים הייתה ארנב זומבי שיתרומם וינוע לאורך ציר ה-x כשהמשתמש גולל.

היכרות עם ה-API

לפני שהצלחנו להתחיל לשחק עם תכונות בודדות וביצי פסחא, היינו צריכים ספר. לכן החלטנו לנצל את ההזדמנות הזו כדי לבדוק את קבוצת המאפיינים של CSS API לאנימציות שמקושרות לגלילה, שאנחנו מפתחים. כרגע אין תמיכה ב-API של אנימציות מקושרות לגלילה באף דפדפן. עם זאת, במהלך הפיתוח של ה-API, המהנדסים בצוות האינטראקציות עבדו על polyfill. כך ניתן לבחון את צורת ה-API בזמן שהיא מתפתחת. כלומר, אנחנו יכולים להשתמש ב-API הזה כבר היום, ופרויקטים מהנים כמו זה הם לרוב מקום מצוין לנסות תכונות ניסיוניות ולשלוח משוב. בהמשך המאמר מוסבר מה למדנו ומה המשוב שיכולנו לספק.

באופן כללי, אפשר להשתמש ב-API הזה כדי לקשר אנימציות לגלילה. חשוב לציין שאי אפשר להפעיל אנימציה בזמן גלילה – יכול להיות שזו תהיה תכונה שנוספה בהמשך. גם אנימציות שמקושרות לגלילה נחלקות לשתי קטגוריות עיקריות:

  1. אלה שמגיבים למיקום הגלילה.
  2. אלה שמגיבים למיקום של אלמנט במארז הגלילה שלו.

כדי ליצור את השיטה השנייה, אנחנו משתמשים ב-ViewTimeline שהוחל דרך נכס animation-timeline.

כך ייראה השימוש ב-ViewTimeline ב-CSS:

.element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
}

.element-scroll-linked {
  animation: rotate both linear;
  animation-timeline: foo;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
}

@keyframes rotate {
 to {
   rotate: 360deg;
 }
}

יוצרים ViewTimeline עם view-timeline-name ומגדירים את הציר שלו. בדוגמה הזו, block מתייחס ל-block לוגי. האנימציה מקושרת לגלילה באמצעות המאפיין animation-timeline. animation-delay ו-animation-end-delay (בזמן הכתיבה) הם האופן שבו אנחנו מגדירים שלבים.

השלבים האלה מגדירים את הנקודות שבהן האנימציה צריכה להיות מקושרת ביחס למיקום הרכיב בקונטיינר הגלילה שלו. בדוגמה שלנו, אנחנו אומרים להתחיל את האנימציה כשהרכיב נכנס (enter 0%) למאגר הגלילה. והיא תסתיים כשהיא תכלול 50% (cover 50%) ממאגר התמונות שגלול.

הדגמה שלנו בפעולה:

אפשר גם לקשר אנימציה לרכיב שנע בחלון התצוגה. כדי לעשות זאת, מגדירים את animation-timeline כ-view-timeline של הרכיב. האפשרות הזו מתאימה למקרים כמו אנימציות של רשימות. ההתנהגות דומה לאופן שבו אפשר להוסיף אנימציה לרכיבים כשהם נכנסים באמצעות IntersectionObserver.

element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
  animation: scale both linear;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
  animation-timeline: foo;
}

@keyframes scale {
  0% {
    scale: 0;
  }
}

כך, הרכיב 'Mover' יתרחב כשהוא ייכנס לאזור התצוגה, ויפעיל את הסיבוב של הרכיב 'Spinner'.

מהניסויים שלי הבנתי שה-API פועל בצורה טובה מאוד עם scroll-snap. שילוב של 'הצמדה בגלילה' עם ViewTimeline יתאים מאוד לצילום של דפים שנפתחים בספר.

יצירת אבטיפוס של המנגנון

אחרי כמה ניסויים, הצלחתי ליצור אב טיפוס של ספר שפועל. גוללים לרוחב כדי להפוך את דפי הספר.

בהדגמה אפשר לראות את הטריגרים השונים בצורה מודגשת בגבולות מקווקווים.

תג העיצוב נראה קצת כך:

<body>
  <div class="book-placeholder">
    <ul class="book" style="--count: 7;">
      <li
        class="page page--cover page--cover-front"
        data-scroll-target="1"
        style="--index: 0;"
      >
        <div class="page__paper">
          <div class="page__side page__side--front"></div>
          <div class="page__side page__side--back"></div>
        </div>
      </li>
      <!-- Markup for other pages here -->
    </ul>
  </div>
  <div>
    <p>intro spacer</p>
  </div>
  <div data-scroll-intro>
    <p>scale trigger</p>
  </div>
  <div data-scroll-trigger="1">
    <p>page trigger</p>
  </div>
  <!-- Markup for other triggers here -->
</body>

כשגוללים, הדפים בספר מסתובבים, אבל נפתחים או נסגרים בתנועה חדה. האפשרות הזו תלויה בהתאמה של הטריגרים מסוג 'הצמדה לגלילה'.

html {
  scroll-snap-type: x mandatory;
}

body {
  grid-template-columns: repeat(var(--trigger-count), auto);
  overflow-y: hidden;
  overflow-x: scroll;
  display: grid;
}

body > [data-scroll-trigger] {
  height: 100vh;
  width: clamp(10rem, 10vw, 300px);
}

body > [data-scroll-trigger] {
  scroll-snap-align: end;
}

הפעם, אנחנו לא מחברים את ViewTimeline ב-CSS, אלא משתמשים ב-Web Animations API ב-JavaScript. היתרון הנוסף של השיטה הזו הוא היכולת להריץ לולאה על קבוצת רכיבים וליצור את ה-ViewTimeline שאנחנו צריכים, במקום ליצור כל אחד מהם באופן ידני.

const triggers = document.querySelectorAll("[data-scroll-trigger]")

const commonProps = {
  delay: { phase: "enter", percent: CSS.percent(0) },
  endDelay: { phase: "enter", percent: CSS.percent(100) },
  fill: "both"
}

const setupPage = (trigger, index) => {
  const target = document.querySelector(
    `[data-scroll-target="${trigger.getAttribute("data-scroll-trigger")}"]`
  );

  const viewTimeline = new ViewTimeline({
    subject: trigger,
    axis: 'inline',
  });

  target.animate(
    [
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`
      },
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`,
        offset: 0.75
      },
      {
        transform: `translateZ(${(triggers.length - index) * -1}px)`
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
  target.querySelector(".page__paper").animate(
    [
      {
        transform: "rotateY(0deg)"
      },
      {
        transform: "rotateY(-180deg)"
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
};

const triggers = document.querySelectorAll('[data-scroll-trigger]')
triggers.forEach(setupPage);

לכל טריגר אנחנו יוצרים ViewTimeline. לאחר מכן מוסיפים אנימציה לדף המשויך לטריגר באמצעות אותו ViewTimeline. שמקשרת את האנימציה בדף לצורך גלילה. באנימציה שלנו, אנחנו מסובבים רכיב של הדף על ציר ה-y כדי להפוך את הדף. אנחנו גם מעבירים את הדף עצמו בציר z כדי שהוא יפעל כמו ספר.

סיכום של כל המידע

אחרי שפיתחתי את המנגנון של הספר, יכולתי להתמקד בהפיכת האיורים של טיילר לחיים.

Astro

הצוות השתמש ב-Astro במהלך Designcember בשנת 2021, ורציתי להשתמש בו שוב במהלך Chrometober. חוויית המפתחים, שמאפשרת לפצל דברים לרכיבים, מתאימה מאוד לפרויקט הזה.

הספר עצמו הוא רכיב. הוא גם אוסף של רכיבי דף. לכל דף יש שני צדדים ורקעים. הצאצאים של צד הדף הם רכיבים שאפשר להוסיף, להסיר ולמקם בקלות.

יצירת ספר

חשוב לי שהבלוקים יהיו נוחים לניהול. רציתי גם לאפשר לשאר חברי הצוות להוסיף תכנים בקלות.

הדפים ברמה גבוהה מוגדרים באמצעות מערך תצורה. כל אובייקט דף במערך מגדיר את התוכן, את הרקע ומטא-נתונים אחרים של דף.

const pages = [
  {
    front: {
      marked: true,
      content: PageTwo,
      backdrop: spreadOne,
      darkBackdrop: spreadOneDark
    },
    back: {
      content: PageThree,
      backdrop: spreadTwo,
      darkBackdrop: spreadTwoDark
    },
    aria: `page 1`
  },
  /* Obfuscated page objects */
]

הם מועברים לרכיב Book.

<Book pages={pages} />

הרכיב Book הוא המקום שבו מופעל מנגנון הגלילה ונוצרים דפי הספר. נעשה שימוש באותו מנגנון מהאב טיפוס, אבל אנחנו משתפים כמה מכונות של ViewTimeline שנוצרות באופן גלובלי.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

כך נוכל לשתף את צירי הזמן לשימוש במקום אחר במקום ליצור אותם מחדש. בהמשך נסביר על כך.

הרכב הדף

כל דף הוא פריט ברשימה בתוך רשימה:

<ul class="book">
  {
    pages.map((page, index) => {
      const FrontSlot = page.front.content
      const BackSlot = page.back.content
      return (
        <Page
          index={index}
          cover={page.cover}
          aria={page.aria}
          backdrop={
            {
              front: {
                light: page.front.backdrop,
                dark: page.front.darkBackdrop
              },
              back: {
                light: page.back.backdrop,
                dark: page.back.darkBackdrop
              }
            }
          }>
          {page.front.content && <FrontSlot slot="front" />}    
          {page.back.content && <BackSlot slot="back" />}    
        </Page>
      )
    })
  }
</ul>

ההגדרה שהוגדרה מועברת לכל מכונה של Page. בדפים נעשה שימוש בתכונה 'חריץ' של Astro כדי להוסיף תוכן לכל דף.

<li
  class={className}
  data-scroll-target={target}
  style={`--index:${index};`}
  aria-label={aria}
>
  <div class="page__paper">
    <div
      class="page__side page__side--front"
      aria-label={`Right page of ${index}`}
    >
      <picture>
        <source
          srcset={darkFront}
          media="(prefers-color-scheme: dark)"
          height="214"
          width="150"
        >
        <img
          src={lightFront}
          class="page__background page__background--right"
          alt=""
          aria-hidden="true"
          height="214"
          width="150"
        >
      </picture>
      <div class="page__content">
        <slot name="front" />
      </div>
    </div>
    <!-- Markup for back page -->
  </div>
</li>

הקוד הזה מיועד בעיקר להגדרת המבנה. לרוב, שותפי העריכה יכולים לעבוד על תוכן הספר בלי לגעת בקוד הזה.

רקעים

המעבר ליצירת ספר עזר לנו לפצל את הקטעים בקלות רבה יותר, וכל דף בספר הוא סצנה שנלקחה מהעיצוב המקורי.

איור של דף פתוח מהספר, שבו מוצגת עץ תפוח בבית קברות. בבית הקברות יש כמה מצבות ויש עטלף בשמיים מול ירח גדול.

מאחר שהחלטנו על יחס גובה-רוחב לספר, הרקע של כל דף יכול לכלול רכיב תמונה. כדי לפתור את הבעיה, צריך להגדיר את הרכיב הזה לרוחב של 200% ולהשתמש ב-object-position על סמך הצד של הדף.

.page__background {
  height: 100%;
  width: 200%;
  object-fit: cover;
  object-position: 0 0;
  position: absolute;
  top: 0;
  left: 0;
}

.page__background--right {
  object-position: 100% 0;
}

תוכן הדף

נראה איך יוצרים אחד מהדפים. בדף שלישי מוצגת ינשופת שמופיעה על עץ.

הוא מאוכלס ברכיב PageThree, כפי שמוגדר בתצורה. זהו רכיב אסטרונומי (PageThree.astro). הרכיבים האלה נראים כמו קובצי HTML, אבל בחלק העליון שלהם יש גדר קוד שדומה לזה של frontmatter. כך אנחנו יכולים לייבא רכיבים אחרים. הרכיב של הדף השלישי נראה כך:

---
import TreeOwl from '../TreeOwl/TreeOwl.astro'
import { contentBlocks } from '../../assets/content-blocks.json'
import ContentBlock from '../ContentBlock/ContentBlock.astro'
---
<TreeOwl/>
<ContentBlock {...contentBlocks[3]} id="four" />

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

שוב, דפים הם בעלי אופי אטומי. הם בנויים מאוסף של תכונות. בדף השלישי יש קטע תוכן ואת הינשוף האינטראקטיבי, לכן יש רכיב לכל אחד מהם.

בלוקים של תוכן הם הקישורים לתוכן שמוצג בספר. גם הם מונעים על ידי אובייקט של הגדרות אישיות.

{
 "contentBlocks": [
    {
      "id": "one",
      "title": "New in Chrome",
      "blurb": "Lift your spirits with a round up of all the tools and features in Chrome.",
      "link": "https://www.youtube.com/watch?v=qwdN1fJA_d8&list=PLNYkxOF6rcIDfz8XEA3loxY32tYh7CI3m"
    },
    …otherBlocks
  ]
}

ההגדרה הזו מיובאת למקומות שבהם נדרשים חסמי תוכן. לאחר מכן, תצורת הבלוק הרלוונטית מועברת לרכיב ContentBlock.

<ContentBlock {...contentBlocks[3]} id="four" />

הדוגמה הזו גם מראה איך אנחנו משתמשים ברכיב של הדף כמקום למיקום התוכן. כאן ממקמים את בלוק התוכן.

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

עם זאת, הסגנונות הכלליים של בלוק תוכן נמצאים באותו מיקום שבו נמצא קוד הרכיב.

.content-block {
  background: hsl(0deg 0% 0% / 70%);
  color: var(--gray-0);
  border-radius:  min(3vh, var(--size-4));
  padding: clamp(0.75rem, 2vw, 1.25rem);
  display: grid;
  gap: var(--size-2);
  position: absolute;
  cursor: pointer;
  width: 50%;
}

לגבי הינשוף שלנו, זוהי תכונה אינטראקטיבית – אחת מתוך תכונות רבות בפרויקט הזה. זוהי דוגמה קטנה ושימושית שמראה איך השתמשנו בתצוגת ציר הזמן המשותפת שיצרנו.

ברמה הכללית, רכיב הינשוף שלנו מייבא חלק מפורמט ה-SVG ומכניס אותו ישירות באמצעות Fragment של Astro.

---
import { default as Owl } from '../Features/Owl.svg?raw'
---
<Fragment set:html={Owl} />

הסגנונות למיקום הינשוף נמצאים באותו מיקום שבו נמצא קוד הרכיב.

.owl {
  width: 34%;
  left: 10%;
  bottom: 34%;
}

יש עוד רכיב עיצוב אחד שמגדיר את ההתנהגות של transform עבור הינשוף.

.owl__owl {
  transform-origin: 50% 100%;
  transform-box: fill-box;
}

השימוש ב-transform-box משפיע על transform-origin. הוא הופך את המיקום ליחסי ביחס לתיבת הגבול של האובייקט בתוך קובץ ה-SVG. הינשוף מתרחב מהמרכז התחתון, ולכן נעשה שימוש ב-transform-origin: 50% 100%.

החלק הכי מהנה הוא כשמקשרים את הינשוף לאחד מה-ViewTimeline שנוצרו:

const setUpOwl = () => {
   const owl = document.querySelector('.owl__owl');

   owl.animate([
     {
       translate: '0% 110%',
     },
     {
       translate: '0% 10%',
     },
   ], {
     timeline: CHROMETOBER_TIMELINES[1],
     delay: { phase: "enter", percent: CSS.percent(80) },
     endDelay: { phase: "enter", percent: CSS.percent(90) },
     fill: 'both' 
   });
 }

 if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches)
   setUpOwl()

בבלוק הקוד הזה אנחנו מבצעים שתי פעולות:

  1. בודקים את העדפות התנועה של המשתמש.
  2. אם אין להם העדפה, אפשר לקשר אנימציה של הינשוף לגלילה.

בחלק השני, הינשוף נע באנימציה על ציר ה-y באמצעות Web Animations API. נעשה שימוש בנכס טרנספורמציה ספציפי translate, שמקושר ל-ViewTimeline אחד. הוא מקושר ל-CHROMETOBER_TIMELINES[1] דרך המאפיין timeline. זהו ViewTimeline שנוצר עבור הפניית הדפים. כך מקשרים את האנימציה של הינשוף להחלפת הדף באמצעות השלב enter. ההגדרה קובעת שכאשר הדף מסתובב ב-80%, מתחילים להזיז את הינשוף. כשהיא תגיע ל-90%, הינשוף אמור לסיים את התרגום.

תכונות הספר

עכשיו ראיתם את הגישה לבניית דף ואיך עובדת ארכיטקטורת הפרויקט. אפשר לראות איך הוא מאפשר לכותבים להצטרף ולעבוד על דף או תכונה לבחירתם. לתכונות שונות בספר יש אנימציות שמקושרות להחלפת הדפים בספר. לדוגמה, העטלף שחג כשהוא נכנס ויוצא מהדף.

יש בו גם אלמנטים שמבוססים על אנימציות CSS.

אחרי שחסימות התוכן היו בספר, היה זמן לתת ביטוי ליצירתיות בעזרת תכונות אחרות. כך הייתה הזדמנות ליצור אינטראקציות שונות ולנסות דרכים שונות ליישם דברים.

שמירה על תגובה מהירה

יחידות של אזור תצוגה רספונסיבי מגדירות את הגודל של הספר והתכונות שלו. עם זאת, שמירה על רספונסיביות של הגופנים הייתה אתגר מעניין. יחידות של שאילתות בקונטיינרים מתאימות למקרה כזה. עם זאת, הן עדיין לא נתמכות בכל מקום. הגודל של הספר מוגדר, ולכן אין צורך בשאילתת מאגר. אפשר ליצור יחידת שאילתת מאגר מוטמעת באמצעות CSS calc() ולהשתמש בה כדי לקבוע את גודל הגופן.


.book-placeholder {
  --size: clamp(12rem, 72vw, 80vmin);
  --aspect-ratio: 360 / 504;
  --cqi: calc(0.01 * (var(--size) * (var(--aspect-ratio))));
}

.content-block h2 {
  color: var(--gray-0);
  font-size: clamp(0.6rem, var(--cqi) * 4, 1.5rem);
}

.content-block :is(p, a) {
  font-size: clamp(0.6rem, var(--cqi) * 3, 1.5rem);
}

דלועים זוהרים בלילה

אם יש לכם עין חדה, יכול להיות ששמתם לב לשימוש ברכיבי <source> כשדיברנו על הרקעים של הדפים מקודם. Una רצתה ליצור אינטראקציה שתגיב להעדפה של ערכת צבעים. כתוצאה מכך, הרקעים תומכים גם במצב בהיר וגם במצב כהה עם וריאנטים שונים. אפשר להשתמש בשאילתות מדיה עם הרכיב <picture>, ולכן זוהי דרך מצוינת לספק שני סגנונות של רקע. הרכיב <source> שולח שאילתה לגבי ההעדפה של ערכת הצבעים, ומציג את הרקע המתאים.

<picture>
  <source srcset={darkFront} media="(prefers-color-scheme: dark)" height="214" width="150">
  <img src={lightFront} class="page__background page__background--right" alt="" aria-hidden="true" height="214" width="150">
</picture>

אפשר לבצע שינויים נוספים על סמך העדפת ערכת הצבעים הזו. הדלועים בדף השני משתנים בהתאם להעדפות של המשתמש לגבי ערכת הצבעים. קובץ ה-SVG שנעשה בו שימוש מכיל עיגולים שמייצגים להבות, שמתרחבים ומציגים אנימציה במצב כהה.

.pumpkin__flame,
 .pumpkin__flame circle {
   transform-box: fill-box;
   transform-origin: 50% 100%;
 }

 .pumpkin__flame {
   scale: 0.8;
 }

 .pumpkin__flame circle {
   transition: scale 0.2s;
   scale: 0;
 }

@media(prefers-color-scheme: dark) {
   .pumpkin__flame {
     animation: pumpkin-flicker 3s calc(var(--index, 0) * -1s) infinite linear;
   }

   .pumpkin__flame circle {
     scale: 1;
   }

   @keyframes pumpkin-flicker {
     50% {
       scale: 1;
     }
   }
 }

האם התמונה הזו צופה בכם?

אם בודקים את דף 10, יכול להיות שמשהו ייראה לכם מוכר. מישהו צופה בך! העיניים של הדיוקן יעקבו אחרי הסמן שלכם כשאתם נעים בדף. הטריק הוא למפות את מיקום הסמן לערך translate ולהעביר אותו ל-CSS.

const mapRange = (inputLower, inputUpper, outputLower, outputUpper, value) => {
   const INPUT_RANGE = inputUpper - inputLower
   const OUTPUT_RANGE = outputUpper - outputLower
   return outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
 }

הקוד הזה מקבל טווחי קלט ופלט וממפה את הערכים שצוינו. לדוגמה, השימוש הזה יניב את הערך 625.

mapRange(0, 100, 250, 1000, 50) // 625

בתמונת הדיוקן, ערך הקלט הוא נקודת המרכז של כל עין, בתוספת או בחיסור מרחק מסוים בפיקסלים. טווח הפלט הוא היקף התרגום של העיניים בפיקסלים. לאחר מכן, מיקום הסמן בציר x או y מועבר כערך. כדי לקבל את נקודת המרכז של העיניים תוך כדי תזוזה, העיניים כפולות. המקור לא זז, הוא שקוף ומשמש לצורכי עיון.

אחר כך נקשר אותם זה לזה ותעדכנו את הערכים של המאפיינים המותאמים אישית ב-CSS בעיניים כדי שהעיניים יוכלו לזוז. פונקציה מקושרת לאירוע pointermove מול window. בזמן שהעין מתלקחת, הגבולות של כל עין משמשים לחישוב נקודות המרכז. לאחר מכן, מיקום הסמן ממופה לערכים שמוגדרים כערכים של מאפיינים מותאמים אישית בעיניים.

const RANGE = 15
const LIMIT = 80
const interact = ({ x, y }) => {
   // map a range against the eyes and pass in via custom properties
   const LEFT_EYE_BOUNDS = LEFT_EYE.getBoundingClientRect()
   const RIGHT_EYE_BOUNDS = RIGHT_EYE.getBoundingClientRect()

   const CENTERS = {
     lx: LEFT_EYE_BOUNDS.left + LEFT_EYE_BOUNDS.width * 0.5,
     rx: RIGHT_EYE_BOUNDS.left + RIGHT_EYE_BOUNDS.width * 0.5,
     ly: LEFT_EYE_BOUNDS.top + LEFT_EYE_BOUNDS.height * 0.5,
     ry: RIGHT_EYE_BOUNDS.top + RIGHT_EYE_BOUNDS.height * 0.5,
   }

   Object.entries(CENTERS)
     .forEach(([key, value]) => {
       const result = mapRange(value - LIMIT, value + LIMIT, -RANGE, RANGE)(key.indexOf('x') !== -1 ? x : y)
       EYES.style.setProperty(`--${key}`, result)
     })
 }

אחרי שהערכים מועברים ל-CSS, אפשר לעשות איתם מה שרוצים בסגנונות. היתרון הגדול כאן הוא השימוש ב-CSS clamp() כדי לשנות את ההתנהגות של כל עין, כך שתוכלו לשנות את ההתנהגות של כל עין בלי לגעת שוב ב-JavaScript.

.portrait__eye--mover {
   transition: translate 0.2s;
 }

 .portrait__eye--mover.portrait__eye--left {
   translate:
     clamp(-10px, var(--lx, 0) * 1px, 4px)
     clamp(-4px, var(--ly, 0) * 0.5px, 10px);
 }

 .portrait__eye--mover.portrait__eye--right {
   translate:
     clamp(-4px, var(--rx, 0) * 1px, 10px)
     clamp(-4px, var(--ry, 0) * 0.5px, 10px);
 }

השמעת לחשים

אם תצפו בעמוד השישי, האם תרגישו מאויתים? בדף הזה מוצג העיצוב של השועל הקסום והנפלא שלנו. אם תזיזו את הסמן, יכול להיות שתראו אפקט מותאם אישית של נתיב הסמן. הפעולה הזו משתמשת באנימציה של לוח הציור. רכיב <canvas> נמצא מעל שאר תוכן הדף עם pointer-events: none. כלומר, המשתמשים עדיין יוכלו ללחוץ על בלוקי התוכן שמתחת.

.wand-canvas {
  height: 100%;
  width: 200%;
  pointer-events: none;
  right: 0;
  position: fixed;
}

בדומה לאופן שבו התמונה הסטטית שלנו מקשיבה לאירוע pointermove ב-window, כך גם הרכיב <canvas>. עם זאת, בכל פעם שהאירוע מופעל, אנחנו יוצרים אובייקט כדי ליצור אנימציה באלמנט <canvas>. האובייקטים האלה מייצגים צורות שמשמשות בנתיב הסמן. יש להן קואורדינטות וגוון אקראי.

הפונקציה mapRange שלנו מוקדם יותר משמשת שוב, כי אפשר להשתמש בה כדי למפות את הדלתה של הסמן אל size ו-rate. האובייקטים מאוחסנים במערך שמתבצעת בו לולאה כשהאובייקטים מצוירים לרכיב <canvas>. המאפיינים של כל אובייקט מציינים לרכיב <canvas> היכן יש לצייר את הדברים.

const blocks = []
  const createBlock = ({ x, y, movementX, movementY }) => {
    const LOWER_SIZE = CANVAS.height * 0.05
    const UPPER_SIZE = CANVAS.height * 0.25
    const size = mapRange(0, 100, LOWER_SIZE, UPPER_SIZE, Math.max(Math.abs(movementX), Math.abs(movementY)))
    const rate = mapRange(LOWER_SIZE, UPPER_SIZE, 1, 5, size)
    const { left, top, width, height } = CANVAS.getBoundingClientRect()
    
    const block = {
      hue: Math.random() * 359,
      x: x - left,
      y: y - top,
      size,
      rate,
    }
    
    blocks.push(block)
  }
window.addEventListener('pointermove', createBlock)

כדי לצייר על הקנבס, יוצרים לולאה באמצעות requestAnimationFrame. נתיב הסמן צריך להיות מוצג רק כשהדף נמצא בתצוגה. יש לנו IntersectionObserver שמתעדכן ומחליט אילו דפים מוצגים. אם דף מוצג, האובייקטים מוצגים כעיגולים על גבי הלוח.

לאחר מכן, אנחנו מבצעים לולאה על המערך blocks ומציירים כל חלק מהמסלול. בכל פריים, הגודל של האובייקט מצטמצם והמיקום שלו משתנה לפי הערך של rate. כך נוצר האפקט של ירידה והקטנה. אם האובייקט מתכווץ לגמרי, הוא מוסר מהמערך blocks.

let wandFrame
const drawBlocks = () => {
   ctx.clearRect(0, 0, CANVAS.width, CANVAS.height)
  
   if (PAGE_SIX.className.indexOf('in-view') === -1 && wandFrame) {
     blocks.length = 0
     cancelAnimationFrame(wandFrame)
     document.body.removeEventListener('pointermove', createBlock)
     document.removeEventListener('resize', init)
   }
  
   for (let b = 0; b < blocks.length; b++) {
     const block = blocks[b]
     ctx.strokeStyle = ctx.fillStyle = `hsla(${block.hue}, 80%, 80%, 0.5)`
     ctx.beginPath()
     ctx.arc(block.x, block.y, block.size * 0.5, 0, 2 * Math.PI)
     ctx.stroke()
     ctx.fill()

     block.size -= block.rate
     block.y += block.rate

     if (block.size <= 0) {
       blocks.splice(b, 1)
     }

   }
   wandFrame = requestAnimationFrame(drawBlocks)
 }

אם הדף יוצא מהתצוגה, מעבדי האירועים יוסרו והלוף של מסגרות האנימציה יבוטל. גם המערך blocks יימחק.

כאן אפשר לראות את נתיב הסמן בפעולה.

בדיקת נגישות

יצירת חוויה מהנה לעיונם של המשתמשים היא דבר טוב, אבל אם היא לא נגישה למשתמשים, היא לא תהיה שימושית. המומחיות של אדם בתחום הזה הייתה משמעותית בהכנת Chrometober לבדיקה של נגישות לפני השקה.

אלה חלק מהנושאים העיקריים שאנחנו עוסקים בהם:

  • מוודאים שה-HTML שנעשה בו שימוש הוא סמנטי. למשל, אלמנטים כמו אלמנטים מתאימים של ציון דרך, כמו <main> עבור הספר, השימוש ברכיב <article> לכל קטע תוכן, ורכיבי <abbr> שבהם מופיעים ראשי תיבות. החשיבה מראש במהלך היצירה של הספר עזרה לנו להנגיש את הדברים. שימוש בכותרות ובקישורים מקל על המשתמשים לנווט. השימוש ברשימת דפים גם מאפשר לספר את מספר הדפים באמצעות טכנולוגיה מסייעת.
  • מוודאים שכל התמונות כוללות את המאפיינים המתאימים של alt. בקובצי SVG שמוצגים בתוך שורה, האלמנט title נמצא במקרים הנדרשים.
  • שימוש במאפיינים של aria במקרים שבהם הם משפרים את חוויית השימוש. השימוש ב-aria-label לדפים ולצדדים שלהם מאפשר למשתמש לדעת באיזה דף הוא נמצא. השימוש ב-aria-describedBy בקישורים של 'מידע נוסף' מעביר את הטקסט של חסימת התוכן. כך תוכלו להסיר את אי-הבהירות לגבי היעד שאליו הקישור יעביר את המשתמש.
  • בנוגע לחסימות תוכן, יש אפשרות ללחוץ על הכרטיס כולו ולא רק על הקישור 'מידע נוסף'.
  • השימוש ב-IntersectionObserver למעקב אחר הדפים המוצגים היה מוקדם יותר. יש לכך הרבה יתרונות שלא קשורים רק לביצועים. אנימציות או אינטראקציות בדפים שלא נמצאים בתצוגה יושהו. אבל על הדפים האלה מופעל גם המאפיין inert. המשמעות היא שמשתמשים שמשתמשים בקורא מסך יכולים לגלוש באותו תוכן כמו משתמשים שרואים. המיקוד נשאר בדף שמוצג, והמשתמשים לא יכולים להקיש על Tab כדי לעבור לדף אחר.
  • לסיום, אנחנו משתמשים בבקשות מדיה כדי לכבד את העדפות התנועה של המשתמשים.

לפניכם צילום מסך מהבדיקה, שבו מודגשות כמה מהפעולות שננקטו.

מוגדר כמקיף את כל הספר, כלומר הוא אמור להיות המאפיין העיקרי שמשתמשים בטכנולוגיה מסייעת יכולים למצוא. מידע נוסף מפורט בצילום המסך." width="800" height="465">

צילום מסך של הספר פתוח ב-Chrometober. תיבות עם קו מתאר ירוק מופיעות סביב היבטים שונים של ממשק המשתמש, ומתארות את פונקציונליות הנגישות המיועדת ואת התוצאות של חוויית המשתמש שהדף יספק. לדוגמה, תמונות מכילות טקסט חלופי. דוגמה נוספת היא תווית נגישות שמצהירה שדפים שלא מוצגים לא פעילים. מידע נוסף מפורט בצילום המסך.

מה למדנו

המטרה של Chrometober לא הייתה רק להבליט תוכן אינטרנט מהקהילה, אלא גם לתת לנו הזדמנות לבדוק את ה-polyfill של ממשק ה-API של אנימציות שמקושרות לגלילה, שנמצא בפיתוח.

במהלך כנס הצוות שלנו בניו יורק, הקדשנו זמן לבדוק את הפרויקט ולטפל בבעיות שהתגלו. התרומה של הצוות הייתה חשובה. זו הייתה גם הזדמנות מצוינת לרשום את כל הדברים שצריך לטפל בהם לפני שנוכל להשיק את התכונה.

צוות ה-CSS, ממשק המשתמש וכלי הפיתוח יושבים מסביב לשולחן בחדר ישיבות. אונה עומדת על לוח אינטראקטיבי שמכוסה עם פתקים דביקים. חברי צוות אחרים יושבים סביב השולחן עם כיבוד קל ומחשבים ניידים.

לדוגמה, בדיקת הספר במכשירים גרמה לבעיית רינדור. הספר שלנו לא הוצג כראוי במכשירי iOS. יחידות אזור התצוגה קובעות את גודל הדף, אבל כשהיה חריץ, הוא השפיע על הספר. הפתרון היה להשתמש ב-viewport-fit=cover באזור התצוגה meta:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

בסשן הזה הוצגו גם כמה בעיות ב-polyfill של ה-API. Bramus הצביע על הבעיות האלה במאגר ה-polyfill. לאחר מכן הוא מצא פתרונות לבעיות האלה ומיזג אותן ב-polyfill. לדוגמה, בקשת ה-pull הזו הביאה לשיפור בביצועים על ידי הוספת אחסון במטמון לחלק מה-polyfill.

צילום מסך של הדגמה שנפתחת ב-Chrome. הכלים למפתחים פתוחים ומוצגת בהם מדידת ביצועים בסיסית.

צילום מסך של הדגמה שנפתחת ב-Chrome. הכלים למפתחים פתוחים ומוצגת בהם מדידת ביצועים משופרת.

זהו!

זה היה פרויקט כיפי מאוד, שהתוצאה שלו היא חוויית גלילה לא שגרתית שמדגישה תוכן מדהים מהקהילה. מעבר לזה, מומלץ מאוד לבדוק את ה-polyfill וגם לספק משוב לצוות מהנדסי התוכנה כדי לעזור בשיפור ה-polyfill.

Chrometober 2022 הוא רק גרסה מצומצמת.

אנחנו מקווים שנהניתם! מה התכונה האהובה עליך? אפשר לשלוח לי ציוץ ולספר לנו.

Jhey אוחז בגיליון סטיקרים של הדמויות מ-Chrometober.

ייתכן שתוכלו אפילו להוריד כמה סטיקרים מאחד מהצוות אם תראו אותנו באירוע.

התמונה הראשית (Hero) של David Menidrey ב-Unsplash