From 0f19482cf2aa0c44f3f5626625830c68b41f7946 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 18 Jun 2026 00:19:51 +0200 Subject: [PATCH] feat: fullscreen image lightbox on image/diagram click Click a note image or a mermaid/tikz diagram to view it fullscreen over a blurred dim backdrop, with a fade/scale animation and rounded white frame. Works on desktop and mobile; diagram SVGs are serialized to a data URL via svgToDataUrl. Closes on click or Escape. --- src/App.vue | 2 + src/components/ImageLightbox.vue | 54 +++++++++++++++++++++++ src/hooks/useImageLightbox.hook.ts | 19 ++++++++ src/styles/app.css | 69 ++++++++++++++++++++++++++++++ src/utils/svgDownload.ts | 3 ++ 5 files changed, 147 insertions(+) create mode 100644 src/components/ImageLightbox.vue create mode 100644 src/hooks/useImageLightbox.hook.ts diff --git a/src/App.vue b/src/App.vue index 9d7363a..9b54610 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,5 @@ + + diff --git a/src/hooks/useImageLightbox.hook.ts b/src/hooks/useImageLightbox.hook.ts new file mode 100644 index 0000000..edf2ed8 --- /dev/null +++ b/src/hooks/useImageLightbox.hook.ts @@ -0,0 +1,19 @@ +import { ref } from "vue" + +const isOpen = ref(false) +const src = ref("") +const alt = ref("") + +export const useImageLightbox = () => { + const open = (imageSrc: string, imageAlt = "") => { + src.value = imageSrc + alt.value = imageAlt + isOpen.value = true + } + + const close = () => { + isOpen.value = false + } + + return { isOpen, src, alt, open, close } +} diff --git a/src/styles/app.css b/src/styles/app.css index 657c9ab..058bd4c 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -344,3 +344,72 @@ iframe { padding-bottom: 0.2rem; padding-inline-start: 0.2rem; } + +/* Image lightbox — desktop click-to-fullscreen */ + +/* Affordance: zoomable content images and diagrams (desktop only). */ +@media (min-width: 769px) { + .note-content img, + .note-display img, + .tikz svg, + .mermaid svg { + cursor: zoom-in; + } +} + +/* DaisyUI hides the native ::backdrop and uses the .modal element's own + background-color for the dim, so the blurred overlay lives on the element. */ +.image-lightbox { + background-color: rgb(0 0 0 / 0); + -webkit-backdrop-filter: blur(0); + backdrop-filter: blur(0); + transition: + visibility 0.3s allow-discrete, + background-color 0.3s ease-out, + -webkit-backdrop-filter 0.3s ease-out, + backdrop-filter 0.3s ease-out; +} + +.image-lightbox[open] { + background-color: rgb(0 0 0 / 0.7); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +@starting-style { + .image-lightbox[open] { + background-color: rgb(0 0 0 / 0); + -webkit-backdrop-filter: blur(0); + backdrop-filter: blur(0); + } +} + +.image-lightbox img { + transition: + opacity 0.3s ease-out, + scale 0.3s ease-out; +} + +.image-lightbox[open] img { + opacity: 1; + scale: 1; +} + +.image-lightbox:not([open]) img { + opacity: 0; + scale: 0.95; +} + +@starting-style { + .image-lightbox[open] img { + opacity: 0; + scale: 0.95; + } +} + +@media (prefers-reduced-motion: reduce) { + .image-lightbox, + .image-lightbox img { + transition: none; + } +} diff --git a/src/utils/svgDownload.ts b/src/utils/svgDownload.ts index 3b7d052..fe48ab0 100644 --- a/src/utils/svgDownload.ts +++ b/src/utils/svgDownload.ts @@ -101,6 +101,9 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => { return new XMLSerializer().serializeToString(clone) } +export const svgToDataUrl = (svg: SVGSVGElement): string => + `data:image/svg+xml;charset=utf-8,${encodeURIComponent(buildExportableSvgString(svg))}` + const triggerBlobDownload = (blob: Blob, filename: string): void => { const url = URL.createObjectURL(blob) const a = document.createElement("a")