Retocando el nuevo blog: He trasladado mi "Jīzǔ" al blog

Este artículo nace de mi espíritu infantil que aún no se ha extinguido.

En abril, durante un viaje, tuve un fuerte impulso de escribir un diario de viaje, que primero publiqué en Gcores. Luego, como es habitual, prepararía una versión en formato Markdown para publicarla en mi propio blog.

Resultó que, después de editarlo, descubrí que el blog estaba roto.

El blog anterior

La moda de crear blogs personales se popularizó en nuestro departamento durante mi tercer año de universidad. Hace 7 años, este artículo (que me hace retorcer los dedos de los pies) describía mi proceso mental inicial al crear este blog.

En cuanto a las razones, por un lado, quería obligarme a escribir algo; por otro, quería hacer algo de práctica de ingeniería, combinando algunas tecnologías nuevas y llamativas que no se enseñan en la escuela; y además, tener un dominio es realmente genial, al menos a mí me lo parece.

Recreemos la escena de aquel entonces:


Después de aprender algo nuevo, con emoción, me apresuraba a plasmar mis ideas en un documento en formato Markdown.

Una vez escrito, ejecutaba con desenvoltura unos comandos para subir el nuevo blog:

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

Luego, emocionado, abría la interfaz de CI (Integración Continua), que en ese entonces aún era algo novedoso, ya que el concepto de DevOps se había propuesto hacía solo unos años. En la página de logs del CI, esperaba a que todo el blog, incluido este artículo, se compilara en un sitio web estático de HTML, esperaba a que Travis usara mi clave para enviar el artefacto de compilación al repositorio de Git, y esperaba a que GitHub publicara el artefacto de compilación.

Finalmente, entraba en mi dominio https://blog.thrimbda.com y miraba con satisfacción cómo el nuevo artículo aparecía en la parte superior de la página.


Con el paso de los años, este proceso se volvió aburrido, aburrido y continuamente efectivo, hasta que llegó el día en que dejó de funcionar.

El nuevo blog

A finales de abril, reintenté 3 veces el pipeline de CI y todas fallaron. Al echar un vistazo rápido a los logs, descubrí que alguna dependencia utilizada para la compilación parecía haber desaparecido de Internet por ser demasiado antigua. Entonces me di cuenta de lo enorme que es un lapso de 7 años en esta industria, suficiente para que la arena destruya castillos y el mar se trague montañas.

Así que, en los momentos de ocio en el trabajo, con algunas peticiones no muy interesantes, hice la selección tecnológica: suficientemente simple, suficientemente conveniente.

Así nació este nuevo blog, y como es tradición, hay que presentarlo:

  • Elegí Zola como generador;

  • Encontré un tema muy simple pero bastante bonito;

Como reemplazo del blog anterior, este nuevo blog quedó listo.

El proceso de retoque con espíritu infantil

Quizás por ser lo suficientemente nuevo, o porque la selección tecnológica fue lo suficientemente simple, o porque el ciclo de retroalimentación del trabajo frontend es lo suficientemente corto, además de haber absorbido algunos conocimientos de frontend trabajando con Azheng, llenando así los vacíos de conocimiento que no estudié con dedicación en mi primer año, el proceso de retocar el blog volvió a ser divertido.

Algunas pequeñas modificaciones de estilo CSS, añadí un nuevo gitcus, alojando la sección de comentarios en GitHub; yo y mi yo de hace siete años estamos de acuerdo: programar y retocar proyectos de ingeniería es realmente algo divertido, es la alegría del trabajo, es la alegría del juego.

Quiero aprovechar el impulso de esta alegría para centrarme (y emocionarme) en el proceso técnico de trasladar mi “Jīzǔ” al blog.


Datos

En una exploración anterior, ya había descubierto que Gcores es increíblemente amigable con los web crawlers, sus interfaces de datos son muy uniformes y expresivas. Por ejemplo, al explorar mi “Jīzǔ”:

