feat: fullscreen image lightbox on image/diagram click
All checks were successful
CI / verify (push) Successful in 2m2s

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.
This commit is contained in:
Julien Calixte
2026-06-18 00:19:51 +02:00
parent 826f7a4ec9
commit 0f19482cf2
5 changed files with 147 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import ImageLightbox from "@/components/ImageLightbox.vue"
import NewVersion from "@/components/NewVersion.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
@@ -12,6 +13,7 @@ const { isATProtoReady } = useATProtoLogin()
<router-view v-if="isReady && isATProtoReady" />
<new-version />
<image-lightbox />
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { useEventListener } from "@vueuse/core"
import { ref, watch } from "vue"
import { useImageLightbox } from "@/hooks/useImageLightbox.hook"
import { svgToDataUrl } from "@/utils/svgDownload"
const { isOpen, src, alt, open, close } = useImageLightbox()
const dialogRef = ref<HTMLDialogElement | null>(null)
const onClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest(".svg-download-buttons")) return
const svg = target.closest<SVGSVGElement>(".tikz svg, .mermaid svg")
if (svg) {
event.preventDefault()
open(svgToDataUrl(svg), "diagram")
return
}
const img = target.closest("img")
if (img && img.closest(".note-content, .note-display")) {
event.preventDefault()
open(img.currentSrc || img.src, img.alt)
}
}
useEventListener(document, "click", onClick)
watch(isOpen, (value) => {
const el = dialogRef.value
if (!el) return
if (value && !el.open) el.showModal()
else if (!value && el.open) el.close()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal image-lightbox not-prose"
@close="close()"
@click="close()"
>
<img
v-if="src"
:src="src"
:alt="alt"
class="max-h-[100vh] max-w-[100vw] rounded-lg bg-white p-3 cursor-zoom-out"
/>
</dialog>
</template>

View File

@@ -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 }
}

View File

@@ -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;
}
}

View File

@@ -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")