diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg index 839afda..a55b716 100644 --- a/apps/web/public/logo.svg +++ b/apps/web/public/logo.svg @@ -1,9 +1,5 @@ - + - - - - @@ -17,12 +13,10 @@ - + - - diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b21137d..08f7688 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -31,6 +31,7 @@ export default function App() { const [exportOpen, setExportOpen] = useState(false); const navCollapsed = useSettings((s) => s.navCollapsed); const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); + const focusMode = useSettings((s) => s.focusMode); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -44,7 +45,7 @@ export default function App() { }, []); return ( -
+
setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 41b1dea..8faed87 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -38,17 +38,36 @@ function totalWords(chapters: ChapterDoc[]): number { return total; } +function formatRevTime(iso: string): string { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + } catch { + return ""; + } +} + export function Sidebar({ nav }: SidebarProps) { const navigate = useNavigate(); const projects = useProjectStore((s) => s.projects); const project = useProjectStore((s) => s.project); const openProject = useProjectStore((s) => s.openProject); const chapters = useProjectStore((s) => s.chapters); + const activeChapterId = useProjectStore((s) => s.activeChapterId); + const revisionsByChapter = useProjectStore((s) => s.revisionsByChapter); + const restoreRevision = useProjectStore((s) => s.restoreRevision); const navCollapsed = useSettings((s) => s.navCollapsed); + const autoSave = useSettings((s) => s.autoSave); const setSettings = useSettings((s) => s.set); const title = project?.title ?? "Untitled project"; const wordCount = totalWords(chapters); + const activeChapter = + chapters.find((c) => c.id === activeChapterId) ?? chapters[0] ?? null; + const activeRevisions = activeChapter + ? (revisionsByChapter[activeChapter.id] ?? []) + : []; const handleSelectProject = async (id: string) => { if (!id || id === project?.id) return; @@ -59,6 +78,14 @@ export function Sidebar({ nav }: SidebarProps) { } }; + const handleRestoreRev = async (revId: string, revNum: number) => { + if (!activeChapter) return; + const ok = await restoreRevision(activeChapter.id, revId); + if (ok) { + toast.success(`Restored autosave rev #${revNum} for "${activeChapter.title}"`); + } + }; + return ( ); } diff --git a/apps/web/src/components/WritingView.tsx b/apps/web/src/components/WritingView.tsx index ae961ad..5f13ff3 100644 --- a/apps/web/src/components/WritingView.tsx +++ b/apps/web/src/components/WritingView.tsx @@ -80,6 +80,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) { const autosave = useProjectStore((s) => s.autosave); const entities = useProjectStore((s) => s.entities); const highlightedLine = useProjectStore((s) => s.highlightedLine); + const highlightedText = useProjectStore((s) => s.highlightedText); const highlightSeq = useProjectStore((s) => s.highlightSeq); const docExternalRev = useProjectStore((s) => s.docExternalRev); @@ -95,6 +96,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) { const [showContextRail, setShowContextRail] = useState(true); const [renamingChapter, setRenamingChapter] = useState(false); const [chapterTitleDraft, setChapterTitleDraft] = useState(""); + const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState(null); const [, setToolbarTick] = useState(0); const editorDomRef = useRef(null); @@ -142,82 +144,114 @@ export function WritingView({ onChapterChange }: WritingViewProps) { } }, [editor, chapter?.id, docExternalRev]); - // Scroll the highlighted 1-based line block into view when an AI suggestion targets it. - // Visual highlighting uses CSS `:nth-child(N)` via `data-highlight-line` on `.ps-editor` - // so ProseMirror's internal DOMObserver never strips the highlight class. + // Highlight the target text span (via CSS Custom Highlight API) and resolve the + // exact 1-based non-empty DOM child block in .ProseMirror so blank lines never + // shift or misalign suggestion highlights. useEffect(() => { - const editorDom = editorDomRef.current; - if (!editorDom || highlightedLine == null || highlightedLine < 1) return undefined; - const pmRoot = editorDom.querySelector(".ProseMirror"); - if (!pmRoot) return undefined; - - const children = Array.from(pmRoot.children) as HTMLElement[]; - const targetEl = children[highlightedLine - 1]; - if (targetEl && typeof targetEl.scrollIntoView === "function") { - targetEl.scrollIntoView({ behavior: "smooth", block: "center" }); - } - return undefined; - }, [editor, highlightedLine, highlightSeq, docExternalRev, chapter?.id]); - - // Focus mode: dim every top-level block in .ProseMirror except the one - // containing the active cursor selection. - useEffect(() => { - if (!editor) return; - const editorDom = editorDomRef.current; - if (!editorDom) return; - - const clearFocusClasses = () => { - const pmRoot = editorDom.querySelector(".ProseMirror"); - if (!pmRoot) return; - Array.from(pmRoot.children).forEach((child) => { - child.classList.remove("ps-focus-active"); - }); - }; - - if (!focusMode) { - clearFocusClasses(); - return undefined; - } - - const applyFocus = () => { - const pmRoot = editorDom.querySelector(".ProseMirror"); - if (!pmRoot) return; - let activeEl: HTMLElement | null = null; + const clearCustomHighlight = () => { try { - const pos = editor.state.selection.$from.pos; - const domInfo = editor.view.domAtPos(pos); - let node: Node | null = domInfo.node; - if (node && node.nodeType === Node.TEXT_NODE) { - node = node.parentElement; - } - while (node && node instanceof HTMLElement && node !== pmRoot) { - if (node.parentElement === pmRoot) { - activeEl = node; - break; - } - node = node.parentElement; - } + const cssAny = globalThis.CSS as unknown as { + highlights?: { delete: (name: string) => void }; + }; + cssAny?.highlights?.delete("ps-ai-target"); } catch { /* ignore */ } - const children = Array.from(pmRoot.children) as HTMLElement[]; - if (!activeEl && children.length > 0) { - activeEl = children[0]!; - } - children.forEach((child) => { - child.classList.toggle("ps-focus-active", child === activeEl); - }); }; - editor.on("selectionUpdate", applyFocus); - editor.on("update", applyFocus); - applyFocus(); - return () => { - editor.off("selectionUpdate", applyFocus); - editor.off("update", applyFocus); - clearFocusClasses(); - }; - }, [editor, focusMode]); + clearCustomHighlight(); + const editorDom = editorDomRef.current; + if (!editorDom) { + setResolvedDomChildIdx(null); + return undefined; + } + const pmRoot = editorDom.querySelector(".ProseMirror"); + if (!pmRoot) { + setResolvedDomChildIdx(null); + return undefined; + } + + const children = Array.from(pmRoot.children) as HTMLElement[]; + const normQuotes = (s: string) => + s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'"); + + const needle = (highlightedText ?? "").trim().replace(/^["“]|["”]$/g, "").trim(); + + if (needle) { + const normNeedle = normQuotes(needle).toLowerCase(); + for (let i = 0; i < children.length; i++) { + const childEl = children[i]!; + const textNodes: { node: Text; start: number; end: number }[] = []; + let full = ""; + const walker = document.createTreeWalker(childEl, NodeFilter.SHOW_TEXT); + let cur = walker.nextNode(); + while (cur) { + const t = cur as Text; + const len = t.nodeValue?.length ?? 0; + if (len > 0) { + textNodes.push({ node: t, start: full.length, end: full.length + len }); + full += t.nodeValue; + } + cur = walker.nextNode(); + } + if (!full.trim()) continue; + + const matchIdx = normQuotes(full).toLowerCase().indexOf(normNeedle); + if (matchIdx !== -1) { + const matchEnd = matchIdx + normNeedle.length; + setResolvedDomChildIdx(i + 1); + if (typeof childEl.scrollIntoView === "function") { + childEl.scrollIntoView({ behavior: "smooth", block: "center" }); + } + + // Highlight the exact inline phrase/clause/sentence via CSS.highlights + try { + const startEntry = textNodes.find((n) => matchIdx >= n.start && matchIdx < n.end); + const endEntry = textNodes.find((n) => matchEnd > n.start && matchEnd <= n.end); + if (startEntry && endEntry && typeof Range !== "undefined") { + const range = document.createRange(); + range.setStart(startEntry.node, matchIdx - startEntry.start); + range.setEnd(endEntry.node, matchEnd - endEntry.start); + const HighlightCtor = (globalThis as unknown as { + Highlight?: new (...ranges: Range[]) => unknown; + }).Highlight; + const cssAny = globalThis.CSS as unknown as { + highlights?: { set: (name: string, h: unknown) => void }; + }; + if (HighlightCtor && cssAny?.highlights) { + cssAny.highlights.set("ps-ai-target", new HighlightCtor(range)); + } + } + } catch { + /* ignore if CSS Custom Highlight API is unavailable */ + } + return clearCustomHighlight; + } + } + } + + if (highlightedLine != null && highlightedLine >= 1) { + // Map highlightedLine to the Nth non-empty block so blank lines are skipped + const nonEmptyDomIndices: number[] = []; + children.forEach((el, idx) => { + if ((el.textContent ?? "").trim().length > 0) { + nonEmptyDomIndices.push(idx + 1); + } + }); + const domIdx = + nonEmptyDomIndices[highlightedLine - 1] ?? + (highlightedLine <= children.length ? highlightedLine : null); + setResolvedDomChildIdx(domIdx); + const targetEl = domIdx ? children[domIdx - 1] : null; + if (targetEl && typeof targetEl.scrollIntoView === "function") { + targetEl.scrollIntoView({ behavior: "smooth", block: "center" }); + } + return clearCustomHighlight; + } + + setResolvedDomChildIdx(null); + return clearCustomHighlight; + }, [editor, highlightedText, highlightedLine, highlightSeq, docExternalRev, chapter?.id]); // Keyboard shortcut: F2 toggles focus mode. useEffect(() => { @@ -854,20 +888,20 @@ export function WritingView({ onChapterChange }: WritingViewProps) { {/* Main Editor Body + Context Rail */}
- {highlightedLine != null && highlightedLine >= 1 && ( + {resolvedDomChildIdx != null && resolvedDomChildIdx >= 1 && (