HellDots

Frameworks

Next.js, React, Vue, Astro, SvelteKit y una etiqueta script simple — más la única llamada que todo router del lado del cliente tiene que hacer.

HellDots es agnóstico al framework: es una función que monta un widget y devuelve un objeto. Lo que cambia entre frameworks es solo dónde lo llamas y cuándo le dices que la página cambió.

Aplicaciones renderizadas en el servidor

Importar el paquete en el servidor es seguro — nada toca el DOM al importarse. Llamar a createCommentOverlay en el servidor no lo es. Mantén la llamada en el cliente:

components/comments.tsx
'use client';

import { useEffect } from 'react';
import { createCommentOverlay } from 'helldots';

export function Comments({ user }: { user: { name: string; id?: string } }) {
  useEffect(() => {
    const overlay = createCommentOverlay({ user, persistence: 'localStorage' });
    return () => overlay.cleanup();
  }, [user]);

  return null;
}

Renderízalo desde el layout raíz, y mira SPAs más abajo para el cableado del router — el App Router es una.

cleanup() es lo que hace esto seguro

React ejecuta los efectos dos veces en desarrollo. Devolver overlay.cleanup() desde el efecto elimina el widget por completo, así que el segundo montaje no te deja con dos barras de herramientas. Es también lo que quieres al cerrar sesión, o cuando el componente se desmonta por cualquier otra razón.

Aplicaciones de una sola página

Un router del lado del cliente cambia el DOM sin recargar la página. HellDots no puede verlo, así que hay dos cosas que cablear.

const overlay = createCommentOverlay({
  user,
  persistence: 'localStorage',

  // 1. Deja que los saltos entre páginas del widget usen tu router.
  navigate: (page) => router.push(page),
});

// 2. Avisa al widget después de cada renderizado de ruta.
router.afterEach(() => overlay.notifyNavigation());

navigate lo usa el salto de "ver en su página" de la bandeja. Sin él, ese salto es una recarga completa, que tira por la borda el estado de tu aplicación.

notifyNavigation() reclasifica cada comentario contra la nueva ruta, vuelve a resolver las anclas contra el nuevo DOM, reconstruye los marcadores y mueve la bandeja a la nueva página. Devuelve las mismas cuentas { anchored, orphaned, inactive } que loadComments().

Llámalo después de que la nueva ruta se haya pintado de verdad, no antes — los elementos tienen que existir para que las anclas se resuelvan contra ellos.

No lo programes solo con requestAnimationFrame

Dos requestAnimationFrame es la forma natural de decir "después de que el navegador haya maquetado esto", y es correcta — hasta que la pestaña está oculta. Una pestaña oculta nunca pinta, así que su requestAnimationFrame no se dispara jamás, y una navegación que ocurra mientras alguien mira otra pestaña deja al widget apuntando a la página anterior.

El maquetado sí se calcula en una pestaña oculta, así que dale al frame una fecha límite en lugar de una espera más larga — gana el que llegue primero. Hay un ayudante onNextPaint desarrollado en la página del playground; este sitio lo usa.

Atrás y adelante

createCommentOverlay({ autoDetectNavigation: true });

Opcional, y solo para popstate: cubre los botones de atrás y adelante del navegador. El enrutado con pushState no dispara popstate, así que el gancho de tu propio router sigue siendo necesario. Cablea los dos.

'use client';

import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { createCommentOverlay, type CommentOverlay } from 'helldots';

export function Comments() {
  const [overlay, setOverlay] = useState<CommentOverlay | null>(null);
  const router = useRouter();
  const pathname = usePathname();

  useEffect(() => {
    const instance = createCommentOverlay({
      persistence: 'localStorage',
      autoDetectNavigation: true,
      navigate: (page) => router.push(page),
      onReady: setOverlay,
    });
    return () => instance.cleanup();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    if (!overlay) return;
    // Dos frames: deja que la nueva ruta pinte antes de resolver las anclas.
    const frame = requestAnimationFrame(() =>
      requestAnimationFrame(() => overlay.notifyNavigation()),
    );
    return () => cancelAnimationFrame(frame);
  }, [overlay, pathname]);

  return null;
}

Volver a anclar sin navegar

notifyNavigation() es también la primitiva de "vuelve a anclar ahora". Si tu aplicación reemplazó el DOM de una ruta sin cambiar la URL — un cambio de pestaña, una recarga de datos que reconstruye una lista — llámalo y los comentarios vuelven a encontrar sus elementos.

await refetchDashboard();
overlay.notifyNavigation();

Atajos de teclado y el Shadow DOM

