Am neuen Blog basteln: Ich habe meine Gcores-Talks in den Blog integriert

Dieser Artikel entsprang meiner ungebrochenen kindlichen Neugier.

Im April, während einer Reise, verspürte ich diesen starken Drang, einen Reisebericht zu schreiben, den ich zuerst auf Gcores veröffentlichte. Wie üblich wollte ich dann eine Markdown-Version für meinen eigenen Blog erstellen.

Nachdem ich mit dem Bearbeiten fertig war, stellte ich fest, dass mein Blog kaputt war.

Der alte Blog

Die Welle, persönliche Blogs zu erstellen, schwappte in meinem dritten Studienjahr durch unseren Fachbereich. In diesem Artikel von vor 7 Jahren, der mir heute peinlich ist, beschrieb ich meine anfänglichen Gedanken und Gefühle beim Aufbau dieses Blogs.

Die Gründe waren: Erstens wollte ich mich dazu anhalten, etwas zu schreiben; zweitens wollte ich praktische Erfahrungen sammeln und einige neue, angesagte Technologien ausprobieren, die an der Uni nicht gelehrt wurden; und außerdem ist es einfach cool, eine eigene Domain zu haben – zumindest fand ich das.

Lassen Sie uns die Szene von damals nachspielen:


Nachdem ich etwas Neues gelernt hatte, notierte ich aufgeregt meine Ideen in einem Markdown-Dokument.

Nach dem Schreiben führte ich lässig ein paar Befehle aus, um den neuen Blogbeitrag hochzuladen:

$ git add .
$ git commit -m "new blog! hahahaha"
$ git push origin master

Dann öffnete ich aufgeregt die CI-Oberfläche – CI war damals noch ziemlich modern, die DevOps-Idee war erst seit wenigen Jahren im Umlauf. Auf der Log-Seite der CI wartete ich darauf, dass der gesamte Blog inklusive dieses Artikels zu einer statischen HTML-Website gebaut wurde, dass Travis die Build-Artefakte mit meinem Schlüssel in das Git-Repository pushte, und dass Github die Artefakte veröffentlichte.

Schließlich ging ich auf meine Domain https://blog.thrimbda.com und sah zufrieden zu, wie der neue Artikel ganz oben auf der Seite erschien.


Im Laufe der Jahre wurde dieser Prozess langweilig, langweilig und doch weiterhin effektiv, bis der Tag kam, an dem er versagte.

Der neue Blog

An jenem Tag Ende April scheiterten drei aufeinanderfolgende CI-Pipeline-Läufe. Ein kurzer Blick in die Logs zeigte, dass eine Build-Abhängigkeit anscheinend wegen ihres Alters aus dem Internet verschwunden war. Mir wurde klar, was für eine enorme Zeitspanne 7 Jahre in dieser Branche sind – lang genug, dass Sandburgen verwehen und Berge vom Meer verschluckt werden.

Also nutzte ich die Leerlaufzeiten bei der Arbeit, um mit einigen nicht sonderlich spannenden Anforderungen eine Technologieauswahl zu treffen: Einfach genug, bequem genug.

So entstand dieser neue Blog. Der Tradition folgend, hier eine Vorstellung:

  • Ich wählte Zola als Generator;
  • Ich suchte ein sehr einfaches, aber hübsches Theme aus;

Als Ersatz für den alten Blog war dieser neue damit fertig.

Der verspielte Bastelprozess

Vielleicht weil er neu genug war, oder weil die Technologieauswahl einfach genug war, vielleicht auch weil der Feedback-Zyklus bei Frontend-Arbeit kurz genug ist, und weil die Arbeit mit Azheng mir zwangsläufig etwas Frontend-Wissen vermittelte und Wissenslücken aus dem ersten Studienjahr schloss, wurde das Herumbasteln am Blog wieder interessant.

Einige bescheidene CSS-Anpassungen, die Integration eines neuen gitcus, das Hosten der Kommentare auf Github; ich war mit meinem Ich von vor sieben Jahren einer Meinung: Programmieren und an Projekten basteln macht tatsächlich Spaß, es ist die Freude an der Arbeit, die Freude am Spiel.

Mit der Nachwirkung dieser Freude möchte ich (in selbstverliebter Manier) den technischen Prozess beschreiben, wie ich meine Gcores-Talks in den Blog integriert habe.


