Cada diagrama y cada captura de pantalla de este sitio se abre en el mismo visor a pantalla completa al hacer clic: rueda del ratón para hacer zoom en escritorio, pellizco o doble toque en táctil, arrastre para desplazarte una vez que has hecho zoom. Hay exactamente uno de estos montado en la página, una sola vez, en el layout base, y cada imagen de cada entrada se conecta a él a través de un único atributo HTML. Nada de esto viene de una librería. Está construido directamente sobre la Pointer Events API, y quiero repasar por qué esa fue la decisión correcta aquí, y en qué casos esperaría que la decisión contraria fuera la correcta.
Lo que realmente hay
El componente es Lightbox.astro, montado una única vez en BaseLayout: una instancia para todo el sitio, no una por imagen. Cualquier elemento que lleve un atributo data-lightbox-trigger con un <img> dentro se convierte en un disparador: un único listener de clic delegado en document comprueba e.target.closest("[data-lightbox-trigger]"), extrae la imagen de dentro y la abre en el visor compartido. ImagePlaceholder.astro —el componente a través del cual se renderiza en realidad cada diagrama y captura de este log— es uno de los sitios que conecta el disparador; envuelve su imagen en un <button data-lightbox-trigger data-lightbox-caption={caption}> y deja que el lightbox global lo recoja al hacer clic.
Dentro del propio visor, el estado es genuinamente pequeño: una escala, un desplazamiento de paneo x/y, y un Map<number, {x, y}> de punteros activos. Cada gesto está construido sobre los mismos tres eventos Pointer Events —pointerdown, pointermove, pointerup/pointercancel—, porque esos eventos ya unifican la entrada de ratón, táctil y lápiz bajo una única API, que es exactamente la unificación que necesita una implementación hecha a mano para evitar escribir rutas de código separadas para ratón y táctil. Un puntero pulsado mientras hay zoom aplicado inicia un desplazamiento, seguido como un offset respecto a la posición inicial del puntero. Dos punteros pulsados inician un pellizco, seguido por la distancia entre ellos relativa al punto donde empezó el pellizco, con el zoom anclado en el punto medio entre los dos dedos, de modo que la imagen hace zoom hacia el punto donde realmente estás pellizcando en lugar de saltar a un centro fijo. Un listener de wheel gestiona el zoom con la rueda del ratón en escritorio, anclado de la misma forma en la posición del cursor. Un listener de dblclick alterna entre 1x y un zoom fijo de 2,5x. Un pequeño flag didDrag distingue un gesto de desplazamiento real de un toque, de modo que tocar el fondo cierra el visor pero arrastrar sobre la imagen no lo cierra por accidente en mitad del desplazamiento.
Nada de eso es código exótico. Son quizá un par de cientos de líneas en total, incluyendo los botones de acercar/alejar zoom, el botón de cierre, el renderizado del pie de foto, y el manejo de la tecla escape y del clic fuera del visor. Es también código que entiendo por completo, porque escribí cada línea contra una única API bien conocida, en lugar de contra la abstracción de una librería sobre esa API.
Lo que habría aportado una librería
Antes de escribir esto miré librerías existentes de lightbox y zoom, y quiero ser justo con lo que ofrecen, porque es real. Una librería madura de visor de imágenes te da un manejo de gestos que ya se ha puesto a prueba en una gama de dispositivos mucho más amplia de la que cubren mis propias pruebas: casos límite en cómo distintos navegadores reportan los eventos de puntero, un scroll con inercia que se siente natural, patrones de accesibilidad refinados con uso real de lectores de pantalla, navegación por teclado entre toda una galería de imágenes, tiras de miniaturas, modos de presentación de diapositivas, integración con carga diferida, y una comunidad que encuentra y arregla los bugs que de otro modo tendría que encontrar yo mismo. Incorporar una librería bien mantenida significa que alguien más ya se topó con el bug de pellizco-zoom en alguna versión concreta del WebView de Android, y ya lo arregló.
Es un trade-off genuino, no un hombre de paja. Escribir tu propia versión de cualquier cosa significa heredar sus bugs en solitario, y una librería ampliamente usada acumula órdenes de magnitud más horas de uso puliendo sus asperezas de las que una implementación de un solo sitio llegará a tener nunca.
Por qué construirlo directamente ganó esta vez
La superficie de interacción que este sitio realmente necesita es estrecha: abrir una imagen a pantalla completa, hacer zoom, desplazarse, cerrar. Eso es todo. No hay modo galería, no hay presentación de diapositivas, no hay tira de miniaturas, no hace falta deslizar entre imágenes en secuencia: cada diagrama o captura se abre y se cierra por su cuenta. Cuando el requisito real es así de pequeño y así de bien entendido, el cálculo sobre una librería de propósito general cambia. Una librería construida para cubrir galerías, presentaciones de diapositivas, múltiples formatos de imagen, vídeo y una docena de opciones de configuración está resolviendo un problema mucho más grande que el que tiene este sitio, y cada una de esas capacidades extra es código que el navegador tiene que analizar y ejecutar, y superficie de API que yo tendría que aprender, incluso para la fracción que realmente usaría.
Tres cosas inclinaron la balanza hacia construirlo directamente:
Sin dependencia añadida para una interacción única y bien acotada. Todo el sentido de ImagePlaceholder y Lightbox aquí es que son piezas pequeñas y autocontenidas en un código base que, por lo demás, evita incorporar una dependencia en tiempo de ejecución para resolver un problema para el que la plataforma ya tiene la mayoría de las primitivas. Pointer Events ya unifica ratón/táctil/lápiz. getBoundingClientRect, las transformaciones CSS y un puñado de matemáticas acotadas ya dan zoom anclado en un punto y desplazamiento limitado. Aquí no falta ninguna capacidad del navegador que solo aporte una librería: solo hay código de pegamento, y el código de pegamento es lo bastante pequeño como para asumirlo directamente.
Control exacto sobre el único patrón de interacción que importa. Como escribí yo mismo las matemáticas de desplazamiento/zoom, sé exactamente por qué el zoom se ancla en el cursor o en el punto medio del pellizco en lugar de en el centro de la imagen, sé exactamente por qué clampPan limita el offset de desplazamiento a las dimensiones reales y escaladas de la imagen en lugar de dejar que se arrastre fuera de pantalla, y sé exactamente por qué el manejador de cierre-al-hacer-clic-en-el-fondo comprueba primero un flag de arrastre. Cada una de esas es una decisión pequeña, y cada una de esas decisiones es fácil de cambiar porque no hay ningún valor por defecto de una librería que esté sobrescribiendo o rodeando para llegar hasta ahí.
Sin pelearme con las asunciones de una librería de propósito general. Este es el modo de fallo que estaba evitando activamente. Las librerías de UI de propósito general toman decisiones para el caso común que no siempre encajan con uno específico: un conjunto fijo de breakpoints, una estructura DOM concreta que la librería espera poseer, un timing de animación que hay que sobrescribir con tu propia pelea de especificidad CSS, un modelo de eventos que no expone del todo el hook que necesitas para el único comportamiento que realmente te importa. Nada de eso es una crítica a esas librerías; es la naturaleza de construir algo lo bastante general para muchos llamadores. Pero significa que adoptar una a menudo cambia «escribir tú mismo la interacción» por «aprender el modelo de la librería, y luego doblegarlo hacia lo único que necesitas», y para una interacción tan estrecha como esta, ese segundo coste no es obviamente menor que el primero.
Dónde esta decisión cambiaría de sentido
No creo que «constrúyelo tú mismo» sea la opción por defecto correcta para las interacciones de UI en general, y estaría tergiversando esta decisión si la planteara así. Si este sitio necesitara una experiencia de galería de verdad —deslizar por una secuencia de imágenes, navegación por miniaturas, contenido mixto de imagen y vídeo, o el tipo de acabado que viene de miles de horas de pruebas entre dispositivos en un producto de cara al consumidor—, recurriría a una librería consolidada sin dudarlo demasiado, porque en ese punto el problema es genuinamente más grande que «una interacción bien entendida», y la carga de mantenimiento y de casos límite de poseer directamente todo ese código deja de merecer la pena. La razón por la que esta decisión concreta fue en la otra dirección es que el requisito real se mantuvo lo bastante pequeño, durante lo bastante tiempo, como para poder describir toda la superficie de interacción en un solo párrafo, y una vez que un requisito está así de acotado, poseer el código directamente cuesta menos que aprender y pelearte con la abstracción de otra persona sobre él.
Lo que sacrifiqué al no probarlo como se prueba una librería
Quiero ser honesto sobre la parte de este trade que es fácil pasar por alto: mi propia superficie de pruebas no se acerca ni de lejos a lo que hay detrás de una librería ampliamente usada. He verificado que esto funciona con ratón, con gestos de pellizco en trackpad, y con táctil en el par de teléfonos y tablets que realmente tengo. No lo he verificado contra la larga cola de WebViews de Android más antiguos, entradas de lápiz óptico poco habituales, o cada combinación de navegador y sistema operativo contra la que una librería con miles de usuarios ya habría tenido bugs reportados. Si hay por ahí algún dispositivo donde pointercancel se dispara en un momento ligeramente distinto al de los que probé, o donde un dblclick no se compone limpiamente con una secuencia de punteros previa, probablemente no me enteraré por un reporte de bug: me enteraré, si es que me entero, notándolo yo mismo en algún dispositivo que esté usando. Ese es un coste real de poseer esto directamente, y es el coste al que apuntaría primero si me preguntaran por qué esto no es automáticamente la decisión correcta para cada interacción del sitio.
El factor atenuante es que el modo de fallo aquí está acotado y no es crítico. Si el gesto de zoom se comporta ligeramente mal en algún dispositivo poco común, el peor resultado es que un lector no pueda hacer zoom en un diagrama tan suavemente como se pretendía: la imagen en sí sigue siendo visible, el pie de foto sigue ahí, el resto de la página sigue funcionando. No es una interacción que condicione el acceso a contenido esencial o a un flujo de compra, donde un bug de caso límite tiene un coste real para alguien. Eso es parte de por qué el cálculo favoreció construirlo: no solo que la superficie de interacción sea pequeña, sino que el radio de impacto de equivocarse en un caso límite también lo sea.
Mantenerlo como una única instancia en lugar de una por imagen
Una decisión estructural que merece mencionarse aparte de las matemáticas del zoom en sí: hay exactamente un Lightbox montado en BaseLayout, no uno instanciado por cada imagen de una página. Cada elemento data-lightbox-trigger de la página —sin importar cuántos diagramas o capturas tenga esa entrada en concreto— comparte el mismo visor, las mismas variables de estado, los mismos listeners de eventos. Una entrada con una docena de diagramas no significa una docena de conjuntos de listeners de eventos de puntero peleándose por el reconocimiento de gestos; significa una docena de disparadores, cada uno de los cuales rellena el mismo visor único con una imagen y un pie de foto distintos al hacer clic. Eso no es algo que una librería necesariamente haga mal, pero es el tipo de suposición que es fácil equivocar por accidente cuando recurres a un modelo mental de componente-por-instancia, y es el tipo de cosa que es trivial hacer bien cuando eres tú quien decide desde el principio cómo se estructura la pieza: exactamente el criterio de «un segundo llamador plausible» que describo en Cada página es su propio archivo para decidir qué se convierte en componente compartido.