El widget se renderiza dentro de un Shadow DOM, que es lo que mantiene tu CSS fuera de él. Tiene una consecuencia que conviene conocer antes de que te muerda: un evento que cruza un límite de shadow se retargetea. Un listener de keydown en window ve event.target como el elemento anfitrión <helldots-root>, no el textarea al que va el carácter.

Cualquier atajo de la página que se proteja con "¿estoy escribiendo?" está escrito contra event.target, así que esa guarda aquí es ciega. En un sitio de documentación con un atajo de tema de una sola letra, escribir d dentro de un comentario cambia la página de claro a oscuro a media frase.

A este sitio le pasó exactamente eso

Tres atajos se disparaban al escribir un comentario: d y D cambiaban el tema (el retargeting derrotaba la guarda del anfitrión), Cmd/Ctrl+K abría el buscador sobre un borrador sin enviar, y Alt+C — el atajo propio de HellDots, y la forma de escribir ç en macOS — cerraba la caja.

composedPath() es la solución. A diferencia de target, informa del elemento real dentro del shadow root, así que una guarda escrita contra él sí ve el textarea:

function isEditable(node: EventTarget): boolean {
  if (!(node instanceof HTMLElement)) return false;
  if (node.isContentEditable) return true;
  return ['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName);
}

function typing(event: KeyboardEvent): boolean {
  return event.composedPath().some(isEditable);
}

Si el atajo es tuyo, protégelo con eso. Si pertenece a un framework que no controlas, intercéptalo antes — un listener en fase de captura sobre window se ejecuta antes que todos ellos:

window.addEventListener(
  'keydown',
  (event) => {
    if (event.isComposing || !typing(event)) return;

    const key = event.key.toLowerCase();
    const bare = !event.metaKey && !event.ctrlKey && !event.altKey;

    // Solo propagación — nunca preventDefault, o el carácter no se escribe.
    if ((key === 'd' && bare) || (event.altKey && event.code === 'KeyC')) {
      event.stopImmediatePropagation();
    }
  },
  true,
);

Dos detalles que importan:

  • Detén la propagación, nunca preventDefault(). Controlar la propagación impide que el manejador del atajo se ejecute; la acción por defecto —insertar el carácter— no se ve afectada en ningún caso. Llamar a preventDefault() se tragaría la pulsación.
  • No bloquees a ciegas. Deja en paz Escape, Enter y Cmd+Enter: el widget los usa para descartar la caja, enviar una respuesta y enviar un comentario.

El atajo propio de HellDots tampoco está protegido

Alt+C se dispara desde dentro de un campo de texto, incluida la propia caja de comentario del widget, y e.code === "KeyC" hace que capture el Option+C de macOS que produce ç. Hasta que eso se proteja en la librería, la intercepción de arriba es lo que lo cubre. Cambiar shortcutModifier a "ctrl" evita la colisión con la ç en concreto.

Sin bundler

El build UMD define una variable global HellDots y lleva el renderizador de capturas dentro:

<script src="https://unpkg.com/helldots@0.12.1"></script>
<script>
  const overlay = HellDots.createCommentOverlay({
    user: { name: 'Ana' },
    persistence: 'localStorage',
  });
</script>

unpkg sirve el build UMD por defecto

El campo unpkg del paquete apunta a dist/helldots.umd.js, así que https://unpkg.com/helldots — con o sin ?module — es el archivo UMD. Asigna una variable global y no exporta nada, así que hacer import de esa URL no funcionará.

Para ESM nativo sin ningún paso de build, usa una CDN que resuelva especificadores desnudos, para que el import("modern-screenshot") dinámico de dentro del paquete tenga adónde ir:

<script type="module">
  import { createCommentOverlay } from 'https://esm.sh/helldots@0.12.1';

  createCommentOverlay({ persistence: 'localStorage' });
</script>

Servir dist/helldots.esm.js directamente desde unpkg o jsDelivr también funciona, pero entonces la dependencia peer es cosa tuya — con un import map:

<script type="importmap">
  {
    "imports": {
      "modern-screenshot": "https://esm.sh/modern-screenshot@4"
    }
  }
</script>
<script type="module">
  import { createCommentOverlay } from 'https://unpkg.com/helldots@0.12.1/dist/helldots.esm.js';

  createCommentOverlay({ persistence: 'localStorage' });
</script>

Posponer el montaje

Por defecto createCommentOverlay monta de inmediato. Pasa autoInit: false y recibes en su lugar un inicializador — útil cuando el widget solo debe aparecer para personal con sesión iniciada, o detrás de un feature flag.

const init = createCommentOverlay({ autoInit: false, user });

if (session.isStaff) {
  const overlay = init();
}

En esta página