Die Daten

Bei einer früheren Erkundung hatte ich bereits festgestellt, dass Gcores Crawlern gegenüber äußerst freundlich ist. Seine Daten-APIs sind sehr einheitlich und ausdrucksstark. Zum Beispiel, wenn man meine Talks erkundet:

gcores-api-at-glance

Man entdeckt, dass dies die API zum Abrufen der Talk-Daten ist:

https://www.gcores.com/gapi/v1/users/464460/recommend?talk-include=topic%2Cuser%2Chelpful%2Cpoll-options%2Crelated-content.radio%2Crelated-content.video%2Crelated-content.article%2Crelated-content.game%2Crelated-content.film%2Crelated-content.album%2Crelated-content.album-bundle%2Crelated-content.product%2Crelated-content.discussion&original-include=user%2Cdjs%2Ccategory&order-by=time&before=1718720726.986&fields[articles]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags&fields[videos]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Calbums%2Cpublished-albums&fields[radios]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Clatest-album%2Calbums%2Cis-free%2Cis-require-privilege

Rohdaten

Lassen Sie uns die Informationen extrahieren:

const user = 464460;

const url = new URL(
  `https://www.gcores.com/gapi/v1/users/${user}/recommend?talk-include=topic%2Cuser%2Chelpful%2Cpoll-options%2Crelated-content.radio%2Crelated-content.video%2Crelated-content.article%2Crelated-content.game%2Crelated-content.film%2Crelated-content.album%2Crelated-content.album-bundle%2Crelated-content.product%2Crelated-content.discussion&original-include=user%2Cdjs%2Ccategory&order-by=time&fields[articles]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags&fields[videos]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Calbums%2Cpublished-albums&fields[radios]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Clatest-album%2Calbums%2Cis-free%2Cis-require-privilege`
);

Bei genauerer Betrachtung scheint die Paginierung durch den Parameter before bestimmt zu sein. Indem man diesen Zeitstempel kontinuierlich nach vorne iteriert, bis keine Daten mehr zurückkommen, erhält man folgenden Code:

import {
  EMPTY,
  Observable,
  expand,
  map,
  mergeAll,
  mergeMap,
  of,
  shareReplay,
  skip,
  takeWhile,
  tap,
  toArray,
} from "https://esm.sh/[email protected]";

const user = 464460;

const url = new URL(
  `https://www.gcores.com/gapi/v1/users/${user}/recommend?talk-include=topic%2Cuser%2Chelpful%2Cpoll-options%2Crelated-content.radio%2Crelated-content.video%2Crelated-content.article%2Crelated-content.game%2Crelated-content.film%2Crelated-content.album%2Crelated-content.album-bundle%2Crelated-content.product%2Crelated-content.discussion&original-include=user%2Cdjs%2Ccategory&order-by=time&fields[articles]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags&fields[videos]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Calbums%2Cpublished-albums&fields[radios]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Clatest-album%2Calbums%2Cis-free%2Cis-require-privilege`
);

const imageUrl = (image: string) =>
  `https://image.gcores.com/${image}?x-oss-process=image/quality,q_90/format,webp`;

// Paginierung
url.searchParams.set("before", `${Date.now() / 1000}`);

interface IGcoresTalk {
  text: string;
  images: string[];
  published_at: number;
  tags: string[];
}

const rawGcoresTalkData$: Observable<any[]> = of({
  before: Date.now() / 1000,
}).pipe(
  // Rohdaten
  expand(async ({ before }) => {
    url.searchParams.set("before", `${before}`);
    const res = await fetch(url);
    const data = await res.json();
    if (!data.data || data.data.length === 0) {
      return EMPTY;
    }
    return {
      before:
        new Date(
          data.data[data.data.length - 1].attributes["published-at"]
        ).getTime() / 1000,
      ...data,
    };
  }),
  skip(1),
  // Debug
  // take(1),
  takeWhile((v) => !!v.data),
  // filter((v) => v.data.length > 0),
  tap((v) => {
    console.info(v);
  }),
  toArray(),
  shareReplay(1)
);

So erhalten wir einen Rx-Stream, der alle Rohdaten der Talks eines Users trägt.

Datenverarbeitung

