Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
0f19482cf2 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.
2026-06-18 00:19:51 +02:00
Julien Calixte
826f7a4ec9 fix(svg): cover full viewBox with export background
The white background rect was anchored at 0,0, but a diagram's viewBox
origin can be negative (content drawn above y=0, e.g. a QFD house roof),
leaving that content outside the fill. Anchor the rect to the viewBox
origin so the whole drawing sits on white in PNG/SVG exports.
2026-06-18 00:19:38 +02:00
5 changed files with 157 additions and 5 deletions

View File

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

@@ -71,17 +71,22 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink") clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink")
const { width, height } = getSvgPixelSize(svg) const { width, height } = getSvgPixelSize(svg)
const viewBox = svg.viewBox?.baseVal
const originX = viewBox && viewBox.width > 0 ? viewBox.x : 0
const originY = viewBox && viewBox.height > 0 ? viewBox.y : 0
if (!clone.getAttribute("viewBox")) { if (!clone.getAttribute("viewBox")) {
clone.setAttribute("viewBox", `0 0 ${width} ${height}`) clone.setAttribute("viewBox", `${originX} ${originY} ${width} ${height}`)
} }
clone.setAttribute("width", String(width)) clone.setAttribute("width", String(width))
clone.setAttribute("height", String(height)) clone.setAttribute("height", String(height))
// Cover the full viewBox (its origin can be negative — e.g. a diagram whose
// content extends above y=0), otherwise that content falls outside the fill.
const bg = document.createElementNS(SVG_NS, "rect") const bg = document.createElementNS(SVG_NS, "rect")
bg.setAttribute("x", "0") bg.setAttribute("x", String(originX))
bg.setAttribute("y", "0") bg.setAttribute("y", String(originY))
bg.setAttribute("width", "100%") bg.setAttribute("width", String(width))
bg.setAttribute("height", "100%") bg.setAttribute("height", String(height))
bg.setAttribute("fill", "#ffffff") bg.setAttribute("fill", "#ffffff")
clone.insertBefore(bg, clone.firstChild) clone.insertBefore(bg, clone.firstChild)
@@ -96,6 +101,9 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
return new XMLSerializer().serializeToString(clone) 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 triggerBlobDownload = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")