gcores-api-at-glance

Descubrí que esta es la API para solicitar los datos del “Jīzǔ”:

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

Datos brutos

Resumiendo la información:

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

Observando más a fondo, parece que la paginación se determina mediante un parámetro llamado before. Simplemente iterando hacia atrás en la marca de tiempo de este parámetro hasta que no se obtengan más datos. Así, podemos obtener el siguiente código:

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

// pagination
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(
  // raw data
  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)
);

Así obtenemos un flujo rx que contiene todos los datos brutos del “Jīzǔ” de un usuario.

Procesamiento de datos

Con los datos brutos, el siguiente paso es procesarlos en Markdown. Comencemos directamente. Los puntos clave son:

  • Decidir qué contenido mostrar.

  • Comprender los campos de la interfaz de Gcores.

El “Jīzǔ” es similar a los momentos de WeChat o Weibo, básicamente texto con imágenes, además de algunas etiquetas. Simplemente los mostraremos en una lista plana.

Los datos del “Jīzǔ” se pueden definir de la siguiente manera:

{
  "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"
    }
  }
}

Combinándolo con el contenido que queremos generar, definimos la siguiente estructura:

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

Lo que queremos generar es simplemente texto Markdown de tipo string.

const cookedData$ = rawGcoresTalkData$.pipe(
  mergeAll(),
  mergeMap(({ data, included }): IGcoresTalk[] => {
    // we only need the title as the tags of the 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;
  })
);

Ahora tenemos un montón de estructuras de datos como queremos. El siguiente paso es procesarlas en string.

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();

Elegí la forma más directa de procesar: escribir directamente en un directorio cercano.

La versión completa de este código está aquí, y se ejecuta con deno:

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

Visualización y renderizado de imágenes

Hasta ahora, hemos generado un Markdown válido, suficiente para renderizar una página web bastante presentable.

gcores-pictures-render

Pero las imágenes se ven extrañas: mostrarlas directamente apiladas en Markdown hace que la proporción entre imágenes y texto sea muy desequilibrada, dando una sensación de pesadez en la parte superior. Sería genial si pudieran mostrarse como un carrusel deslizante, como en Gcores.

Selección de la solución

Markdown admite extender su expresividad a través de HTML, por lo que, de cualquier manera, podríamos renderizar las imágenes directamente como HTML con nuestro propio código para lograr el efecto deseado. Pero Zola ofrece otra forma que nos permite implementar el objetivo más fácilmente: Shortcodes | Zola

Lo que tenemos que hacer es escribir una plantilla HTML con el CSS apropiado para los estilos y el código JS para la interacción; luego, llamar directamente a este HTML en el Markdown del contenido principal.

¿Espera? Escribir HTML + CSS + JS, esto me recuerda a mi primer año de universidad.

Desarrollo del frontend

HTML

La parte HTML es muy simple y aburrida. Para cada grupo de imágenes, proporcionamos los siguientes elementos:

  • Un contenedor para el carrusel deslizante en sí.

  • Las imágenes.

  • Los puntos indicadores de índice en la parte inferior.

  • Botones de flecha para cambiar las imágenes hacia la izquierda y derecha.

<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

Gracias a las tecnologías frontend más modernas, ahora es muy simple implementar esto usando flexbox. La idea central es alinear las imágenes en una fila, pero solo mostrar el espacio de una imagen, ocultando las demás detrás:

.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; /* Centrar horizontalmente */
}

