feat(nav): peel overlays with esc, close the book on a second esc

This commit is contained in:
pj committed 2026-08-10 18:37:23 +05:30
1 parent 2e15dc2dc9
commit fe39274ed7
12 files changed
+128 -57

No files matched your search

+6 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { PAGE_TYPES } from "../model/book";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface AddPageMenuProps {
@@ -44,12 +45,13 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
}, [open]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
});
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement);
+3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Icon } from "./Icon";
import { ConfirmDialog } from "./ConfirmDialog";
import { useBackup } from "../store/useBackup";
import { useEscapeLayer } from "../escape";
import { formatBackupTime, gdriveListBackups } from "../backup";
export function BackupSettings() {
@@ -38,6 +39,8 @@ export function BackupSettings() {
};
}, [open, connected, lastBackup]);
useEscapeLayer(open, close);
if (!open) return null;
return (
+3 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface ConfirmDialogProps {
@@ -14,17 +15,10 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
useEffect(() => {
confirmRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEscapeLayer(true, onClose);
return (
<div className="overlay" onClick={onClose}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
+30 -13
View File
@@ -22,6 +22,7 @@ import { useWidth } from "../store/useWidth";
import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library";
import { useEscapeLayer } from "../escape";
import { isDesktop } from "../ipc";
import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing";
@@ -59,6 +60,10 @@ export function EditorView() {
const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false);
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null);
const [escArmed, setEscArmed] = useState(false);
const escArmedRef = useRef(false);
escArmedRef.current = escArmed;
const escTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const spelling = useProofing((s) => s.spelling);
const grammar = useProofing((s) => s.grammar);
@@ -84,6 +89,25 @@ export function EditorView() {
setDock(false);
};
useEscapeLayer(true, () => {
clearTimeout(escTimer.current);
if (escArmedRef.current) {
setEscArmed(false);
useBook.getState().closeBook();
return;
}
(document.activeElement as HTMLElement | null)?.blur();
setEscArmed(true);
escTimer.current = setTimeout(() => setEscArmed(false), 1500);
});
useEscapeLayer(isCompact && (sidebarOpen || dock), closeDrawers);
useEscapeLayer(widthOpen, () => setWidthOpen(false));
useEscapeLayer(exportOpen, () => setExportOpen(false));
useEscapeLayer(moreOpen, () => setMoreOpen(false));
useEscapeLayer(!!proofPopover, () => setProofPopover(null));
useEffect(() => () => clearTimeout(escTimer.current), []);
const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded);
setFindOpen(true);
@@ -173,19 +197,6 @@ export function EditorView() {
};
}, []);
useEffect(() => {
if (!widthOpen && !exportOpen && !moreOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setWidthOpen(false);
setExportOpen(false);
setMoreOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [widthOpen, exportOpen, moreOpen]);
useEffect(() => {
if (!notice) return;
const timer = setTimeout(() => setNotice(null), 4000);
@@ -480,6 +491,12 @@ export function EditorView() {
/>
)}
{escArmed && (
<div className="esc-hint">
Press <kbd>Esc</kbd> again for All books
</div>
)}
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && (
<div className="export-overlay">
+2 -7
View File
@@ -7,6 +7,7 @@ import { useExportPreview } from "../store/useExportPreview";
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc";
import { saveBytes } from "../project";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
@@ -81,13 +82,7 @@ function ExportPreviewModal() {
};
}, [book]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [close]);
useEscapeLayer(true, close);
useEffect(() => {
const el = panelRef.current;
+3 -6
View File
@@ -3,6 +3,7 @@ import type { Editor as TiptapEditor } from "@tiptap/react";
import { buildRegex, getSearchState } from "../editor/search";
import { countMatches, replaceInContent } from "../search-book";
import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface FindBarProps {
@@ -95,6 +96,8 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
};
}, [editor, pendingNav, activeChapterId]);
useEscapeLayer(open, onClose);
if (!open || !editor) return null;
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
@@ -163,9 +166,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
if (e.key === "Enter") {
e.preventDefault();
e.shiftKey ? prev() : next();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
@@ -256,9 +256,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
if (e.key === "Enter") {
e.preventDefault();
replaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
+6 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface RowMenuProps {
@@ -38,12 +39,13 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
}, [open]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
});
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement);
+2 -10
View File
@@ -13,6 +13,7 @@ import {
fontStack,
pairingFor,
} from "../model/fonts";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
const LANGUAGES = [
@@ -94,16 +95,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
});
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEscapeLayer(true, onClose);
if (!book) return null;
+4 -1
View File
@@ -1,5 +1,6 @@
import { useUpdater } from "../store/useUpdater";
import { installUpdate, dismissUpdate } from "../updater";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
const TITLES: Record<string, string> = {
@@ -19,9 +20,11 @@ export function UpdateDialog() {
const total = useUpdater((s) => s.total);
const error = useUpdater((s) => s.error);
const busy = phase === "downloading" || phase === "installing";
useEscapeLayer(phase !== "idle" && !busy, dismissUpdate);
if (phase === "idle") return null;
const busy = phase === "downloading" || phase === "installing";
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : 0;
const close = busy ? undefined : dismissUpdate;
+7 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon";
import { useEscapeLayer } from "../escape";
import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = {
@@ -67,6 +68,12 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, linkOpen]);
useEscapeLayer(linkOpen, () => {
setLinkOpen(false);
editor?.commands.focus();
});
useEscapeLayer(alignOpen, () => setAlignOpen(false));
if (!editor) return null;
const insertImage = async (file: File) => {
@@ -158,9 +165,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
} else if (e.key === "Escape") {
e.preventDefault();
setLinkOpen(false);
}
}}
/>
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
type Handler = () => void;
const layers: Handler[] = [];
let bound = false;
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
const top = layers[layers.length - 1];
if (!top) return;
e.preventDefault();
e.stopPropagation();
top();
}
function pushLayer(handler: Handler): () => void {
if (!bound) {
window.addEventListener("keydown", onKeyDown, true);
bound = true;
}
layers.push(handler);
return () => {
const i = layers.lastIndexOf(handler);
if (i !== -1) layers.splice(i, 1);
};
}
export function useEscapeLayer(active: boolean, onEscape: () => void): void {
const latest = useRef(onEscape);
latest.current = onEscape;
useEffect(() => {
if (!active) return;
return pushLayer(() => latest.current());
}, [active]);
}
+26
View File
@@ -2096,6 +2096,32 @@ body.resizing .sidebar {
}
}
.esc-hint {
position: fixed;
top: 54px;
left: 50%;
z-index: 30;
padding: 7px 14px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
background: var(--glass);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--shadow-pop);
pointer-events: none;
transform: translateX(-50%);
animation: find-drop 140ms var(--ease);
}
.esc-hint kbd {
font-family: var(--font-ui);
font-weight: 600;
color: var(--ink);
}
.find-expand {
flex: none;
width: 20px;