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
+130 -59

No files matched your search

+7 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PAGE_TYPES } from "../model/book"; import { PAGE_TYPES } from "../model/book";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface AddPageMenuProps { interface AddPageMenuProps {
@@ -44,12 +45,13 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus(); popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
}, [open]); }, [open]);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => { const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []); const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement); 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 { Icon } from "./Icon";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { useBackup } from "../store/useBackup"; import { useBackup } from "../store/useBackup";
import { useEscapeLayer } from "../escape";
import { formatBackupTime, gdriveListBackups } from "../backup"; import { formatBackupTime, gdriveListBackups } from "../backup";
export function BackupSettings() { export function BackupSettings() {
@@ -38,6 +39,8 @@ export function BackupSettings() {
}; };
}, [open, connected, lastBackup]); }, [open, connected, lastBackup]);
useEscapeLayer(open, close);
if (!open) return null; if (!open) return null;
return ( return (
+3 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface ConfirmDialogProps { interface ConfirmDialogProps {
@@ -14,17 +15,10 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
useEffect(() => { useEffect(() => {
confirmRef.current?.focus(); 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 ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}> <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 { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book"; import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library"; import { saveBook } from "../library";
import { useEscapeLayer } from "../escape";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { useCompact } from "../useMedia"; import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing"; import { issueSignature, rememberWord, runProof } from "../proofing";
@@ -59,6 +60,10 @@ export function EditorView() {
const [findOpen, setFindOpen] = useState(false); const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false); const [findExpanded, setFindExpanded] = useState(false);
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null); 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 spelling = useProofing((s) => s.spelling);
const grammar = useProofing((s) => s.grammar); const grammar = useProofing((s) => s.grammar);
@@ -84,6 +89,25 @@ export function EditorView() {
setDock(false); 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) => { const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded); setFindExpanded(expanded);
setFindOpen(true); 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(() => { useEffect(() => {
if (!notice) return; if (!notice) return;
const timer = setTimeout(() => setNotice(null), 4000); 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} />} {settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && ( {exporting && (
<div className="export-overlay"> <div className="export-overlay">
+2 -7
View File
@@ -7,6 +7,7 @@ import { useExportPreview } from "../store/useExportPreview";
import { bookToPdfInputs, unsupportedScripts } from "../export/typst"; import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc"; import { compilePdf } from "../ipc";
import { saveBytes } from "../project"; import { saveBytes } from "../project";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
@@ -81,13 +82,7 @@ function ExportPreviewModal() {
}; };
}, [book]); }, [book]);
useEffect(() => { useEscapeLayer(true, close);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [close]);
useEffect(() => { useEffect(() => {
const el = panelRef.current; 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 { buildRegex, getSearchState } from "../editor/search";
import { countMatches, replaceInContent } from "../search-book"; import { countMatches, replaceInContent } from "../search-book";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface FindBarProps { interface FindBarProps {
@@ -95,6 +96,8 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
}; };
}, [editor, pendingNav, activeChapterId]); }, [editor, pendingNav, activeChapterId]);
useEscapeLayer(open, onClose);
if (!open || !editor) return null; if (!open || !editor) return null;
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0); 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") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
e.shiftKey ? prev() : next(); 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") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
replaceOne(); replaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
} }
}} }}
/> />
+7 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface RowMenuProps { interface RowMenuProps {
@@ -38,12 +39,13 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus(); popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
}, [open]); }, [open]);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => { const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []); const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement); const idx = items.indexOf(document.activeElement as HTMLElement);
+2 -10
View File
@@ -13,6 +13,7 @@ import {
fontStack, fontStack,
pairingFor, pairingFor,
} from "../model/fonts"; } from "../model/fonts";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const LANGUAGES = [ const LANGUAGES = [
@@ -94,16 +95,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
}); });
}, []); }, []);
useEffect(() => { useEscapeLayer(true, onClose);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
if (!book) return null; if (!book) return null;
+4 -1
View File
@@ -1,5 +1,6 @@
import { useUpdater } from "../store/useUpdater"; import { useUpdater } from "../store/useUpdater";
import { installUpdate, dismissUpdate } from "../updater"; import { installUpdate, dismissUpdate } from "../updater";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
@@ -19,9 +20,11 @@ export function UpdateDialog() {
const total = useUpdater((s) => s.total); const total = useUpdater((s) => s.total);
const error = useUpdater((s) => s.error); const error = useUpdater((s) => s.error);
const busy = phase === "downloading" || phase === "installing";
useEscapeLayer(phase !== "idle" && !busy, dismissUpdate);
if (phase === "idle") return null; 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 pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : 0;
const close = busy ? undefined : dismissUpdate; const close = busy ? undefined : dismissUpdate;
+7 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react"; import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { useEscapeLayer } from "../escape";
import type { Alignment } from "./align"; import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = { 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, linkOpen]); }, [editor, linkOpen]);
useEscapeLayer(linkOpen, () => {
setLinkOpen(false);
editor?.commands.focus();
});
useEscapeLayer(alignOpen, () => setAlignOpen(false));
if (!editor) return null; if (!editor) return null;
const insertImage = async (file: File) => { const insertImage = async (file: File) => {
@@ -158,9 +165,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
applyLink(); 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 { .find-expand {
flex: none; flex: none;
width: 20px; width: 20px;