/* 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

La parte de JS se encarga de permitir al usuario cambiar qué imagen específica se muestra en el área visible a través de la interacción. Estas interacciones incluyen:

  • Clic del ratón en los botones izquierdo y derecho.

  • Clic del ratón en los puntos pequeños.

  • Deslizamiento izquierda/derecha en dispositivos táctiles.

Después de que la API web incorporara JQuery, y con la ayuda de rxjs, esto se volvió muy simple:

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();
});

La idea general de todo este código es que para cada grupo de imágenes deslizantes, se registra un índice de la imagen actualmente mostrada: [0, 1, ..., imageNumbers - 1]. La interacción del usuario cambia el índice, calculando así cuántos píxeles debe desplazarse el grupo de imágenes deslizantes para mostrar la imagen indicada por el índice.

Estoy bastante satisfecho con el último fragmento de código rx para la interacción táctil.

La interacción táctil es más compleja que la de botones porque, durante el deslizamiento del usuario, las imágenes deben seguir el dedo, y esta sensación debe ajustarse bien para no frustrar al usuario.

Esta complejidad significa que la interacción requiere más información y API web más complejas.

Lógicamente, necesitamos registrar la posición en píxeles del dedo al inicio del toque, y el desplazamiento en píxeles de la posición del dedo en cada fotograma de renderizado respecto a la posición inicial. Luego, en cada fotograma de renderizado, aplicamos este desplazamiento al grupo de imágenes deslizantes para que las imágenes sigan al dedo. Después de moverse una cierta distancia, cuando el dedo abandona la pantalla, debemos decidir si cambiar la imagen. Por ejemplo, si el usuario solo toca ligeramente la pantalla, causando un desplazamiento de 2 píxeles a la derecha, la imagen no debería cambiar; pero si el dedo se desliza hacia la derecha más de la mitad de la pantalla, debería cambiar la imagen. Establezcamos un umbral del 20% del desplazamiento respecto al ancho total del contenedor. Si la distancia deslizada supera el 20% del ancho del grupo de imágenes, consideramos que debe cambiar la imagen.

En cuanto a las API, hay 3 eventos relevantes:

  • touchstart - Indica que el usuario comenzó a tocar la pantalla. Este evento lleva las coordenadas iniciales en píxeles del dedo.

  • touchmove - Se activa cada vez que el usuario mueve el dedo, llevando las coordenadas actuales del dedo.

  • touchend - Indica que el dedo ha abandonado la pantalla.

Rx es muy adecuado para implementar este tipo de requisitos. Envolvemos los eventos táctiles del usuario en un flujo de datos. Después del procesamiento, se convierte en un flujo de desplazamiento horizontal del dedo, utilizado para actualizar la posición de la imagen en cada fotograma de animación. Finalmente, después de que termina el toque, decidimos si cambiar la imagen según la relación entre el desplazamiento y el ancho del contenedor.


¿De dónde viene la alegría?

El trabajo puede traer alegría porque a nuestro cerebro le gustan los ciclos de retroalimentación.

stateDiagram-v2 [*] --> Trabajo Trabajo --> Ver resultado Ver resultado --> Insatisfecho Insatisfecho --> Pensar en el problema Pensar en el problema --> Trabajo Ver resultado --> Satisfecho, salir Satisfecho, salir --> [*]

El dolor proviene de ciclos incompletos o demasiado largos. Y programar frontend es tan divertido porque su ciclo es muy eficiente. Cada vez que modificas el código, con guardar y actualizar, puedes ver directamente el efecto del cambio. Esto permite completar varias iteraciones de este ciclo de retroalimentación en minutos, generando alegría.

Espero que esta alegría pueda acompañarlos a todos.

Conclusión

Hasta hoy, el contenido de este artículo, como blog de un estudiante universitario, aún puede considerarse innovador y progresista, pero su profundidad es bastante superficial en comparación con mis años de experiencia laboral, hasta el punto de que a menudo me sentía avergonzado al escribirlo.

Pero, de cualquier manera, en comparación con la copia descuidada al crear el blog anterior, esta vez tengo un control total sobre cada aspecto de mi blog. El proceso de retoque me ha brindado una alegría muy pura y simple, y es precisamente esta alegría la que me ha permitido terminar de escribirlo sin vergüenza.