Mit den Rohdaten können wir nun Markdown generieren. Fangen wir direkt an. Die Hauptpunkte sind:

  • Entscheiden, welche Inhalte angezeigt werden sollen.
  • Die API-Felder von Gcores verstehen.

Talks sind ähnlich wie Freundeskreise oder Microblogs: Text mit Bildern und einigen Tags. Lassen Sie sie uns einfach flach anzeigen.

Die Daten eines Talks können wie folgt definiert werden:

{
  "blocks": [
    {
      "data": { "spoiler": false },
      "depth": 0,
      "entityRanges": [{ "key": 0, "length": 1, "offset": 0 }],
      "inlineStyleRanges": [],
      "key": "7u4tf",
      "text": "-",
      "type": "atomic"
    },
    {
      "data": { "spoiler": false },
      "depth": 0,
      "entityRanges": [],
      "inlineStyleRanges": [],
      "key": "wisz4",
      "text": "核聚变好玩,和雨川西蒙合了影,见到了做志愿者的 merz,然而社恐差点没敢上前搭话,腿快走断了,给没能来的小朋友们买了点纪念品,Celeste 随机异变速通震撼我妈,全程硬是没把因为震惊而张开的大嘴合上。可惜周天广州下雨航班被取消所以急匆匆买了深圳回上海的高铁票就没去成周天的。",
      "type": "unstyled"
    },
    {
      "data": { "spoiler": false },
      "depth": 0,
      "entityRanges": [],
      "inlineStyleRanges": [],
      "key": "nzffe",
      "text": "",
      "type": "unstyled"
    },
    {
      "data": { "spoiler": false },
      "depth": 0,
      "entityRanges": [],
      "inlineStyleRanges": [],
      "key": "7f8m6",
      "text": "下次还来!",
      "type": "unstyled"
    }
  ],
  "entityMap": {
    "0": {
      "data": {
        "caption": "",
        "images": [
          {
            "path": "0ad6514d154c80a9ef6b4b0d6173d132-3024-4032.HEIC",
            "width": 3024,
            "height": 4032
          },
          {
            "path": "69cea053377d3155d5f7e22e8584f289-4032-3024.HEIC",
            "width": 4032,
            "height": 3024
          },
          {
            "path": "aedf102fa319ce3df2438386f894def4-4032-3024.HEIC",
            "width": 4032,
            "height": 3024
          }
        ]
      },
      "mutability": "IMMUTABLE",
      "type": "GALLERY"
    }
  }
}

Basierend auf der gewünschten Ausgabe definieren wir folgende Struktur:

interface IGcoresTalk {
  text: string;
  images: string[];
  published_at: number;
  tags: string[];
}

Was wir ausgeben wollen, ist einfach ein string im Markdown-Format.

