feat: fullscreen image lightbox on image/diagram click
All checks were successful
CI / verify (push) Successful in 2m2s
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:
@@ -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>
|
||||
|
||||
|
||||
54
src/components/ImageLightbox.vue
Normal file
54
src/components/ImageLightbox.vue
Normal 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>
|
||||
19
src/hooks/useImageLightbox.hook.ts
Normal file
19
src/hooks/useImageLightbox.hook.ts
Normal 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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user