fix keyboard fixed

This commit is contained in:
pj committed 2026-07-14 16:07:05 +05:30
1 parent 20365dd02d
commit a31f99cb76
12 files changed
+290 -52

No files matched your search

+1 -1
View File
@@ -4713,7 +4713,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.9" version = "0.1.11"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"fontdb", "fontdb",
+22
View File
@@ -16,6 +16,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 }); const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null); const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const toggle = () => { const toggle = () => {
if (!open && btnRef.current) { if (!open && btnRef.current) {
@@ -38,6 +39,25 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
}; };
}, [open]); }, [open]);
useEffect(() => {
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
}, [open]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else 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);
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[nextIdx]?.focus();
}
};
const pick = (group: "front" | "back", label: string) => { const pick = (group: "front" | "back", label: string) => {
setOpen(false); setOpen(false);
onAdd(group, label); onAdd(group, label);
@@ -56,9 +76,11 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
</button> </button>
{open && ( {open && (
<div <div
ref={popRef}
className="add-page-pop" className="add-page-pop"
style={{ top: coords.top, left: coords.left }} style={{ top: coords.top, left: coords.left }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onMenuKeyDown}
> >
<div className="add-page-group"> <div className="add-page-group">
<div className="add-page-label">Body</div> <div className="add-page-label">Body</div>
+17 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface ConfirmDialogProps { interface ConfirmDialogProps {
@@ -10,6 +10,21 @@ interface ConfirmDialogProps {
} }
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) { export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null);
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
}, []);
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()}>
@@ -26,7 +41,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
<button className="btn-ghost" onClick={onClose}> <button className="btn-ghost" onClick={onClose}>
Cancel Cancel
</button> </button>
<button className="btn-danger" onClick={onConfirm}> <button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
+55 -13
View File
@@ -44,6 +44,9 @@ export function EditorView() {
const isCompact = useCompact(); const isCompact = useCompact();
const [editor, setEditor] = useState<TiptapEditor | null>(null); const [editor, setEditor] = useState<TiptapEditor | null>(null);
const editorRef = useRef<TiptapEditor | null>(null);
editorRef.current = editor;
const lastChapterRef = useRef<string>("");
const [sidebarOpen, setSidebarOpen] = useState(!isCompact); const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(!isCompact); const [dock, setDock] = useState(!isCompact);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
@@ -83,6 +86,18 @@ export function EditorView() {
setFindOpen(true); setFindOpen(true);
}, []); }, []);
const focusEditorSoon = useCallback(() => {
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
}, []);
const switchChapter = useCallback(
(dir: 1 | -1) => {
useBook.getState().goToAdjacentChapter(dir);
focusEditorSoon();
},
[focusEditorSoon]
);
const reproof = useCallback(() => { const reproof = useCallback(() => {
if (!editor) return; if (!editor) return;
const state = useProofing.getState(); const state = useProofing.getState();
@@ -120,11 +135,36 @@ export function EditorView() {
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
e.preventDefault(); e.preventDefault();
openFind(e.altKey); openFind(e.altKey);
} else if (e.metaKey && e.altKey && (e.key === "ArrowDown" || e.key === "ArrowRight")) {
e.preventDefault();
e.stopPropagation();
switchChapter(1);
} else if (e.metaKey && e.altKey && (e.key === "ArrowUp" || e.key === "ArrowLeft")) {
e.preventDefault();
e.stopPropagation();
switchChapter(-1);
} else if (e.ctrlKey && !e.metaKey && e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
switchChapter(e.shiftKey ? -1 : 1);
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [saveNow, openFind, switchChapter]);
useEffect(() => {
if (!widthOpen && !exportOpen && !moreOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setWidthOpen(false);
setExportOpen(false);
setMoreOpen(false);
} }
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [saveNow, openFind]); }, [widthOpen, exportOpen, moreOpen]);
useEffect(() => { useEffect(() => {
if (!notice) return; if (!notice) return;
@@ -177,8 +217,11 @@ export function EditorView() {
}; };
if (!book) return null; if (!book) return null;
const idx = book.chapters.findIndex((c) => c.id === activeChapterId); const activeIdx = book.chapters.findIndex((c) => c.id === activeChapterId);
const chapter = book.chapters[idx] ?? book.chapters[0]; const activeChapter = book.chapters[activeIdx];
if (activeChapter) lastChapterRef.current = activeChapter.id;
const editorIdx = activeChapter ? activeIdx : book.chapters.findIndex((c) => c.id === lastChapterRef.current);
const chapter = book.chapters[editorIdx] ?? book.chapters[0];
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id); const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
const kind = chapter ? chapterKind(chapter) : "body"; const kind = chapter ? chapterKind(chapter) : "body";
const eyebrow = const eyebrow =
@@ -342,14 +385,16 @@ export function EditorView() {
/> />
<div className="body"> <div className="body">
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} /> <Sidebar
onNavigate={() => {
if (isCompact) setSidebarOpen(false);
focusEditorSoon();
}}
/>
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />} {sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
<main className="editor-pane"> <main className="editor-pane">
{coverActive ? ( {coverActive && <CoverView />}
<CoverView /> <article className="sheet" hidden={coverActive}>
) : (
<>
<article className="sheet">
<header className="chapter-opener"> <header className="chapter-opener">
<div className="chapter-num">{eyebrow}</div> <div className="chapter-num">{eyebrow}</div>
<ChapterTitleInput <ChapterTitleInput
@@ -372,7 +417,6 @@ export function EditorView() {
)} )}
</header> </header>
<Editor <Editor
key={chapter.id}
bookId={book.id} bookId={book.id}
chapterId={chapter.id} chapterId={chapter.id}
content={chapter.content} content={chapter.content}
@@ -383,9 +427,7 @@ export function EditorView() {
} }
/> />
</article> </article>
<FloatingToolbar editor={editor} /> {!coverActive && <FloatingToolbar editor={editor} />}
</>
)}
</main> </main>
{dock && !isCompact && <ResizeHandle pane="dock" />} {dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />} {dock && <Dock />}
+2 -2
View File
@@ -58,7 +58,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
return () => { return () => {
editor.off("transaction", update); editor.off("transaction", update);
}; };
}, [editor]); }, [editor, activeChapterId]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
@@ -67,7 +67,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
} else { } else {
editor.commands.clearSearch(); editor.commands.clearSearch();
} }
}, [editor, open, query, caseSensitive, wholeWord]); }, [editor, open, query, caseSensitive, wholeWord, activeChapterId]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
+14 -1
View File
@@ -51,6 +51,11 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
onOpen(copy); onOpen(copy);
}; };
const openById = (id: string) =>
loadBook(id)
.then(onOpen)
.catch((e) => setNotice(`Could not open book: ${e}`));
const removeBook = async () => { const removeBook = async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
await deleteBook(pendingDelete.id); await deleteBook(pendingDelete.id);
@@ -108,7 +113,15 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<div <div
key={b.id} key={b.id}
className="card card-book" className="card card-book"
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))} role="button"
tabIndex={0}
onClick={() => openById(b.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openById(b.id);
}
}}
> >
<span className="card-title">{b.title || "Untitled"}</span> <span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>} {b.author && <span className="card-author">{b.author}</span>}
+20 -1
View File
@@ -33,6 +33,25 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
setOpen((v) => !v); setOpen((v) => !v);
}; };
useEffect(() => {
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
}, [open]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else 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);
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[nextIdx]?.focus();
}
};
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
@@ -88,7 +107,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
</button> </button>
{open && {open &&
createPortal( createPortal(
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}> <div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }} onKeyDown={onMenuKeyDown}>
{onToggleTitle && ( {onToggleTitle && (
<button className="row-menu-item" onClick={toggleTitle}> <button className="row-menu-item" onClick={toggleTitle}>
{titleHidden ? ( {titleHidden ? (
+12 -1
View File
@@ -94,6 +94,17 @@ 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]);
if (!book) return null; if (!book) return null;
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch })); const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
@@ -121,7 +132,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
</div> </div>
<div className="panel-body"> <div className="panel-body">
<Field label="Title"> <Field label="Title">
<input value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} /> <input autoFocus value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
</Field> </Field>
<Field label="Subtitle"> <Field label="Subtitle">
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} /> <input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} />
+32
View File
@@ -155,6 +155,33 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
onNavigate?.(); onNavigate?.();
}; };
const focusRow = (id: string) =>
requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`.chapter[data-id="${CSS.escape(id)}"]`)?.focus();
});
const onRowKeyDown = (e: React.KeyboardEvent, index: number, id: string) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveChapter(id);
onNavigate?.();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Home" || e.key === "End") {
e.preventDefault();
const target =
e.key === "Home"
? chapters[0]
: e.key === "End"
? chapters[chapters.length - 1]
: chapters[index + (e.key === "ArrowDown" ? 1 : -1)];
if (target) {
setActiveChapter(target.id);
focusRow(target.id);
}
}
};
const tabStopId = chapters.some((c) => c.id === activeChapterId) ? activeChapterId : chapters[0]?.id;
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<button className="brand" onClick={closeBook} title="All books"> <button className="brand" onClick={closeBook} title="All books">
@@ -193,6 +220,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
key={row.chapter.id} key={row.chapter.id}
className="chapter" className="chapter"
data-idx={row.index} data-idx={row.index}
data-id={row.chapter.id}
data-kind={group.kind} data-kind={group.kind}
data-part={isPart} data-part={isPart}
data-indent={row.indent} data-indent={row.indent}
@@ -200,7 +228,11 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
data-dragging={dragIndex === row.index} data-dragging={dragIndex === row.index}
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index} data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1} data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1}
role="button"
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
aria-current={row.chapter.id === activeChapterId}
onClick={() => onRowClick(row.chapter.id)} onClick={() => onRowClick(row.chapter.id)}
onKeyDown={(e) => onRowKeyDown(e, row.index, row.chapter.id)}
onPointerDown={(e) => onRowPointerDown(e, row.index)} onPointerDown={(e) => onRowPointerDown(e, row.index)}
> >
<span className="grip" title="Drag to reorder"> <span className="grip" title="Drag to reorder">
+89 -31
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import { EditorState } from "@tiptap/pm/state";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions"; import { editorExtensions } from "./extensions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions"; import { loadPosition, savePosition, type ChapterPosition } from "./positions";
import { useBook } from "../store/useBook";
interface EditorProps { interface EditorProps {
bookId: string; bookId: string;
@@ -13,13 +15,34 @@ interface EditorProps {
onContentError: (error: Error) => void; onContentError: (error: Error) => void;
} }
interface Cached {
state: EditorState;
content: JSONContent;
scroll: number;
}
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
const base = editor.view.state;
try {
const doc = editor.schema.nodeFromJSON(content);
doc.check();
return EditorState.create({ doc, plugins: base.plugins });
} catch (error) {
onError(error as Error);
return EditorState.create({ schema: base.schema, plugins: base.plugins });
}
}
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) { export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError); const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError; onContentErrorRef.current = onContentError;
const cache = useRef(new Map<string, Cached>());
const activeId = useRef(chapterId);
const latest = useRef<ChapterPosition | null>(null); const latest = useRef<ChapterPosition | null>(null);
const restoreToken = useRef(0);
const editor = useEditor({ const editor = useEditor({
extensions: editorExtensions, extensions: editorExtensions,
@@ -31,49 +54,84 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
}); });
useEffect(() => { const scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
onReady(editor);
return () => onReady(null); const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => {
}, [editor, onReady]); const scroller = scrollerOf(ed);
const saved = loadPosition(bookId, id);
const size = ed.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
const chain = ed.chain().setTextSelection(selection);
if (focus) chain.focus(undefined, { scrollIntoView: false });
chain.run();
if (scroller) {
const token = ++restoreToken.current;
const top = saved?.scroll ?? 0;
const apply = () => {
if (restoreToken.current === token) scroller.scrollTop = top;
};
apply();
requestAnimationFrame(apply);
document.fonts?.ready.then(apply).catch(() => {});
}
};
const stash = (ed: TiptapEditor, id: string) => {
const scroller = scrollerOf(ed);
const { from, to } = ed.state.selection;
const scroll = scroller?.scrollTop ?? 0;
const stored = useBook.getState().book?.chapters.find((c) => c.id === id)?.content ?? cache.current.get(id)?.content ?? ed.getJSON();
cache.current.set(id, { state: ed.view.state, content: stored, scroll });
savePosition(bookId, id, { from, to, scroll });
};
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
let active = true; cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; activeId.current = chapterId;
restorePosition(editor, chapterId, true);
const saved = loadPosition(bookId, chapterId); onReady(editor);
const size = editor.state.doc.content.size; return () => onReady(null);
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
if (scroller) {
const top = saved?.scroll ?? 0;
const restore = () => {
if (active) scroller.scrollTop = top;
};
restore();
requestAnimationFrame(restore);
document.fonts?.ready.then(restore).catch(() => {});
}
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]); }, [editor]);
useLayoutEffect(() => {
if (!editor) return;
const prev = activeId.current;
if (prev === chapterId) return;
stash(editor, prev);
const entry = cache.current.get(chapterId);
if (entry && entry.content === content) {
editor.view.updateState(entry.state);
const scroller = scrollerOf(editor);
if (scroller) scroller.scrollTop = entry.scroll;
++restoreToken.current;
} else {
editor.view.updateState(buildState(editor, content, onContentErrorRef.current));
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
restorePosition(editor, chapterId, false);
}
activeId.current = chapterId;
latest.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId, content, editor]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; const scroller = scrollerOf(editor);
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
const capture = () => { const capture = () => {
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 }; const scroll = scroller?.scrollTop ?? 0;
latest.current = { from, to, scroll };
const entry = cache.current.get(activeId.current);
if (entry) entry.scroll = scroll;
}; };
const persist = () => { const persist = () => {
capture(); capture();
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400); timer = setTimeout(() => savePosition(bookId, activeId.current, latest.current!), 400);
}; };
editor.on("selectionUpdate", persist); editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true }); scroller?.addEventListener("scroll", persist, { passive: true });
@@ -81,9 +139,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
clearTimeout(timer); clearTimeout(timer);
editor.off("selectionUpdate", persist); editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist); scroller?.removeEventListener("scroll", persist);
if (latest.current) savePosition(bookId, chapterId, latest.current); if (latest.current) savePosition(bookId, activeId.current, latest.current);
}; };
}, [editor, bookId, chapterId]); }, [editor, bookId]);
return <EditorContent editor={editor} className="editor-host" />; return <EditorContent editor={editor} className="editor-host" />;
} }
+10
View File
@@ -30,6 +30,7 @@ interface BookState {
openBook: (book: Book) => void; openBook: (book: Book) => void;
closeBook: () => void; closeBook: () => void;
setActiveChapter: (id: string) => void; setActiveChapter: (id: string) => void;
goToAdjacentChapter: (dir: 1 | -1) => void;
setChapterContent: (id: string, content: JSONContent) => void; setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void; setChapterTitle: (id: string, title: string) => void;
setChapterNoTitle: (id: string, noTitle: boolean) => void; setChapterNoTitle: (id: string, noTitle: boolean) => void;
@@ -73,6 +74,15 @@ export const useBook = create<BookState>((set, get) => ({
set({ book: null, activeChapterId: "", dirty: false }); set({ book: null, activeChapterId: "", dirty: false });
}, },
setActiveChapter: (id) => set({ activeChapterId: id }), setActiveChapter: (id) => set({ activeChapterId: id }),
goToAdjacentChapter: (dir) => {
const { book, activeChapterId } = get();
if (!book) return;
const ids = [COVER_ID, ...book.chapters.map((c) => c.id)];
const i = ids.indexOf(activeChapterId);
if (i === -1) return;
const next = ids[(i + dir + ids.length) % ids.length];
if (next !== activeChapterId) set({ activeChapterId: next });
},
setChapterContent: (id, content) => setChapterContent: (id, content) =>
set((s) => set((s) =>
s.book s.book
+16
View File
@@ -40,6 +40,22 @@ button {
padding: 0; padding: 0;
} }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chapter:focus-visible,
.card:focus-visible {
outline-offset: -2px;
}
.prose:focus,
.prose:focus-visible,
.editor-host:focus-visible {
outline: none;
}
.app { .app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;