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 76099a7..fe48ab0 100644 --- a/src/utils/svgDownload.ts +++ b/src/utils/svgDownload.ts @@ -71,17 +71,22 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => { clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink") 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")) { - clone.setAttribute("viewBox", `0 0 ${width} ${height}`) + clone.setAttribute("viewBox", `${originX} ${originY} ${width} ${height}`) } clone.setAttribute("width", String(width)) 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") - bg.setAttribute("x", "0") - bg.setAttribute("y", "0") - bg.setAttribute("width", "100%") - bg.setAttribute("height", "100%") + bg.setAttribute("x", String(originX)) + bg.setAttribute("y", String(originY)) + bg.setAttribute("width", String(width)) + bg.setAttribute("height", String(height)) bg.setAttribute("fill", "#ffffff") clone.insertBefore(bg, clone.firstChild) @@ -96,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") diff --git a/src/views/TodoNotes.vue b/src/views/TodoNotes.vue index a10c5c4..ebb8126 100644 --- a/src/views/TodoNotes.vue +++ b/src/views/TodoNotes.vue @@ -24,6 +24,7 @@ type Prop = { } const TODO_PATH = "todo.txt" +const DONE_PATH = "done.txt" const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue")) @@ -59,6 +60,34 @@ watch( { immediate: true } ) +const doneFile = computed(() => store.files.find((f) => f.path === DONE_PATH)) +const doneSha = computed(() => doneFile.value?.sha ?? "") +const donePath = computed(() => doneFile.value?.path ?? DONE_PATH) + +// Reuse useTodoTxtCommit as a read-only loader. We never call its `mutate`, +// so nothing is ever written to done.txt from this view — the file is +// produced by an external tool. +const { items: doneItems, syncContent: syncDoneContent } = useTodoTxtCommit({ + user: props.user, + repo: props.repo, + path: donePath, + initialContent: "", + initialSha: doneSha, + debounceMs: 1000 +}) + +watch( + doneSha, + async (newSha) => { + if (!newSha) return + const base64 = await queryFileContent(props.user, props.repo, newSha) + if (base64) { + syncDoneContent(decodeBase64ToUTF8(base64), newSha) + } + }, + { immediate: true } +) + // Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last // regardless of locale collation quirks. const priorityRank = (p?: string): number => @@ -76,6 +105,10 @@ const taskEntries = computed(() => { return out }) +const archivedTasks = computed(() => + doneItems.value.filter((line): line is Task => !isBlank(line)) +) + const allProjects = computed(() => { const set = new Set() items.value.forEach((line) => { @@ -440,6 +473,15 @@ const createTodoFile = async () => { > Your todo list is empty. Add a task above.

+ +
+ Archived ({{ archivedTasks.length }}) + +
@@ -540,5 +582,37 @@ const createTodoFile = async () => { .todo-filter-chip { cursor: pointer; } + + .todo-archive { + margin-top: 1rem; + + > summary { + cursor: pointer; + opacity: 0.7; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + list-style: none; + } + + > summary::-webkit-details-marker { + display: none; + } + } + + .todo-archive-list { + list-style: none; + padding-left: 0; + margin: 0.5rem 0 0; + display: flex; + flex-direction: column; + + li { + list-style: none; + opacity: 0.7; + padding-bottom: 0.25rem; + } + } }