diff --git a/src/components/AddPageMenu.tsx b/src/components/AddPageMenu.tsx index f66555c..0b6f8da 100644 --- a/src/components/AddPageMenu.tsx +++ b/src/components/AddPageMenu.tsx @@ -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(".add-page-item")?.focus(); }, [open]); + useEscapeLayer(open, () => { + setOpen(false); + btnRef.current?.focus(); + }); + const onMenuKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - setOpen(false); - btnRef.current?.focus(); - } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const items = Array.from(popRef.current?.querySelectorAll(".add-page-item") ?? []); const idx = items.indexOf(document.activeElement as HTMLElement); diff --git a/src/components/BackupSettings.tsx b/src/components/BackupSettings.tsx index 0d48a2c..321cec3 100644 --- a/src/components/BackupSettings.tsx +++ b/src/components/BackupSettings.tsx @@ -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 ( diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index dff4f25..5c5fff1 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -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 (
e.stopPropagation()}> diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index 7c1e05c..fa9c33a 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -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 | 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 && ( +
+ Press Esc again for All books +
+ )} + {settingsOpen && setSettingsOpen(false)} onSave={saveNow} />} {exporting && (
diff --git a/src/components/ExportPreview.tsx b/src/components/ExportPreview.tsx index 00fc221..112f427 100644 --- a/src/components/ExportPreview.tsx +++ b/src/components/ExportPreview.tsx @@ -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; diff --git a/src/components/FindBar.tsx b/src/components/FindBar.tsx index e6ed10d..a36b192 100644 --- a/src/components/FindBar.tsx +++ b/src/components/FindBar.tsx @@ -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(); } }} /> diff --git a/src/components/RowMenu.tsx b/src/components/RowMenu.tsx index f93c56e..4287f95 100644 --- a/src/components/RowMenu.tsx +++ b/src/components/RowMenu.tsx @@ -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(".row-menu-item")?.focus(); }, [open]); + useEscapeLayer(open, () => { + setOpen(false); + btnRef.current?.focus(); + }); + const onMenuKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - setOpen(false); - btnRef.current?.focus(); - } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const items = Array.from(popRef.current?.querySelectorAll(".row-menu-item") ?? []); const idx = items.indexOf(document.activeElement as HTMLElement); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 9541f2c..e09ab63 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -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; diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index ea34996..69c84b2 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -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 = { @@ -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; diff --git a/src/editor/FloatingToolbar.tsx b/src/editor/FloatingToolbar.tsx index fd698c9..9b3517a 100644 --- a/src/editor/FloatingToolbar.tsx +++ b/src/editor/FloatingToolbar.tsx @@ -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 = { @@ -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); } }} /> diff --git a/src/escape.ts b/src/escape.ts new file mode 100644 index 0000000..6f32893 --- /dev/null +++ b/src/escape.ts @@ -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]); +} diff --git a/src/styles/app.css b/src/styles/app.css index 030071e..2c71460 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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;