From 62861862dfccbaa96d7d685414d1f7f283677868 Mon Sep 17 00:00:00 2001 From: Prud'homme Date: Fri, 3 Jul 2026 07:08:23 +0200 Subject: [PATCH 1/4] chore(release): v0.11.0 Bump version to 0.11.0 and document the user-facing changes since 0.10.0: native File/View menus with Open Recent (#19), fast document open with in-memory cache, cloud-sync UX and a debug HUD (#18), and a search highlight fix on document switch / 1->0 clear (#17). --- CHANGELOG.md | 18 ++++++++++++++++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d6964..af3a31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.11.0](https://github.com/ekino/MarkdownViewer/releases/tag/v0.11.0) - 2026-07-03 + +### Added + +- Native File and View menus matching standard macOS apps: File (Open File… ⌘O, Open Folder… ⇧⌘O, Open Recent ▸, Print… ⌘P, Export as PDF… ⇧⌘S, Close Window) and View (Toggle Dark Mode) +- Open Recent submenu, dynamically populated from the last 10 opened documents (deduped, most-recent-first), recorded on top-level opens (picker, file association, CLI, restore) +- In-memory document cache keyed by path + mtime so re-opening a document is instant, without serving stale content after an external edit +- Loading spinner (shown after a 150ms delay to avoid flashing on fast reads) and a one-time dismissible notice when an uncached read exceeds 600ms, explaining the likely cloud-sync cause (FR + EN) +- Debug HUD (⌘/Ctrl+Shift+D) showing live FPS and per-phase document-open timings (ipc, read, parse, images, mermaid, dom, outline, total) with click-to-copy + +### Changed + +- Documents are read through a dedicated async Rust command (`read_document`, confined to home/resource dirs with canonicalization) instead of the fs plugin, keeping slow reads off the UI thread — fixes multi-second freezes when opening cloud-synced (e.g. OneDrive on-demand) files + +### Fixed + +- Stop lingering search highlights when switching documents (reset now reads the live input value) and when clearing the last character (1→0) on the WKWebView CSS Custom Highlight path + ## [0.10.0](https://github.com/ekino/MarkdownViewer/releases/tag/v0.10.0) - 2026-07-01 ### Added diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a41d0f9..1c42efc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2209,7 +2209,7 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "mdv" -version = "0.10.0" +version = "0.11.0" dependencies = [ "block2", "font-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 487f92c..9b5098a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mdv" -version = "0.10.0" +version = "0.11.0" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 250b9c3..af25ca8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Markdown Viewer", - "version": "0.10.0", + "version": "0.11.0", "identifier": "com.mdv.viewer", "build": { "frontendDist": "../dist", From 27216f7fd4f9579a9595cdb5d8d56b695e8441ac Mon Sep 17 00:00:00 2001 From: Prud'homme Date: Thu, 13 Aug 2026 13:39:25 +0200 Subject: [PATCH 2/4] fix(ui): stop transparent modal backdrops from locking the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A modal backdrop could be left in `display: flex` while fully transparent, covering the whole window at z-index 2000. It swallowed every click and scroll while staying invisible, and because the close path had already detached its listeners, the user had no way to dismiss it — the app was unusable until restart. Reported on 0.10.0: no clicks on Open Folder, no file selection in the sidebar, no scrolling, while the native menu bar still worked. The backdrops were shown and hidden through deferred callbacks. WebKit suspends both animation frames and timers while a window is occluded, so neither the reveal nor the hide is guaranteed to run — leaving the layer displayed but never marked `visible`. Make interactivity follow opacity in CSS: an overlay without `.visible` is now click-through and hidden from the tab order and the accessibility tree. That is the load-bearing guarantee — it holds whatever the reason the layer got stuck, including a keyboard user reaching a destructive button inside an invisible dialog. Defence in depth on top of it: - reveal synchronously (forced reflow, then class) instead of from a frame callback, so a dialog opened on an occluded window is still visible and therefore dismissable; - track an open generation so a pending hide never acts on a backdrop a newer open has claimed, in both confirm-dialog and Preferences; - focus the trap immediately, since callers now reveal before trapping. Tests cover the two suspension modes that produce the field state: frames never delivered, and timers never delivered. Claude-Session: https://claude.ai/code/session_01NcJuB2iR4WEfuQVLpYqWi4 --- CHANGELOG.md | 3 ++ index.html | 31 ++++++++++++++- src/confirm-dialog.test.ts | 79 ++++++++++++++++++++++++++++++++++++++ src/confirm-dialog.ts | 27 ++++++++++--- src/focus-trap.ts | 8 ++-- src/main.ts | 21 ++++++++-- 6 files changed, 157 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af3a31e..f93cdd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed +- Fix the whole window becoming unresponsive — no clicks, no scrolling, no file selection — after using Preferences. A modal backdrop could be left displayed while fully transparent, covering the window and swallowing every pointer event with no way to dismiss it. Transparent overlays are now click-through and removed from the tab order and the accessibility tree, so no blocking state can survive a frame or timer callback that never runs (reported on 0.10.0) +- Modal dialogs no longer rely on an animation frame to become visible, so they still open correctly when the window is occluded and WebKit suspends deferred callbacks +- Reopening Preferences during the closing fade no longer hides the freshly reopened panel or resets its state underneath the user - Stop lingering search highlights when switching documents (reset now reads the live input value) and when clearing the last character (1→0) on the WKWebView CSS Custom Highlight path ## [0.10.0](https://github.com/ekino/MarkdownViewer/releases/tag/v0.10.0) - 2026-07-01 diff --git a/index.html b/index.html index 56e8b6f..8c1065f 100644 --- a/index.html +++ b/index.html @@ -332,11 +332,26 @@ justify-content: center; padding: 20px; opacity: 0; - transition: opacity 0.18s; + transition: opacity 0.18s, visibility 0s 0.18s; + } + + /* Load-bearing safety net: no blocking UI state may depend on a deferred + callback (frame or timer) ever running. A backdrop left displayed + without `.visible` is fully transparent, so intercepting input there + locks the whole window with no way for the user to recover. + `visibility` matters as much as `pointer-events`: without it the + invisible dialog stays in the tab order and in the accessibility tree + (the panel carries aria-modal), so keyboard and screen-reader users + could still reach and activate a destructive button they cannot see. + The delayed `visibility` transition lets the fade-out play in full. */ + .prefs-backdrop:not(.visible) { + pointer-events: none; + visibility: hidden; } .prefs-backdrop.visible { opacity: 1; + transition: opacity 0.18s, visibility 0s; } .prefs-panel { @@ -1617,6 +1632,13 @@ transition: opacity 0.2s; } + /* See .prefs-backdrop — a transparent overlay must never trap input, + focus, or assistive technology. */ + .image-overlay:not(.visible) { + pointer-events: none; + visibility: hidden; + } + .image-overlay.visible { opacity: 1; } @@ -1690,6 +1712,13 @@ transition: opacity 0.2s; } + /* See .prefs-backdrop — a transparent overlay must never trap input, + focus, or assistive technology. */ + .mermaid-overlay:not(.visible) { + pointer-events: none; + visibility: hidden; + } + .mermaid-overlay.visible { opacity: 1; } diff --git a/src/confirm-dialog.test.ts b/src/confirm-dialog.test.ts index 6fc80af..b28ba4c 100644 --- a/src/confirm-dialog.test.ts +++ b/src/confirm-dialog.test.ts @@ -130,6 +130,85 @@ describe("confirmDialog", () => { await expect(p2).resolves.toBe(true); }); + // Regression: the reveal used to run inside requestAnimationFrame. WebKit + // suspends frames while the window is occluded, so the frame never ran and + // the backdrop stayed display:flex at opacity 0 — an invisible layer over + // the whole window that swallowed every click and scroll, leaving the app + // unusable with no way for the user to dismiss it. + it("opens and closes correctly when no animation frame ever runs", async () => { + const originalRaf = window.requestAnimationFrame; + // Frames are requested but never delivered, as on an occluded window. + window.requestAnimationFrame = (() => + 0) as unknown as typeof window.requestAnimationFrame; + + try { + const p = confirmDialog(DEFAULTS); + const backdrop = document.getElementById( + "confirm-backdrop" + ) as HTMLDivElement; + + // Revealed without waiting for a frame: displayed *and* opaque. + expect(backdrop.style.display).toBe("flex"); + expect(backdrop.classList.contains("visible")).toBe(true); + + document.getElementById("confirm-cancel")!.click(); + await p; + + await new Promise((r) => setTimeout(r, 250)); + expect(backdrop.classList.contains("visible")).toBe(false); + expect(backdrop.style.display).toBe("none"); + } finally { + window.requestAnimationFrame = originalRaf; + } + }); + + // Regression for the state actually observed in the field: both backdrops + // left at display:flex without `visible`. WebKit suspends timers as well as + // frames on an occluded window, so the deferred hide never ran. The dialog + // must at least end up non-interactive — the CSS (`:not(.visible)` → + // pointer-events/visibility) is what guarantees the window stays usable, + // and it hinges on `visible` being absent once the dialog is closed. + it("leaves a closed dialog non-visible even when timers never fire", async () => { + const originalTimeout = window.setTimeout; + window.setTimeout = (() => + 0) as unknown as typeof window.setTimeout; + + try { + const p = confirmDialog(DEFAULTS); + const backdrop = document.getElementById( + "confirm-backdrop" + ) as HTMLDivElement; + expect(backdrop.classList.contains("visible")).toBe(true); + + document.getElementById("confirm-cancel")!.click(); + await p; + + // `display` still says flex (its reset was deferred and never ran), so + // the CSS hook must be the thing that neutralises the layer. + expect(backdrop.classList.contains("visible")).toBe(false); + } finally { + window.setTimeout = originalTimeout; + } + }); + + it("keeps the backdrop shown when a newer dialog took it over", async () => { + const p1 = confirmDialog(DEFAULTS); + const p2 = confirmDialog({ ...DEFAULTS, title: "Second" }); + const backdrop = document.getElementById( + "confirm-backdrop" + ) as HTMLDivElement; + + await expect(p1).resolves.toBe(false); + // Past the first dialog's hide delay: it must not hide the second's. + await new Promise((r) => setTimeout(r, 250)); + expect(backdrop.style.display).toBe("flex"); + + document.getElementById("confirm-ok")!.click(); + await p2; + await new Promise((r) => setTimeout(r, 250)); + expect(backdrop.style.display).toBe("none"); + }); + it("falls back to window.confirm when the markup is missing", async () => { document.body.innerHTML = ""; const originalConfirm = window.confirm; diff --git a/src/confirm-dialog.ts b/src/confirm-dialog.ts index addd250..541219d 100644 --- a/src/confirm-dialog.ts +++ b/src/confirm-dialog.ts @@ -33,6 +33,10 @@ function getRefs(): Refs | null { let activeTrap: FocusTrap | null = null; let activeCleanup: (() => void) | null = null; +// Incremented on every open. The close path hides the shared backdrop only if +// no newer dialog has claimed it since, which lets the hide be unconditional +// (see `close`) instead of inferring ownership from the `visible` class. +let openGeneration = 0; /** * Open a confirmation dialog and resolve when the user picks OK or Cancel. @@ -62,21 +66,34 @@ export function confirmDialog(options: ConfirmOptions): Promise { cancel.textContent = options.cancelLabel; ok.classList.toggle("destructive", options.destructive === true); + const generation = ++openGeneration; + let settled = false; + + // Reveal synchronously rather than from a frame callback. WebKit suspends + // both frames and timers while the window is occluded, so nothing deferred + // is guaranteed to run: an open that depends on a frame can leave the dialog + // invisible but present. Forcing layout between `display` and the class + // change gives the opacity transition its starting frame, so the fade still + // plays. Note this is defence in depth, not the load-bearing fix — the + // guarantee that a stuck backdrop cannot lock the window lives in the CSS + // (`.prefs-backdrop:not(.visible)` in index.html). backdrop.style.display = "flex"; - requestAnimationFrame(() => backdrop.classList.add("visible")); + void backdrop.offsetHeight; + backdrop.classList.add("visible"); activeTrap = trapFocus(backdrop); return new Promise((resolve) => { - let settled = false; - function close(result: boolean): void { if (settled) return; settled = true; backdrop.classList.remove("visible"); cleanup(); setTimeout(() => { - // Only hide if no other dialog opened in the meantime. - if (!backdrop.classList.contains("visible")) { + // Hide unconditionally unless a newer dialog now owns the backdrop. + // Never infer ownership from the `visible` class: a stale frame could + // re-add it here and leave the backdrop displayed forever, covering + // the whole window at opacity 0 with every listener already detached. + if (generation === openGeneration) { backdrop.style.display = "none"; } }, 180); diff --git a/src/focus-trap.ts b/src/focus-trap.ts index 03669b6..bf35888 100644 --- a/src/focus-trap.ts +++ b/src/focus-trap.ts @@ -146,9 +146,11 @@ export function trapFocus( } container.addEventListener("keydown", onKeyDown); - // Defer initial focus so the caller's open animation (display:flex → - // visible class) can complete and elements are actually focusable. - requestAnimationFrame(focusInitial); + // Focus immediately: callers reveal the container (display + visible class) + // synchronously before trapping, so its contents are already focusable. + // Deferring through a frame callback would silently skip initial focus + // whenever frames are suspended, e.g. while the window is occluded. + focusInitial(); return { refocusFirst() { diff --git a/src/main.ts b/src/main.ts index f01ca52..e259d2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -325,7 +325,9 @@ function openImageOverlay(img: HTMLImageElement): void { overlay.appendChild(fullImg); document.body.appendChild(overlay); - requestAnimationFrame(() => overlay.classList.add("visible")); + // Reveal synchronously — see confirm-dialog.ts. + void overlay.offsetHeight; + overlay.classList.add("visible"); const trap = trapFocus(overlay); function closeOverlay() { @@ -381,7 +383,9 @@ function openMermaidFullscreen(svgContent: string, title: string): void { overlay.appendChild(body); document.body.appendChild(overlay); - requestAnimationFrame(() => overlay.classList.add("visible")); + // Reveal synchronously — see confirm-dialog.ts. + void overlay.offsetHeight; + overlay.classList.add("visible"); const trap = trapFocus(overlay); function onKeyDown(e: KeyboardEvent) { @@ -1207,6 +1211,9 @@ async function saveEditorTheme(): Promise { } let prefsFocusTrap: FocusTrap | null = null; +// Bumped on every open so a pending close callback can tell whether it still +// owns the panel — see closePrefs. +let prefsGeneration = 0; function getActiveTabButton(): HTMLButtonElement | null { for (const t of prefsTabs) { @@ -1233,8 +1240,11 @@ function setActiveTab(tab: string): void { function openPrefs(): void { syncPrefsUI(); + // Reveal synchronously — see confirm-dialog.ts. + prefsGeneration++; prefsBackdrop.style.display = "flex"; - requestAnimationFrame(() => prefsBackdrop.classList.add("visible")); + void prefsBackdrop.offsetHeight; + prefsBackdrop.classList.add("visible"); void populateFontDropdowns(); prefsFocusTrap?.release(); // Open with focus on the active tab rather than the close (×) button, which @@ -1252,7 +1262,12 @@ function setAdvancedOpen(open: boolean): void { function closePrefs(): void { prefsBackdrop.classList.remove("visible"); + const generation = prefsGeneration; setTimeout(() => { + // Skip if Preferences were reopened during the fade-out: this callback + // would otherwise hide a panel the user just asked for, and wipe its state + // underneath them. Same ownership problem as confirm-dialog.ts. + if (generation !== prefsGeneration) return; prefsBackdrop.style.display = "none"; // Reset transient UI state so the next open lands on a clean default: // - close the theme editor if it was open (no preview rollback needed — From 30e6de6e4d4f88558d6c62ad1beb35a94cd2f10a Mon Sep 17 00:00:00 2001 From: Prud'homme Date: Thu, 13 Aug 2026 13:53:53 +0200 Subject: [PATCH 3/4] docs(changelog): record multi-window support in 0.11.0 PR #22 landed without a changelog entry. Covers the new windows, the native Window menu, the arrangement commands, and the switch from broadcast to frontmost-window menu delivery. Claude-Session: https://claude.ai/code/session_01NcJuB2iR4WEfuQVLpYqWi4 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f93cdd5..77498c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - In-memory document cache keyed by path + mtime so re-opening a document is instant, without serving stale content after an external edit - Loading spinner (shown after a 150ms delay to avoid flashing on fast reads) and a one-time dismissible notice when an uncached read exceeds 600ms, explaining the likely cloud-sync cause (FR + EN) - Debug HUD (⌘/Ctrl+Shift+D) showing live FPS and per-phase document-open timings (ipc, read, parse, images, mermaid, dom, outline, total) with click-to-copy +- Multi-window support: File › New Window (⌘N) opens an empty window and File › Open Folder in New Window… (⇧⌘N) opens a picked folder in its own window, so two sets of docs can be read side by side +- Native Window menu listing every open window (checked on the frontmost, select to bring forward), with windows titled after the document or folder they show +- Window arrangement commands: Fill, Center, Move & Resize (halves, quarters, Return to Previous Size) and Bring All to Front ### Changed - Documents are read through a dedicated async Rust command (`read_document`, confined to home/resource dirs with canonicalization) instead of the fs plugin, keeping slow reads off the UI thread — fixes multi-second freezes when opening cloud-synced (e.g. OneDrive on-demand) files +- Menu commands now target the frontmost window instead of broadcasting to every window, so ⌘F no longer focuses several search fields and Open Folder… no longer raises several pickers ### Fixed From 611f0f6f25078c26237ad71747844e0ced02d21a Mon Sep 17 00:00:00 2001 From: Prud'homme Date: Thu, 13 Aug 2026 13:57:50 +0200 Subject: [PATCH 4/4] docs(changelog): set the 0.11.0 release date The heading still carried the date the release branch was prepared. Claude-Session: https://claude.ai/code/session_01NcJuB2iR4WEfuQVLpYqWi4 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77498c7..bd83745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). -## [0.11.0](https://github.com/ekino/MarkdownViewer/releases/tag/v0.11.0) - 2026-07-03 +## [0.11.0](https://github.com/ekino/MarkdownViewer/releases/tag/v0.11.0) - 2026-08-13 ### Added