const cookedData$ = rawGcoresTalkData$.pipe(
  mergeAll(),
  mergeMap(({ data, included }): IGcoresTalk[] => {
    // Wir benötigen nur den Titel als Tag für den Talk
    const mapTypeIdToTitle = Object.fromEntries(
      included.map((v: any) => [`${v.type}-${v.id}`, v.attributes.title])
    );
    console.info(mapTypeIdToTitle);
    const talks = data
      .filter((v: any) => v.type === "talks")
      .map((v: any) => {
        const content = JSON.parse(v.attributes.content);
        const text = content.blocks
          .filter((v: any) => v.type === "unstyled")
          .map((v: any) => v.text.replace(/\#/, "\\#"))
          .join("\n");

        const images = (content.entityMap?.[0]?.data?.images ?? []).map(
          (v: any) => v.path
        );
        const published_at = new Date(v.attributes["published-at"]).getTime();
        const tags = Object.values(v.relationships as any[])
          .filter((v) => !!v.data)
          .filter((v) => ["topics", "games"].includes(v.data.type))
          .map(({ data }) => mapTypeIdToTitle[`${data.type}-${data.id}`]);
        return {
          text,
          images,
          published_at,
          tags,
        };
      });

    return talks;
  })
);

Jetzt haben wir einen Haufen der gewünschten Datenstrukturen. Als nächstes müssen wir sie nur noch in string umwandeln.

cookedData$
  .pipe(
    //
    tap((v) => {
      console.info(v);
    }),
    map((v: IGcoresTalk): string => {
      const published_time = new Date(v.published_at);
      const title = `## ${published_time.getFullYear()}-${
        published_time.getMonth() + 1
      }-${published_time.getDate()}`;
      const content = v.text;
      const images = v.images.map((v) => `![${v}](${imageUrl(v)})`).join("\n");
      const tags = v.tags.map((v) => `- ${v}`).join("\n");

      return `${title}\n\n${images}\n\n${content}\n\n${tags}\n`;
    }),
    toArray(),
    map(
      (all) =>
        `---\ntitle: '0xc1 的机组日志'\ndate: ${new Date().toISOString()}\n---\n原始链接:[Thrimbda 的机组](https://www.gcores.com/users/464460/talks)\n${all.join(
          "\n\n---\n---\n\n"
        )}`
    ),
    tap((v) => {
      console.info(v);
    }),
    tap((v) => Deno.writeTextFile(`./content/gcores-talks.md`, v))
  )
  .subscribe();

Ich wählte den direktesten Ansatz: Direktes Schreiben in ein Verzeichnis.

Die vollständige Version dieses Codes ist hier zu finden und wird mit deno ausgeführt:

deno cache get-gcores-talk.ts
deno run -A get-gcores-talk.ts

Darstellung und Rendering der Bilder

An diesem Punkt haben wir ein gültiges Markdown-Dokument erzeugt, das zu einer halbwegs ansehnlichen Webseite gerendert werden kann.

gcores-pictures-render

Aber die Bilder sehen seltsam aus: Einfaches, flaches Stapeln durch direktes Markdown-Rendering lässt das Verhältnis von Bild zu Text unharmonisch erscheinen und wirkt kopflastig. Es wäre schön, wenn sie wie bei Gcores zu einer schiebbaren Bildergruppe gemacht werden könnten.

Auswahl der Lösung

Markdown unterstützt die Erweiterung seiner Ausdruckskraft durch HTML, also könnten wir auf jeden Fall unseren eigenen Code schreiben, um die Bilder direkt als HTML mit dem gewünschten Effekt zu rendern. Aber Zola bietet einen anderen Weg, der die Umsetzung einfacher macht: Shortcodes | Zola

Was wir tun müssen, ist eine HTML-Vorlage zu schreiben, ergänzt durch passendes CSS für das Styling und JS-Code für die Interaktion; und dann diese HTML direkt im Markdown-Text aufzurufen.

Moment mal? HTML + CSS + JS schreiben, sagten Sie? Das erinnert mich an mein erstes Studienjahr.

Frontend schreiben

HTML

Der HTML-Teil ist sehr einfach und langweilig. Für jede Bildergruppe benötigen wir folgende Elemente:

  • Einen Container für die schiebbare Bildergruppe selbst
  • Die Bilder
  • Die Punkte unten zur Indexanzeige
  • Pfeiltasten zum Wechseln der Bilder
<div class="slider-container">
  <div class="slider-wrapper">
    <div class="slider">
      {% for slide in slides %}
      <div class="slider-item">
        <img src="{{slide}}" />
        {% if slide.caption %}
        <div class="caption">{{slide.caption}}</div>
        {% endif %}
      </div>
      {% endfor %}
    </div>
    <button class="slider-prev" type="button">&#10094;</button>
    <button class="slider-next" type="button">&#10095;</button>
  </div>
  <!-- The dots/circles -->
  <div class="slider-dot-container">
    {% for slide in slides %}
    <span class="slider-dot"></span>
    {% endfor %}
  </div>
</div>
CSS

Dank modernerer Frontend-Technologien ist die Umsetzung mit Flexbox heute sehr einfach. Die Kernidee ist, die Bilder in einer Reihe anzuordnen, aber nur den Platz für ein Bild freizugeben, während die anderen dahinter versteckt sind:

.slider-wrapper {
  overflow: hidden;
  position: relative;
  box-sizing: border-box;
  width: 100%;
}

.slider {
  position: relative;
  display: flex;
  box-sizing: border-box;
}

.slider-item {
  position: relative;
  flex: 1 0 100%;
  min-height: 150px;
  max-height: min(550px, 55svh);

  display: flex;
  align-items: center;
  justify-items: center;
  box-sizing: border-box;

  overflow: visible;
}

.slider-item img {
  position: relative;
  width: auto;
  max-height: 100%;
  margin: auto; /* Horizontal zentrieren */
}

/* Next & previous buttons */
.slider-prev,
.slider-next {
  /* cursor: pointer; */
  position: absolute;
  /* top: 50%; */
  width: auto;
  height: 100%;
  transform: translateY(-100%);
  font-weight: bold;
  font-size: 1.2rem;

  z-index: 1;
  color: white;
  transition: 0.6s ease;
  border-radius: 5px;
  border: none;
  background-color: transparent;
}

/* Position the "next button" to the right */
.slider-next {
  right: 0;
  border-radius: 5px;
  /* border-radius: 3px 0 0 3px; */
}
.slider-prev:hover:enabled,
.slider-next:hover:enabled {
  border: none;
  color: var(--accent);
  background-color: rgba(71, 71, 71, 0.3);
}

.slider-dot-container {
  text-align: center;
}

.slider-dot {
  cursor: pointer;
  height: 15px;
  width: 15px;
  margin: 0 2px;
  background-color: #bbb;
  border-radius: 50%;
  display: inline-block;
  transition: background-color 0.6s ease;
}
.slider-dot:hover,
.slider-dot-active {
  background-color: #717171;
}
JavaScript

Der JS-Teil ist dafür verantwortlich, dass der Benutzer durch Interaktion das angezeigte Bild im sichtbaren Bereich wechseln kann. Diese Interaktionen umfassen:

  • Mausklick auf die Pfeiltasten
  • Mausklick auf die kleinen Punkte
  • Wischen nach links/rechts auf Touch-Geräten

Nachdem die Web-APIs jQuery übernommen haben und mit Hilfe von rxjs, ist dies sehr einfach:

const ELS = (selector, parent) =>
  (parent || document).querySelectorAll(selector);
const EL = (selector, parent) => (parent || document).querySelector(selector);
const mod = (n, m) => ((n % m) + m) % m;

ELS(".slider-container").forEach((EL_parent) => {
  const EL_slider = EL(".slider", EL_parent);
  const ELS_items = ELS(".slider-item", EL_parent);
  const ELS_dots = ELS(".slider-dot", EL_parent);
  const total = ELS_items.length;
  let c = 0;

  const setDotActive = () => {
    ELS_dots.forEach((EL_dot, i) => {
      EL_dot.classList.toggle("slider-dot-active", i === c);
    });
  };

  setDotActive();

  const anim = () => {
    EL_slider.style.transform = `translateX(-${c * EL_slider.offsetWidth}px)`;
  };
  const prev = () => {
    distance = 0;
    startX = 0;
    c = mod(c - 1, total);
    setDotActive();
    anim();
  };
  const next = () => {
    distance = 0;
    startX = 0;
    c = mod(c + 1, total);
    setDotActive();
    anim();
  };

  EL(".slider-prev", EL_parent).addEventListener("click", prev);
  EL(".slider-next", EL_parent).addEventListener("click", next);

  ELS(".slider-dot", EL_parent).forEach((dot, i) => {
    dot.addEventListener("click", () => {
      c = i;
      setDotActive();
      anim();
    });
  });

  const touchstart$ = fromEvent(EL_parent, "touchstart");
  const touchend$ = fromEvent(EL_slider, "touchend");
  const touchmove$ = fromEvent(EL_slider, "touchmove");

  touchstart$
    .pipe(
      tap(() => {
        EL_slider.style.transition = "none";
      }),
      switchMap((start) =>
        animationFrames().pipe(
          withLatestFrom(touchmove$),
          map(([, touchEvent]) => {
            const distance =
              touchEvent.touches[0].clientX - start.touches[0].clientX;

            EL_slider.style.transform = `translateX(-${
              c * EL_slider.offsetWidth - distance
            }px)`;
            return distance;
          }),
          takeUntil(touchend$),
          defaultIfEmpty(0),
          last()
        )
      ),
      tap({
        next: (distance) => {
          EL_slider.style.transition = "transform 0.3s ease-in-out";
          if (distance / EL_slider.offsetWidth > 0.2) {
            c = mod(c - 1, total);
          } else if (distance / EL_slider.offsetWidth < -0.2) {
            c = mod(c + 1, total);
          }
          setDotActive();
          anim();
        },
      }),
      repeat()
    )
    .subscribe();
});

Die grobe Idee des gesamten Codes ist, für jede schiebbare Bildergruppe einen Index des aktuell angezeigten Bildes zu speichern: [0, 1, ..., imageNumbers - 1]. Benutzerinteraktionen ändern diesen Index, woraus berechnet wird, um wie viele Pixel die Bildergruppe verschoben werden muss, um das durch den Index angegebene Bild anzuzeigen.

Ich bin besonders stolz auf den letzten Rx-Code für die Touch-Interaktion.

Touch-Interaktion ist komplexer als Button-Interaktion, weil sich das Bild während des Wischens mit dem Finger bewegen muss, und dieses Gefühl gut abgestimmt sein muss, um den Benutzer nicht zu frustrieren.

Diese Komplexität bedeutet, dass die Interaktion mehr Informationen und komplexere WEB-APIs benötigt.

Vom Interaktionslogik her müssen wir die Pixelposition des Fingers zu Beginn der Berührung und den Versatz relativ zur Startposition in jedem Render-Frame aufzeichnen. Dann, während des Renderings jedes Frames, müssen wir diesen Versatz auf die schiebbare Bildergruppe anwenden, damit das Bild dem Finger folgt. Nachdem der Finger eine Strecke bewegt wurde und den Bildschirm verlässt, müssen wir entscheiden, ob das Bild gewechselt werden soll. Zum Beispiel, wenn der Finger nur leicht den Bildschirm berührt und einen Versatz von 2 Pixeln nach rechts verursacht, sollte das Bild nicht gewechselt werden. Wenn der Finger jedoch über die Hälfte des Bildschirms nach rechts wischt, sollte das Bild gewechselt werden. Setzen wir einen Schwellenwert für den Versatz relativ zur Containerbreite auf 20%. Wenn die Wischstrecke 20% der Breite der Bildergruppe überschreitet, gehen wir davon aus, dass das Bild gewechselt werden sollte.

Von der API-Seite gibt es 3 relevante Ereignisse:

  • touchstart - Markiert den Beginn der Berührung, enthält die anfänglichen Fingerkoordinaten.
  • touchmove - Wird bei jeder Fingerbewegung ausgelöst, enthält die aktuellen Fingerkoordinaten.
  • touchend - Markiert das Ende der Berührung.

Rx eignet sich hervorragend für solche Anforderungen. Wir verpacken die Touch-Ereignisse des Benutzers in einen Datenstrom. Nach der Verarbeitung wird daraus ein Strom von horizontalen Finger-Versätzen, der verwendet wird, um die Bildposition in jedem Animationsframe zu aktualisieren. Schließlich wird nach Beendigung der Berührung basierend auf dem Verhältnis von Versatz zu Containerbreite entschieden, ob das Bild gewechselt wird.


Woher kommt die Freude?

Arbeit kann Freude bereiten, weil unser Gehirn Feedback und Zyklen mag.

stateDiagram-v2 [*] --> Arbeit Arbeit --> Ergebnis prüfen Ergebnis prüfen --> Unzufrieden Unzufrieden --> Problem analysieren Problem analysieren --> Arbeit Ergebnis prüfen --> Zufrieden verlassen Zufrieden verlassen --> [*]

Schmerz entsteht durch unvollständige oder zu lange Zyklen. Das Schreiben von Frontend-Code ist so erfreulich, weil sein Zyklus effizient genug ist. Jede Codeänderung, gefolgt von Speichern und Aktualisieren, lässt das Ergebnis direkt sichtbar werden. So können innerhalb weniger Minuten mehrere solcher Feedback-Zyklen durchlaufen werden, was Freude bereitet.

Ich hoffe, diese Freude begleitet Sie alle.

Zusammenfassung

Heutzutage wäre der Inhalt dieses Artikels für einen Studentenblog noch recht fortschrittlich, aber seine Tiefe ist im Verhältnis zu meiner Berufserfahrung etwas oberflächlich, sodass ich mich beim Schreiben oft schämte.

Aber egal, im Vergleich zum oberflächlichen Kopieren beim letzten Blogaufbau habe ich diesmal die vollständige Kontrolle über jeden Aspekt meines Blogs. Der Bastelprozess hat mir eine sehr reine und einfache Freude bereitet, und genau diese Freude gab mir die Dreistigkeit, ihn zu Ende zu schreiben.