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:
'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 apreventDefault()se tragaría la pulsación. - No bloquees a ciegas. Deja en paz
Escape,EnteryCmd+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();
}Persistencia
Guarda los comentarios en el navegador, en tu propio almacén, o reconcilia los dos — y ten claro a qué renuncia cada modo.
Capturas
Qué es de verdad una captura de HellDots, por qué puede ser lenta, las cuatro palancas sobre su coste, y cómo mantener las imágenes fuera de tu base de datos.