Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ 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-08-13

### 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
- 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

- 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

### Added
Expand Down
31 changes: 30 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mdv"
version = "0.10.0"
version = "0.11.0"
description = "A Tauri App"
authors = ["you"]
license = ""
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
79 changes: 79 additions & 0 deletions src/confirm-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 22 additions & 5 deletions src/confirm-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -62,21 +66,34 @@ export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
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<boolean>((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);
Expand Down
8 changes: 5 additions & 3 deletions src/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
21 changes: 18 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1207,6 +1211,9 @@ async function saveEditorTheme(): Promise<void> {
}

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) {
Expand All @@ -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
Expand All @@ -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 —
Expand Down
Loading