diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000..0ba48e5 --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,49 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [ + "releases/linux-unpacked/LICENSES.chromium.html" + ], + "ignoreValues": [ + { + "rule": "overused-font", + "value": "inter", + "createdAt": "2026-08-29T16:09:55.801Z", + "reason": "Folio writing app intentionally uses Inter for UI shell and bundles common writing fonts for user choice" + }, + { + "rule": "overused-font", + "value": "roboto", + "createdAt": "2026-08-29T16:10:01.545Z", + "reason": "Bundled writing font choice" + }, + { + "rule": "overused-font", + "value": "open sans", + "createdAt": "2026-08-29T16:10:01.593Z", + "reason": "Bundled writing font choice" + }, + { + "rule": "overused-font", + "value": "lato", + "createdAt": "2026-08-29T16:10:01.642Z", + "reason": "Bundled writing font choice" + }, + { + "rule": "overused-font", + "value": "montserrat", + "createdAt": "2026-08-29T16:10:01.696Z", + "reason": "Bundled writing font choice" + }, + { + "rule": "clipped-overflow-container", + "value": "*", + "files": [ + "src/renderer/index.html" + ], + "createdAt": "2026-08-29T16:12:00.835Z", + "reason": "Hidden chapter views are display:none; main editor fixed to visible for dropdowns" + } + ] + } +} diff --git a/src/main/index.ts b/src/main/index.ts index f6e0330..862e566 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -144,9 +144,13 @@ function notifyChange() { // unhandled promise rejection that the UI would silently swallow. /* eslint-disable @typescript-eslint/no-explicit-any */ function handleIpc(channel: string, fn: (...args: any[]) => unknown): void { - ipcMain.handle(channel, async (_e, ...args: any[]) => { + ipcMain.handle(channel, async (event, ...args: any[]) => { try { - return await fn(...args); + // Handlers follow the raw ipcMain convention of (event, ...args); the + // unified wrapper must forward the event so their declared params line + // up with what preload actually sent. Dropping it here shifted every + // argument left by one ("The path argument must be of type string"). + return await fn(event, ...args); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[folio] ${channel} failed: ${msg}`); diff --git a/src/main/menu.ts b/src/main/menu.ts index 497cedc..3478928 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -36,8 +36,8 @@ export function buildMenu( const fileMenu: MenuItem = { label: "File", submenu: [ - { label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew }, - { label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, + { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew }, + { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, { type: "separator" }, { label: "Save", diff --git a/src/main/native-doc.ts b/src/main/native-doc.ts index f841655..2944367 100644 --- a/src/main/native-doc.ts +++ b/src/main/native-doc.ts @@ -1,16 +1,15 @@ -// Native Folio rich-text document model. +// Native Folio chapter persistence (main process only). // -// This is the single source of truth for chapter content once a book is -// migrated to the native format. The document is a ProseMirror/Tiptap JSON -// tree, not Markdown. See Stage 2 of the rich-text migration plan. +// The pure document model — types, format constants, serialization and +// migration logic — lives in src/shared/native-doc-core.ts so the renderer +// can bundle it without dragging Node builtins into the browser build. This +// module keeps only the disk IO and re-exports the core, so existing +// main-process imports are unaffected. // // Persistence layout (per book): // folio.json -> book metadata (chapterOrder, chapters, docFormat, ...) // chapters/.json -> { schemaVersion, doc } (native content) // assets/ -> copied image assets referenced by chapters -// -// Markdown remains only for legacy migration (this module does the parsing) -// and optional export (added later). import fs from "fs"; import path from "path"; @@ -19,135 +18,14 @@ import { saveMeta, chaptersDir, } from "./project.js"; +import { + nativeChapterFile, + serializeChapter, + parseChapter, + type FolioChapterDoc, +} from "../shared/native-doc-core.js"; -export interface FolioMark { - type: string; - attrs?: Record; -} - -export interface FolioNode { - type: string; - attrs?: Record; - content?: FolioNode[]; - text?: string; - marks?: FolioMark[]; -} - -export interface FolioChapterDoc { - schemaVersion: number; - doc: FolioNode; -} - -// Bump this whenever the native document shape needs a one-way migration. -export const CURRENT_DOC_SCHEMA_VERSION = 1; - -// ---- Cross-converter contracts ------------------------------------------- -// These string constants are part of the document format itself. They are -// consumed by every module that reads or writes native docs (markdown-to-native, -// native-to-markdown, renderer/print-format), so they live here — next to the -// schema they belong to — instead of being re-typed as literals in each -// converter. Changing any of them changes what is persisted on disk. -// -// Callout title paragraphs carry this class so serializers can tell the title -// apart from body paragraphs (set on parse, stripped on serialize/render). -export const CALLOUT_TITLE_CLASS = "callout-title"; - -// Internal chapter-link scheme: folio:chapter/. Serializers turn it back -// into [[wikilinks]]; the wikilink plugin turns [[text]] into it on parse. -export const CHAPTER_LINK_PREFIX = "folio:chapter/"; - -// ---- Chapter presentation helpers ---------------------------------------- -// Shared by every assembled/exported rendering of a chapter (Full Book view, -// PDF print, DOCX/ODT) so "does this chapter already carry its own title?" -// has exactly one answer everywhere. Before this lived in two files with -// different rules, DOCX and PDF could disagree about injecting the title. - -/** Concatenated plain text of a node subtree (atom leaves contribute nothing). */ -export function nativeNodeText(node: FolioNode): string { - if (node.text) return node.text; - let t = ""; - for (const c of node.content ?? []) t += nativeNodeText(c); - return t; -} - -/** Whether any top-level heading in the content already matches the title - * (case/space-insensitive). If true, renderers must not inject a duplicate - * title heading ahead of the content. */ -export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean { - const t = title.trim().toLowerCase(); - if (!t) return false; - for (const n of content) { - if (n.type === "heading" && nativeNodeText(n).trim().toLowerCase() === t) return true; - } - return false; -} - -/** Top-level nodes for one chapter, injecting a title heading when the - * content does not already provide one. */ -export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] { - if (nativeHasTitleHeading(content, title)) return content.slice(); - return [ - { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] }, - ...content, - ]; -} - -// Each entry migrates the document FROM its key version TO key+1. -// Entries are additive and must be non-destructive. -const MIGRATIONS: Record FolioChapterDoc> = {}; - -// File name for a native chapter (replaces .md). -export function nativeChapterFile(id: string): string { - return `${id}.json`; -} - -// Apply any pending schema migrations, always returning the current version. -export function migrateDoc(doc: FolioChapterDoc): FolioChapterDoc { - let current = doc; - while (current.schemaVersion < CURRENT_DOC_SCHEMA_VERSION) { - const step = MIGRATIONS[current.schemaVersion]; - if (!step) break; - current = step(current); - } - current.schemaVersion = CURRENT_DOC_SCHEMA_VERSION; - return current; -} - -// Normalize legacy node type names on read. The editor's StarterKit historically -// emitted Tiptap's default `hardBreak` (camelCase), while the converter/export -// pipeline uses `hard_break` (snake_case). Rename so every consumer sees one -// canonical name regardless of how the document was originally saved. -function normalizeDocTypes(node: unknown): unknown { - if (!node || typeof node !== "object") return node; - const n = node as Record; - if (typeof n.type === "string" && n.type === "hardBreak") n.type = "hard_break"; - if (Array.isArray(n.content)) n.content = n.content.map((c) => normalizeDocTypes(c)); - return n; -} - -export function serializeChapter(doc: FolioChapterDoc): string { - return JSON.stringify(migrateDoc(doc), null, 2); -} - -export function parseChapter(text: string): FolioChapterDoc { - let raw: unknown; - try { - raw = JSON.parse(text); - } catch { - throw new Error("Not a native Folio chapter document (invalid JSON)."); - } - if ( - !raw || - typeof raw !== "object" || - !("doc" in raw) || - (raw as FolioChapterDoc).doc?.type !== "doc" - ) { - throw new Error("Not a native Folio chapter document (missing doc tree)."); - } - const parsed = migrateDoc(raw as FolioChapterDoc); - normalizeDocTypes(parsed.doc); - return parsed; -} +export * from "../shared/native-doc-core.js"; // Read a native chapter document (throws if the chapter is not native JSON). export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc { @@ -170,9 +48,4 @@ export function setChapterDoc( fs.writeFileSync(file, serializeChapter(doc), "utf-8"); } -// Validate that a parsed object is structurally a usable chapter document. -export function isValidDoc(doc: FolioChapterDoc): boolean { - return !!doc && doc.doc?.type === "doc" && Array.isArray(doc.doc.content); -} - export { loadBook, saveMeta }; diff --git a/src/renderer/book.ts b/src/renderer/book.ts deleted file mode 100644 index 267acbe..0000000 --- a/src/renderer/book.ts +++ /dev/null @@ -1,2365 +0,0 @@ -import type { BookMeta, ChapterComment } from "../main/project.js"; -import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; -import { renderPrintChaptersNative } from "./print-format.js"; -import type { WikilinkTarget } from "./wikilinks.js"; -import type { Editor } from "@tiptap/core"; -import type { FolioNode } from "../main/native-doc.js"; -import { - createTiptapEditor, - setEditorDoc, - getEditorDoc, - getFormatState, - runCommand, - ChapterBreak, -} from "./tiptap-editor.js"; -import { - buildFullBookDoc, - nativeNodeText, -} from "./native-assembly.js"; - -export interface ChapterSnapshot { - id: string; - title: string; - color?: string | null; - content: string; - index: number; -} - -export type FolioAPI = { - newBookNamed: (name: string) => Promise; - deleteBook: (p: string) => Promise; - restoreBook: (p: string) => Promise; - openBook: () => Promise; - openBookPath: (p: string) => Promise; - revealInFolder: (p: string) => Promise; - getRecents: () => Promise; - closeBook: () => Promise; - showContextMenu: (x: number, y: number) => void; - getBookMeta: () => Promise; - setComments: (comments: Record) => Promise; - getChapterList: () => Promise; - getChapterContent: (id: string) => Promise; - createChapter: (title: string) => Promise; - renameChapter: (id: string, title: string) => Promise; - setChapterColor: (id: string, color: string | null) => Promise; - deleteChapter: (id: string) => Promise; - restoreChapter: (snap: ChapterSnapshot) => Promise; - duplicateChapter: (id: string, title?: string) => Promise; - reorderChapters: (newOrder: string[]) => Promise; - saveChapter: (id: string, content: string) => Promise; - migrateChapter: (id: string) => Promise; - migrateBook: () => Promise; - loadChapterDoc: (id: string) => Promise; - saveChapterDoc: (id: string, doc: unknown) => Promise; - importImageAsset: () => Promise; - exportMarkdown: () => Promise; - exportZip: () => Promise; - exportPdf: (chapterIds?: string[] | null) => Promise; - exportDocx: (chapterIds?: string[] | null) => Promise; - exportOdt: (chapterIds?: string[] | null) => Promise; - importAttachment: () => Promise; - copyText: (text: string) => Promise; - getClipboardText: () => Promise; - toggleFullscreen: () => Promise; - getTheme: () => Promise; - setTheme: (id: string) => Promise; - getZoom: () => Promise; - setZoom: (pct: number) => Promise; - onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; - onBookClosed: (cb: () => void) => void; - onChaptersChanged: (cb: () => void) => void; - onNewBookRequested: (cb: () => void) => void; - onOpenBookRequested: (cb: () => void) => void; - onOpenRecent: (cb: (p: string) => void) => void; - onRevealRequested: (cb: (p: string) => void) => void; - onExportMarkdownRequested: (cb: () => void) => void; - onExportZipRequested: (cb: () => void) => void; - onExportPdfRequested: (cb: () => void) => void; - onExportDocxRequested: (cb: () => void) => void; - onExportOdtRequested: (cb: () => void) => void; - onUndoRequested: (cb: () => void) => void; - onRedoRequested: (cb: () => void) => void; - onSelectAllRequested: (cb: () => void) => void; - onSaveRequested: (cb: () => void) => void; - onZoomInRequested: (cb: () => void) => void; - onZoomOutRequested: (cb: () => void) => void; - onZoomResetRequested: (cb: () => void) => void; -}; - -const $ = (id: string) => document.getElementById(id) as HTMLElement; - -interface ChapterEntry { - id: string; - title: string; - file: string; - color?: string; -} - -interface Recent { - path: string; - title: string; - lastOpened: string; -} - -function moveInList(items: T[], fromIndex: number, toIndex: number): T[] { - if (fromIndex === toIndex) return items; - const copy = items.slice(); - const [moved] = copy.splice(fromIndex, 1); - copy.splice(toIndex, 0, moved); - return copy; -} - -// Reduce Markdown to searchable plain text (headings, emphasis, links, etc. -// are flattened so a search matches what the reader actually sees). -function markdownToPlain(md: string): string { - return md - .replace(/```[\s\S]*?```/g, " ") - .replace(/`([^`]+)`/g, "$1") - .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") - .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") - .replace(/^ {0,3}#{1,6}\s+/gm, "") - .replace(/^\s{0,3}>+\s?/gm, "") - .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1") - .replace(/^\s*[-*+]\s+/gm, "• ") - .replace(/\r?\n\s*[-*_]{3,}\s*\r?\n/g, " ") - .replace(/\|/g, " ") - .replace(/[ \t]+/g, " "); -} - -function findMatches(plain: string, term: string, matchCase: boolean): number[] { - const out: number[] = []; - const hay = matchCase ? plain : plain.toLowerCase(); - const needle = matchCase ? term : term.toLowerCase(); - if (!needle) return out; - let idx = hay.indexOf(needle); - while (idx !== -1 && out.length < 200) { - out.push(idx); - idx = hay.indexOf(needle, idx + needle.length); - } - return out; -} - -function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">"); -} - -function makeSnippet(plain: string, pos: number, len: number): string { - const start = Math.max(0, pos - 40); - const end = Math.min(plain.length, pos + len + 40); - const before = plain.slice(start, pos); - const match = plain.slice(pos, pos + len); - const after = plain.slice(pos + len, end); - const pre = start > 0 ? "…" : ""; - const post = end < plain.length ? "…" : ""; - return escapeHtml(pre + before) + "" + escapeHtml(match) + "" + escapeHtml(after) + post; -} - - export function initBook(api: FolioAPI) { - let currentBook: { path: string; title: string } | null = null; - let meta: BookMeta | null = null; - let ordered: ChapterEntry[] = []; - let selectedId: string | null = null; - - function scrollToTerm(term: string, matchCase: boolean): void { - if (!term) return; - const needle = matchCase ? term : term.toLowerCase(); - let found: number | null = null; - editor.state.doc.descendants((node, pos) => { - if (found !== null) return false; - if (node.isText && node.text) { - const t = matchCase ? node.text : node.text.toLowerCase(); - const idx = t.indexOf(needle); - if (idx !== -1) { - found = pos + idx; - return false; - } - } - return true; - }); - if (found === null) return; - const to = Math.min(found + term.length, editor.state.doc.content.size); - editor.commands.focus(); - editor.commands.setTextSelection({ from: found, to }); - const dom = (editor.view as any).domAt(found); - const node = (dom.node.nodeType === 3 ? dom.node.parentElement : dom.node) as HTMLElement | null; - node?.scrollIntoView({ block: "center" }); - } - - - const chapterList = $("chapterList") as HTMLUListElement; - const editorPlaceholder = $("editorPlaceholder"); - const chapterView = $("chapterView"); - const chapterTitleEl = $("chapterTitle"); - const editorHost = $("editorHost"); - const wordCountEl = $("wordCount"); - const saveStateEl = $("saveState"); - const toastEl = $("toast"); - const exportBtn = $("exportBtn") as HTMLButtonElement; - const exportZipBtn = $("exportZipBtn") as HTMLButtonElement; - const addChapterBtn = $("addChapterBtn") as HTMLButtonElement; - const projectBar = $("projectBar"); - const fullBookNav = $("fullBookNav"); - const fullBookBtn = $("fullBookBtn") as HTMLButtonElement; - const fullBookView = $("fullBookView"); - const fullBookHost = $("fullBookHost"); - const fullBookStats = $("fullBookStats"); - const printBook = $("printBook"); - const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; - const fullBookStatus = $("fullBookStatus"); - const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement; - const exportMenu = $("exportMenu"); - const exportDropdown = $("exportDropdown"); - const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement; - const fbNextBtn = $("fbNextBtn") as HTMLButtonElement; - const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement; - - // Full Book is a read-only Tiptap surface that renders every chapter's native - // document back-to-back, separated by ChapterBreak nodes. Those nodes carry - // the chapter id as a data attribute so navigation can scroll to them via the - // DOM, and render the on-screen separator / print page break. - const fullBookEditor: Editor = createTiptapEditor( - fullBookHost, - { onChange: () => undefined }, - { editable: false, extensions: [ChapterBreak] } - ); - - // Navigation state for the Full Book view. chapterNav tracks every chapter in - // canonical order along with the DOM anchor (ChapterBreak element) that marks - // where its block begins. This is the same boundary data that drives page - // breaks, so navigation can never drift from the assembled order. The current - // chapter is kept in sync with the scroll position (manual scrolling updates - // the selector too). - interface FullBookChapterNav { - id: string; - title: string; - el: HTMLElement | null; - } - let fullBookChapterEls: HTMLElement[] = []; - let fullBookChapters: FullBookChapterNav[] = []; - let currentChapterIndex = 0; - let navScrollRaf: number | null = null; - - function populateChapterNav(entries: { id: string; title: string }[]) { - fbChapterSelect.innerHTML = ""; - for (const e of entries) { - const opt = document.createElement("option"); - opt.value = e.id; - opt.textContent = e.title; - fbChapterSelect.appendChild(opt); - } - setCurrentChapter(0); - } - - function setCurrentChapter(index: number) { - if (!fullBookChapters.length) currentChapterIndex = 0; - else currentChapterIndex = Math.max(0, Math.min(index, fullBookChapters.length - 1)); - updateChapterNavUI(); - } - - function updateChapterNavUI() { - const n = fullBookChapters.length; - fbPrevBtn.disabled = n === 0 || currentChapterIndex <= 0; - fbNextBtn.disabled = n === 0 || currentChapterIndex >= n - 1; - const entry = fullBookChapters[currentChapterIndex]; - if (entry && fbChapterSelect.value !== entry.id) fbChapterSelect.value = entry.id; - } - - // Scroll the assembled manuscript so the given chapter's anchor sits near the - // top of the Full Book viewport. This only touches the rendered editor, never - // the chapter files or the assembled content. - function jumpToChapter(index: number) { - if (!fullBookChapters.length) return; - const clamped = Math.max(0, Math.min(index, fullBookChapters.length - 1)); - fullBookChapterEls[clamped]?.scrollIntoView({ block: "start" }); - setCurrentChapter(clamped); - } - - // Map the element currently at the top of the Full Book viewport back to the - // chapter whose anchor is the last one above it, so manual scrolling updates - // the selector. - function detectChapterFromScroll(): number { - if (!fullBookChapters.length) return 0; - const scroller = fullBookEditor.view.dom as HTMLElement; - const rect = scroller.getBoundingClientRect(); - if (rect.height === 0) return currentChapterIndex; - const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2; - if (atBottom) return fullBookChapters.length - 1; - let idx = 0; - for (let i = 0; i < fullBookChapters.length; i++) { - const el = fullBookChapterEls[i]; - if (!el) continue; - const r = el.getBoundingClientRect(); - if (r.top <= rect.top + 4) idx = i; - else break; - } - return idx; - } - - (fullBookEditor.view.dom as HTMLElement).addEventListener("scroll", () => { - if (!fullBookChapters.length) return; - if (navScrollRaf !== null) cancelAnimationFrame(navScrollRaf); - navScrollRaf = requestAnimationFrame(() => { - navScrollRaf = null; - setCurrentChapter(detectChapterFromScroll()); - }); - }); - - // Native rich-text editor (Tiptap / ProseMirror). This is the editing surface - // for a single chapter; its state is native JSON, never Markdown. - const editor = createTiptapEditor(editorHost, { - onChange: () => handleEditorChange(), - onSelectionUpdate: () => handleSelection(), - }); - - const toolbar = $("editorToolbar"); - const headingSelect = $("headingSelect") as HTMLSelectElement; - - // Open/close helpers that drive the CSS open/close animations. `closeEl` - // adds `.closing` so the exit animation can play, then hides after it ends - // (with a fallback timer). `openEl` cancels any pending close so rapid - // toggles don't leave an element stuck hidden. - function openEl(el: HTMLElement) { - const pending = (el as unknown as { __close?: () => void }).__close; - if (pending) pending(); - el.classList.remove("hidden", "closing"); - } - function closeEl(el: HTMLElement | null) { - if (!el || el.classList.contains("hidden")) return; - el.classList.add("closing"); - let done = false; - const finish = () => { - if (done) return; - done = true; - el.classList.add("hidden"); - el.classList.remove("closing"); - el.removeEventListener("animationend", onEnd); - delete (el as unknown as { __close?: () => void }).__close; - }; - const onEnd = () => finish(); - el.addEventListener("animationend", onEnd); - const timer = setTimeout(finish, 420); - (el as unknown as { __close?: () => void }).__close = () => { - done = true; - clearTimeout(timer); - el.removeEventListener("animationend", onEnd); - el.classList.remove("closing"); - delete (el as unknown as { __close?: () => void }).__close; - }; - } - - function closeDropdowns() { - toolbar.querySelectorAll(".dropdown-menu").forEach((m) => closeEl(m)); - toolbar.querySelectorAll(".dropdown-toggle").forEach((t) => t.classList.remove("open")); - } - - function setActive(cmd: string, on: boolean) { - document.querySelectorAll(`[data-cmd="${cmd}"]`).forEach((el) => el.classList.toggle("active", on)); - } - - async function toggleFullscreen() { - const isFs = await api.toggleFullscreen(); - setActive("fullscreen", !!isFs); - } - - document.addEventListener("fullscreenchange", () => { - setActive("fullscreen", !!document.fullscreenElement); - }); - - function toggleFocus() { - const on = document.body.classList.toggle("focus-mode"); - setActive("focus", on); - } - - async function attachFile() { - const res = await api.importAttachment(); - if (!res) return; - if ("canceled" in res) return; - if ("error" in res) { - await alertMessage("Could not attach file", (res as { error: string }).error); - return; - } - if ("relPath" in res) { - runCommand(editor, "wikilink", { - href: (res as { relPath: string }).relPath, - text: (res as { name: string }).name, - }); - } - } - - function selectedText(): string { - const { from, to } = editor.state.selection; - return editor.state.doc.textBetween(from, to, " "); - } - - async function handleSpecial(item: HTMLElement) { - const action = item.dataset.action; - switch (action) { - case "wikilink": { - const sel = selectedText(); - const name = await promptInput("Wikilink to chapter", sel); - if (name === null) return; - runCommand(editor, "wikilink", { - href: `folio:chapter/${name}`, - text: sel && sel !== name ? sel : name, - }); - break; - } - case "image": { - const res = await api.importImageAsset(); - if (!res || "canceled" in res) return; - if ("error" in res) { - await alertMessage("Could not insert image", (res as { error: string }).error); - return; - } - if ("relPath" in res) { - runCommand(editor, "image", { - src: (res as { relPath: string }).relPath, - }); - } - break; - } - case "attach": { - await attachFile(); - return; - } - case "callout": { - // The native editor has no callout node; insert a styled blockquote. - runCommand(editor, "quote"); - break; - } - case "table": { - const rowsEl = $("tableRows") as HTMLInputElement; - const colsEl = $("tableCols") as HTMLInputElement; - const rows = Math.max(1, Math.min(12, parseInt(rowsEl.value, 10) || 3)); - const cols = Math.max(1, Math.min(8, parseInt(colsEl.value, 10) || 2)); - runCommand(editor, "table", { rows, cols }); - break; - } - default: - return; - } - editor.commands.focus(); - } - - toolbar.addEventListener("pointerdown", (e) => { - // Prevent default only for buttons so the editor keeps its selection/focus - // when a formatting command is invoked. Never prevent it for can't render each option - // in its own font reliably, so we build buttons whose label uses its own - // font-family. Fonts are bundled locally (see fonts.css) so each name shows - // in its actual typeface, and a search box + categories keep the long list - // manageable. Selecting a font applies it book-wide. - type FontDef = { name: string; family: string; cat: string }; - const FONT_LIST: FontDef[] = [ - { name: "Inter", family: '"Inter", sans-serif', cat: "Sans-serif" }, - { name: "Roboto", family: '"Roboto", sans-serif', cat: "Sans-serif" }, - { name: "Open Sans", family: '"Open Sans", sans-serif', cat: "Sans-serif" }, - { name: "Lato", family: "Lato, sans-serif", cat: "Sans-serif" }, - { name: "Montserrat", family: "Montserrat, sans-serif", cat: "Sans-serif" }, - { name: "Poppins", family: "Poppins, sans-serif", cat: "Sans-serif" }, - { name: "Oswald", family: "Oswald, sans-serif", cat: "Sans-serif" }, - { name: "Raleway", family: "Raleway, sans-serif", cat: "Sans-serif" }, - { name: "Nunito", family: "Nunito, sans-serif", cat: "Sans-serif" }, - { name: "Ubuntu", family: "Ubuntu, sans-serif", cat: "Sans-serif" }, - { name: "Source Sans 3", family: '"Source Sans 3", sans-serif', cat: "Sans-serif" }, - { name: "Noto Sans", family: '"Noto Sans", sans-serif', cat: "Sans-serif" }, - { name: "PT Sans", family: '"PT Sans", sans-serif', cat: "Sans-serif" }, - { name: "DM Sans", family: '"DM Sans", sans-serif', cat: "Sans-serif" }, - { name: "Quicksand", family: "Quicksand, sans-serif", cat: "Sans-serif" }, - { name: "Work Sans", family: '"Work Sans", sans-serif', cat: "Sans-serif" }, - { name: "Roboto Slab", family: '"Roboto Slab", serif', cat: "Serif" }, - { name: "Merriweather", family: "Merriweather, serif", cat: "Serif" }, - { name: "Playfair Display", family: '"Playfair Display", serif', cat: "Serif" }, - { name: "Libre Baskerville", family: '"Libre Baskerville", serif', cat: "Serif" }, - ]; - const fontMenu = $("fontMenu") as HTMLDivElement; - const fontLabel = $("fontLabel") as HTMLElement; - - function setFontLabel(name: string, family: string) { - fontLabel.textContent = name; - fontLabel.style.fontFamily = family; - } - - async function selectFont(name: string, family: string) { - // Apply font to the current selection (or cursor position). - // Book-wide propagation is handled separately via applyFontToAllChapters. - editor.chain().focus().setMark("fontFamily", { fontFamily: family }).run(); - setFontLabel(name, family); - if (selectedId) await api.saveChapterDoc(selectedId, getEditorDoc(editor)); - await applyFontToAllChapters(family, selectedId ?? undefined); - if (!fullBookView.classList.contains("hidden")) await refreshFullBook(); - } - - // Search box (filters the list without closing the menu). - const fontSearch = document.createElement("input"); - fontSearch.type = "text"; - fontSearch.placeholder = "Search fonts…"; - fontSearch.className = "font-search"; - fontSearch.addEventListener("click", (e) => e.stopPropagation()); - fontMenu.appendChild(fontSearch); - - const fontToggle = $("fontToggle") as HTMLButtonElement; - let activeIndex = 0; - const visibleOptions = () => optionEls.filter((el) => el.style.display !== "none"); - function highlight(index: number) { - optionEls.forEach((el) => el.classList.remove("is-active")); - const vis = visibleOptions(); - if (!vis.length) return; - activeIndex = Math.max(0, Math.min(index, vis.length - 1)); - const el = vis[activeIndex]; - if (el) { - el.classList.add("is-active"); - el.scrollIntoView({ block: "nearest" }); - } - } - // Open the menu with the search box focused so arrow-key navigation works - // immediately. - fontToggle.addEventListener("click", () => { - if (!fontMenu.classList.contains("hidden")) { - setTimeout(() => { fontSearch.focus(); highlight(activeIndex); }, 0); - } - }); - fontMenu.addEventListener("keydown", (e) => { - if (e.target !== fontSearch) return; - e.stopPropagation(); - const vis = visibleOptions(); - if (e.key === "ArrowDown") { - e.preventDefault(); - highlight(activeIndex + 1); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - highlight(activeIndex - 1); - } else if (e.key === "Enter") { - e.preventDefault(); - const el = vis[activeIndex]; - if (el) el.click(); - } - }); - - const fontListWrap = document.createElement("div"); - fontMenu.appendChild(fontListWrap); - - const optionEls: HTMLButtonElement[] = []; - const categories = [...new Set(FONT_LIST.map((f) => f.cat))]; - for (const cat of categories) { - const header = document.createElement("div"); - header.className = "font-cat"; - header.textContent = cat; - fontListWrap.appendChild(header); - for (const opt of FONT_LIST.filter((f) => f.cat === cat)) { - const item = document.createElement("button"); - item.type = "button"; - item.className = "dropdown-item font-option"; - item.textContent = opt.name; - item.style.fontFamily = opt.family; - item.dataset.name = opt.name.toLowerCase(); - item.addEventListener("click", (e) => { - e.stopPropagation(); - void selectFont(opt.name, opt.family); - closeDropdowns(); - }); - fontListWrap.appendChild(item); - optionEls.push(item); - } - } - - fontSearch.addEventListener("input", () => { - const q = fontSearch.value.trim().toLowerCase(); - for (const el of optionEls) { - el.style.display = !q || (el.dataset.name || "").includes(q) ? "" : "none"; - } - fontListWrap.querySelectorAll(".font-cat").forEach((h) => { - const cat = (h.textContent || "").trim(); - const anyVisible = FONT_LIST.filter((f) => f.cat === cat).some( - (f) => !q || f.name.toLowerCase().includes(q) - ); - h.style.display = anyVisible ? "" : "none"; - }); - activeIndex = 0; - highlight(activeIndex); - }); - - // Default writing font is Inter (the first bundled font). - setFontLabel(FONT_LIST[0].name, FONT_LIST[0].family); - - // Write the given font family into every chapter's content so the change - // propagates to all chapters (and survives reloads and exports). - async function applyFontToAllChapters(fontFamily: string, excludeId?: string) { - if (!meta) return; - const host = document.createElement("div"); - host.style.position = "absolute"; - host.style.left = "-99999px"; - host.style.top = "0"; - document.body.appendChild(host); - const tmp = createTiptapEditor(host); - try { - for (const ch of ordered) { - if (excludeId && ch.id === excludeId) continue; - try { - const res = await api.loadChapterDoc(ch.id); - if (!res || typeof res !== "object" || "error" in res) continue; - const doc = (res as { doc?: unknown }).doc; - if (!doc) continue; - setEditorDoc(tmp, doc as FolioNode); - if (fontFamily) - tmp.chain().selectAll().setMark("fontFamily", { fontFamily }).run(); - else tmp.chain().selectAll().unsetMark("fontFamily").run(); - await api.saveChapterDoc(ch.id, getEditorDoc(tmp)); - } catch (err) { - console.error("applyFontToAllChapters failed for", ch.id, err); - } - } - } finally { - tmp.destroy(); - host.remove(); - } - } - - const fontSizeSelect = $("fontSizeSelect") as HTMLSelectElement; - fontSizeSelect?.addEventListener("change", () => { - runCommand(editor, "fontsize", { fontSize: fontSizeSelect.value }); - editor.commands.focus(); - }); - - const lineSpacingSelect = $("lineSpacingSelect") as HTMLSelectElement; - lineSpacingSelect?.addEventListener("change", () => { - runCommand(editor, "linespacing", { lineHeight: lineSpacingSelect.value }); - editor.commands.focus(); - }); - - function setChapterButtons(enabled: boolean) { - addChapterBtn.disabled = !enabled; - fullBookNav.classList.toggle("hidden", !enabled); - } - let dirty = false; - let loadedDocJson = ""; - let saveTimer: ReturnType | null = null; - // Debounce for automatic saves while typing. When the user stops typing for - // this long the chapter is persisted in the background so navigating away - // (or closing the book) never loses text. - const AUTOSAVE_MS = 1000; - const bookTitleEl = $("bookTitle"); - const bookPathEl = $("bookPath"); - const welcome = $("welcome"); - const recentList = $("recentList") as HTMLUListElement; - - - // One-time wiring for search UI and the Save button. Previously this - // block lived inside showWelcome(), re-registering ~15 listeners every - // time the welcome screen was shown (N Home trips = N duplicate saves - // per click). Elements are static; bind once instead. - function initSearchUI(): void { - // ---- search across all chapters ---- - const searchBtn = $("searchBtn") as HTMLButtonElement; - const searchModal = $("searchModal") as HTMLDivElement; - const searchInput = $("searchInput") as HTMLInputElement; - const searchCaseCb = $("searchCase") as HTMLInputElement; - const searchMeta = $("searchMeta") as HTMLElement; - const searchResults = $("searchResults") as HTMLUListElement; - const searchClose = $("searchClose") as HTMLButtonElement; - const replaceInput = $("replaceInput") as HTMLInputElement; - const replaceBtn = $("replaceBtn") as HTMLButtonElement; - const replaceAllBtn = $("replaceAllBtn") as HTMLButtonElement; - let searchTimer: number | undefined; - - const saveBtn = $("saveBtn") as HTMLButtonElement; - saveBtn.addEventListener("click", () => void saveAndNotify()); - - function openSearch(): void { - if (!meta) { - void alertMessage("No book open", "Open a book before searching."); - return; - } - openEl(searchModal); - searchInput.value = ""; - searchResults.innerHTML = ""; - searchMeta.textContent = ""; - setTimeout(() => searchInput.focus(), 0); - } - function closeSearch(): void { - closeEl(searchModal); - } - - function runSearchNow(): void { - const term = searchInput.value.trim(); - searchResults.innerHTML = ""; - if (!term) { - searchMeta.textContent = ""; - return; - } - if (!meta) { - searchMeta.textContent = "Open a book to search."; - return; - } - const matchCase = searchCaseCb.checked; - void (async () => { - const list = await api.getChapterList(); - const chapters = Array.isArray(list) ? list : []; - const byChapter: { id: string; title: string; plain: string; positions: number[] }[] = []; - let total = 0; - for (const ch of chapters) { - if (total >= 200) break; - const res = await api.getChapterContent(ch.id); - const md = typeof res === "string" ? res : ""; - const plain = markdownToPlain(md); - const positions = findMatches(plain, term, matchCase); - if (positions.length) { - byChapter.push({ id: ch.id, title: ch.title || ch.id, plain, positions }); - total += positions.length; - } - } - searchMeta.textContent = total - ? `${total} match${total === 1 ? "" : "es"} in ${byChapter.length} chapter${byChapter.length === 1 ? "" : "s"}` - : "No matches."; - for (const r of byChapter) { - const head = document.createElement("li"); - head.className = "search-chapter-head"; - head.textContent = `${r.title} (${r.positions.length})`; - searchResults.appendChild(head); - for (const pos of r.positions) { - const li = document.createElement("li"); - li.className = "search-result"; - li.innerHTML = makeSnippet(r.plain, pos, term.length); - li.addEventListener("click", () => void goToResult(r.id, pos, term, matchCase)); - searchResults.appendChild(li); - } - } - })(); - } - - function goToResult(id: string, pos: number, term: string, matchCase: boolean): void { - closeSearch(); - void (async () => { - await selectChapter(id); - scrollToTerm(term, matchCase); - })(); - } - - // --- find & replace (operates on native chapter documents) --- - // Build a literal (or case-insensitive) RegExp from a user term. Replace - // treats the term as a literal string to avoid surprising the user. - function termToRegExp(term: string, matchCase: boolean): RegExp { - const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(escaped, matchCase ? "g" : "gi"); - } - - // Find every occurrence of `term` within a Tiptap editor's text and replace - // it with `replacement`. Matches are gathered per text node (positions are - // ProseMirror positions) and applied in reverse so earlier positions stay - // valid. Returns the number of replacements made. - function findAndReplaceInEditor( - ed: Editor, - term: string, - replacement: string, - matchCase: boolean - ): number { - if (!term) return 0; - const re = termToRegExp(term, matchCase); - const matches: { from: number; to: number }[] = []; - ed.state.doc.descendants((node, pos) => { - if (node.isText && node.text) { - let m: RegExpExecArray | null; - re.lastIndex = 0; - while ((m = re.exec(node.text)) !== null) { - if (m[0].length === 0) { - re.lastIndex++; - continue; - } - matches.push({ from: pos + m.index, to: pos + m.index + m[0].length }); - } - } - return true; - }); - if (!matches.length) return 0; - // Apply highest-position match first so lower positions are unaffected. - const { state, view } = ed; - let tr = state.tr; - for (let i = matches.length - 1; i >= 0; i--) { - tr = tr.insertText(replacement, matches[i].from, matches[i].to); - } - view.dispatch(tr); - return matches.length; - } - - // Load a chapter's native document into a throwaway editor, run find & replace - // on it, and persist the result. Returns the number of replacements. - async function replaceInChapterDoc( - id: string, - term: string, - replacement: string, - matchCase: boolean - ): Promise { - const res = await api.loadChapterDoc(id); - if (!res || typeof res !== "object" || "error" in res) return 0; - const doc = (res as { doc?: unknown }).doc; - if (!doc) return 0; - const host = document.createElement("div"); - host.style.position = "absolute"; - host.style.left = "-99999px"; - host.style.top = "0"; - document.body.appendChild(host); -const tmp = createTiptapEditor(host); - let count = 0; - try { - setEditorDoc(tmp, doc as FolioNode); - count = findAndReplaceInEditor(tmp, term, replacement, matchCase); - if (count > 0) await api.saveChapterDoc(id, getEditorDoc(tmp)); - } finally { - tmp.destroy(); - host.remove(); - } - return count; - } - - // Flush current edits, then replace either in the current chapter or the whole - // book. Afterward the UI is refreshed so the changes are visible. - async function doReplace(all: boolean): Promise { - const term = searchInput.value.trim(); - const replacement = replaceInput.value; - if (!term) { - searchMeta.textContent = "Enter a search term to replace."; - return; - } - if (!meta) return; - await saveCurrentChapter(); - const ids = all ? meta.chapterOrder.slice() : selectedId ? [selectedId] : []; - if (!ids.length) { - searchMeta.textContent = "No chapter to replace in."; - return; - } - let total = 0; - let chaptersTouched = 0; - for (const id of ids) { - const c = await replaceInChapterDoc(id, term, replacement, searchCaseCb.checked); - if (c > 0) { - total += c; - chaptersTouched++; - } - } - if (total === 0) { - searchMeta.textContent = "No matches to replace."; - return; - } - if (selectedId) await selectChapter(selectedId); - if (!fullBookView.classList.contains("hidden")) await refreshFullBook(); - searchMeta.textContent = `Replaced ${total} occurrence${total === 1 ? "" : "s"} in ${chaptersTouched} chapter${chaptersTouched === 1 ? "" : "s"}.`; - void runSearchNow(); - } - - searchInput.addEventListener("input", () => { - clearTimeout(searchTimer); - searchTimer = window.setTimeout(runSearchNow, 200); - }); - searchCaseCb.addEventListener("change", runSearchNow); - replaceBtn.addEventListener("click", () => void doReplace(false)); - replaceAllBtn.addEventListener("click", () => void doReplace(true)); - searchBtn.addEventListener("click", openSearch); - searchClose.addEventListener("click", closeSearch); - searchModal.addEventListener("click", (e) => { - if (e.target === searchModal) closeSearch(); - }); - window.addEventListener("keydown", (e) => { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") { - e.preventDefault(); - openSearch(); - } else if (e.key === "Escape" && !searchModal.classList.contains("hidden")) { - closeSearch(); - } else if ( - e.key === "Enter" && - !searchModal.classList.contains("hidden") && - document.activeElement === searchInput - ) { - const first = searchResults.querySelector(".search-result"); - if (first) first.click(); - } - }); - } - - async function showWelcome() { - welcome.classList.remove("hidden"); - chapterView.classList.add("hidden"); - fullBookView.classList.add("hidden"); - editorPlaceholder.classList.add("hidden"); - // Keep the project bar visible so the Home button is a persistent nav - // control. On Home it is shown disabled/active (you are already here). - projectBar.classList.remove("hidden"); - bookTitleEl.textContent = ""; - bookTitleEl.classList.add("hidden"); - bookPathEl.classList.add("hidden"); - bookPathEl.title = ""; - $("revealBtn")?.classList.add("hidden"); - const hb = homeBtn as HTMLButtonElement | null; - if (hb) { - hb.disabled = true; - hb.classList.add("active"); - hb.setAttribute("aria-current", "page"); - } - currentBook = null; - meta = null; - ordered = []; - selectedId = null; - await renderRecents(); - - setChapterButtons(false); - fullBookBtn.classList.remove("active"); - setEditorDoc(fullBookEditor, { type: "doc", content: [] }); - fullBookChapters = []; - fullBookChapterEls = []; - currentChapterIndex = 0; - fbChapterSelect.innerHTML = ""; - updateChapterNavUI(); - renderChapterList(); - void renderRecents(); - } - - function showPlaceholder() { - chapterView.classList.add("hidden"); - fullBookView.classList.add("hidden"); - fullBookBtn.classList.remove("active"); - editorPlaceholder.classList.remove("hidden"); - } - - function setCurrentBook(data: { path: string; title: string }) { - currentBook = data; - bookTitleEl.textContent = "Home"; - bookTitleEl.classList.remove("hidden"); - bookPathEl.classList.add("hidden"); - $("revealBtn")?.classList.remove("hidden"); - const hb = homeBtn as HTMLButtonElement | null; - if (hb) { - hb.disabled = false; - hb.classList.remove("active"); - hb.removeAttribute("aria-current"); - } - projectBar.classList.remove("hidden"); - welcome.classList.add("hidden"); - selectedId = null; - void loadMeta(); - } - - async function loadMeta() { - const res = await api.getBookMeta(); - if (res && typeof res === "object" && "error" in res) { - await alertMessage("Could not load book", (res as { error: string }).error); - meta = null; - } else { - meta = res as BookMeta; - ordered = meta.chapterOrder - .map((id) => meta!.chapters[id]) - .filter(Boolean) as ChapterEntry[]; - } - setChapterButtons(!!meta); - renderChapterList(); - // Preserve the chapter currently being read. Metadata refreshes fire on - // every rename/color/reorder, and jumping back to the first chapter - // here would yank the editor out from under the user mid-session. - const keepId = selectedId && meta?.chapters[selectedId] ? selectedId : null; - if (keepId) { - const entry = meta!.chapters[keepId]; - chapterTitleEl.textContent = entry.title || keepId; - updateCommentsBadge(); - await refreshFullBook(); - return; - } - if (ordered.length) { - await selectChapter(ordered[0].id); - } else { - showPlaceholder(); - } - await refreshFullBook(); - } - - async function refreshMeta() { - await loadMeta(); - if (meta && ordered.length === 0) { - selectedId = null; - showPlaceholder(); - } - } - - // Predefined palette used for the chapter-color picker (kept in sync with the - // text/highlight palettes so the whole app feels consistent). - const CHAPTER_COLORS = [ - "#e11d48", "#f97316", "#eab308", "#22c55e", - "#3b82f6", "#8b5cf6", "#78716c", "#18181b", - ]; - const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; - - let colorPopover: HTMLDivElement | null = null; - function closeColorPopover() { - closeEl(colorPopover); - } - - function openColorPopover( - anchor: HTMLElement, - current: string | null, - onPick: (c: string | null) => void - ) { - if (!colorPopover) { - colorPopover = document.createElement("div"); - colorPopover.className = "color-popover hidden"; - document.body.appendChild(colorPopover); - } - const pop = colorPopover; - pop.innerHTML = ""; - const grid = document.createElement("div"); - grid.className = "cp-grid"; - for (const c of CHAPTER_COLORS) { - const sw = document.createElement("button"); - sw.type = "button"; - sw.className = "cp-swatch" + (current === c ? " active" : ""); - sw.style.background = c; - sw.title = c; - sw.setAttribute("aria-label", c); - sw.addEventListener("click", (e) => { - e.stopPropagation(); - closeColorPopover(); - onPick(c); - }); - grid.appendChild(sw); - } - pop.appendChild(grid); - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "cp-remove"; - remove.textContent = current ? "Remove color" : "No color set"; - remove.disabled = !current; - remove.addEventListener("click", (e) => { - e.stopPropagation(); - closeColorPopover(); - onPick(null); - }); - pop.appendChild(remove); - - openEl(pop); - const pw = pop.offsetWidth; - const ph = pop.offsetHeight; - const rect = anchor.getBoundingClientRect(); - let left = rect.left; - let top = rect.bottom + 4; - if (left + pw > window.innerWidth - 8) { - left = Math.max(8, window.innerWidth - pw - 8); - } - if (top + ph > window.innerHeight - 8) { - top = Math.max(8, rect.top - ph - 4); - } - pop.style.left = `${left}px`; - pop.style.top = `${top}px`; - } - - // Click outside the popover closes it (the open button stops propagation so it - // doesn't immediately self-close). - document.addEventListener("click", (e) => { - if (colorPopover && !colorPopover.classList.contains("hidden") && - !colorPopover.contains(e.target as Node)) { - closeColorPopover(); - } - }); - window.addEventListener("scroll", closeColorPopover, true); - window.addEventListener("resize", closeColorPopover); - - async function applyChapterColor(id: string, color: string | null) { - const res = await api.setChapterColor(id, color); - if (res && "error" in res) { - await alertMessage("Could not set chapter color", (res as { error: string }).error); - return; - } - const entry = ordered.find((c) => c.id === id); - if (entry) { - if (color === null) delete entry.color; - else entry.color = color; - } - if (meta && meta.chapters[id]) { - if (color === null) delete meta.chapters[id].color; - else meta.chapters[id].color = color; - } - renderChapterList(); - } - - function renderChapterList() { - chapterList.innerHTML = ""; - for (const ch of ordered) { - const li = document.createElement("li"); - li.className = "chapter-row" + (selectedId === ch.id ? " active" : ""); - li.dataset.id = ch.id; - li.draggable = false; - li.tabIndex = -1; - li.innerHTML = ` - ⋮ - ${ch.title} - - - - - - `; - if (ch.color && HEX_RE.test(ch.color)) { - const dot = document.createElement("span"); - dot.className = "row-color-dot"; - dot.style.background = ch.color; - dot.title = "Chapter color: " + ch.color; - li.insertBefore(dot, li.querySelector(".row-title")); - } - li.addEventListener("click", (e) => { - if (dragJustHappened) { - dragJustHappened = false; - e.stopPropagation(); - return; - } - e.stopPropagation(); - li.focus(); - void selectChapter(ch.id); - }); - li.querySelector('[data-act="color"]')?.addEventListener("click", (e) => { - e.stopPropagation(); - openColorPopover(li, ch.color ?? null, (c) => void applyChapterColor(ch.id, c)); - }); - li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => { - e.stopPropagation(); - void rename(ch.id); - }); - li.querySelector('[data-act="dup"]')?.addEventListener("click", (e) => { - e.stopPropagation(); - void duplicate(ch.id); - }); - li.querySelector('[data-act="del"]')?.addEventListener("click", (e) => { - e.stopPropagation(); - void del(ch.id); - }); - li.addEventListener("keydown", (e) => { - if (!e.altKey) return; - if (e.key === "ArrowDown") { e.preventDefault(); moveSelectedDown(); } - else if (e.key === "ArrowUp") { e.preventDefault(); moveSelectedUp(); } - }); - chapterList.appendChild(li); - } - } - - let toastTimer: ReturnType | null = null; - function showToast(msg: string) { - if (!toastEl) return; - toastEl.textContent = msg; - toastEl.classList.remove("hidden"); - toastEl.classList.add("show"); - if (toastTimer) clearTimeout(toastTimer); - toastTimer = setTimeout(() => { - toastEl.classList.remove("show"); - toastTimer = setTimeout(() => toastEl.classList.add("hidden"), 200); - }, 1400); - } - - // Toast with an Undo action, used after destructive operations (delete book, - // delete/cut chapter). Auto-dismisses; the action runs once if tapped. - let undoToastTimer: ReturnType | null = null; - function hideUndoToast(): void { - const el = $("undoToast"); - if (!el) return; - el.classList.remove("show"); - // Drop the handler so a second click during the hide animation cannot - // run the undo twice (restore would duplicate the chapter entry). - const btn = $("undoToastBtn") as HTMLButtonElement | null; - if (btn) btn.onclick = null; - setTimeout(() => el.classList.add("hidden"), 200); - } - function showUndoToast(msg: string, onUndo: () => void, timeout = 9000): void { - const el = $("undoToast"); - if (!el) { - onUndo(); - return; - } - const msgEl = $("undoToastMsg"); - const btn = $("undoToastBtn") as HTMLButtonElement | null; - if (msgEl) msgEl.textContent = msg; - if (btn) - btn.onclick = () => { - // Single-fire: detach before running so double-clicks are ignored. - btn.onclick = null; - hideUndoToast(); - onUndo(); - }; - el.classList.remove("hidden"); - el.classList.add("show"); - if (undoToastTimer) clearTimeout(undoToastTimer); - undoToastTimer = setTimeout(hideUndoToast, timeout); - } - - async function saveCurrentChapter(): Promise { - if (!selectedId) return; - clearTimers(); - const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor)); - if (!(res && "error" in res)) { - loadedDocJson = JSON.stringify(getEditorDoc(editor)); - dirty = false; - saveStateEl.textContent = "Saved"; - saveStateEl.classList.remove("unsaved"); - } - } - - // Explicit save (Ctrl/Cmd+S or menu) — always give visible feedback. - async function saveAndNotify(): Promise { - if (!selectedId) { - showToast("Nothing to save"); - return; - } - await saveCurrentChapter(); - showToast("Saved"); - } - - // Guards against overlapping selections: rapid clicks (or metadata - // refreshes) can interleave loads; only the newest one may commit state. - let selectToken = 0; - async function selectChapter(id: string) { - clearTimers(); - if (selectedId && dirty) { - // Auto-save silently so navigating away never loses text and returning to - // this chapter shows what was just written. The save targets the - // chapter still selected right now, so it happens before reassignment. - await saveCurrentChapter(); - } - const token = ++selectToken; - let res: { doc?: never; error?: string } | null = null; - try { - res = await api.loadChapterDoc(id); - } catch { - res = null; - } - // A newer selection superseded this one: drop the stale reply instead of - // pairing its document with the wrong chapter id. - if (token !== selectToken) return; - if (!(res && "doc" in res)) { - // Load failed. Keep the current editor contents and selection intact — - // seeding an empty document here would let the next autosave overwrite - // the real chapter file with near-empty content. - const msg = res && "error" in res ? String(res.error) : "The chapter file could not be read."; - void alertMessage("Couldn't open chapter", msg); - return; - } - selectedId = id; - renderChapterList(); - updateCommentsBadge(); - const doc = (res as { doc: never }).doc; - const title = (meta && meta.chapters[id]?.title) || id; - chapterTitleEl.textContent = title; - setEditorDoc(editor, doc); - loadedDocJson = JSON.stringify(getEditorDoc(editor)); - dirty = false; - saveStateEl.textContent = "Saved"; - saveStateEl.classList.remove("unsaved"); - wordCountEl.textContent = formatWordCount(editor.getText()); - editor.commands.focus("end"); - clearTimers(); - fullBookBtn.classList.remove("active"); - fullBookView.classList.add("hidden"); - chapterView.classList.remove("hidden"); - editorPlaceholder.classList.add("hidden"); - } - - // ---- full book view ---- - - function chapterHasOwnHeading(content: string, title: string): boolean { - const t = title.trim().toLowerCase(); - if (!t) return true; - for (const line of content.split(/\r?\n/)) { - const m = line.match(/^#{1,6}\s+(.+?)\s*$/); - if (m && m[1].trim().toLowerCase() === t) return true; - } - return false; - } - - // Assemble the whole book directly from native chapter documents. The view is - // a single ProseMirror doc with a ChapterBreak anchor before every chapter - // (the first one unmarked). Print/export still use the Markdown chapters - // computed alongside, so export formats are unchanged. The pure logic lives - // in native-assembly.ts; here we only fetch the per-chapter data. - async function assembleFullBook(): Promise<{ - nativeDoc: FolioNode; - chapterEntries: { id: string; title: string }[]; - targets: WikilinkTarget[]; - }> { - const res = await api.getBookMeta(); - if (!res || typeof res !== "object" || "error" in res) { - return { - nativeDoc: { type: "doc", content: [] }, - chapterEntries: [], - targets: [], - }; - } - const book = res as BookMeta; - const inputs = []; - for (const id of book.chapterOrder) { - const ch = book.chapters[id]; - if (!ch) continue; - const docRes = await api.loadChapterDoc(ch.id); - const doc = docRes && "doc" in docRes ? (docRes as { doc: FolioNode }).doc : { type: "doc", content: [] }; - inputs.push({ id: ch.id, title: ch.title, doc }); - } - return buildFullBookDoc(inputs); - } - - async function renderFullBook() { - const { nativeDoc, chapterEntries } = await assembleFullBook(); - setEditorDoc(fullBookEditor, nativeDoc); - fullBookChapterEls = Array.from( - fullBookHost.querySelectorAll("[data-chapter-id]") - ); - fullBookChapters = chapterEntries.map((e, i) => ({ ...e, el: fullBookChapterEls[i] ?? null })); - populateChapterNav(chapterEntries); - const text = nativeNodeText(nativeDoc); - fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length} · ~${countWords(text) ? Math.ceil(countWords(text) / 200) : 0} min read`; - printBook.innerHTML = renderPrintChaptersNative(nativeDoc); - // Re-render replaces the whole document, which resets the scroll position, - // so re-derive the current chapter once layout has settled. - requestAnimationFrame(() => { - requestAnimationFrame(() => setCurrentChapter(detectChapterFromScroll())); - }); - } - - async function selectFullBook() { - if (selectedId && dirty) { - await saveCurrentChapter(); - dirty = false; - saveStateEl.textContent = "Saved"; - saveStateEl.classList.remove("unsaved"); - } - clearTimers(); - selectedId = null; - chapterList.querySelectorAll(".chapter-row").forEach((r) => r.classList.remove("active")); - fullBookBtn.classList.add("active"); - chapterView.classList.add("hidden"); - editorPlaceholder.classList.add("hidden"); - fullBookView.classList.remove("hidden"); - await renderFullBook(); - fullBookEditor.commands.focus(); - } - - async function refreshFullBook() { - if (fullBookView.classList.contains("hidden")) return; - await renderFullBook(); - } - - function clearTimers() { - if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } - } - - // Schedule a background save a short time after the last edit. The indicator - // shows "Saving…" while the debounce is pending so the user knows the chapter - // is about to be persisted automatically. - function scheduleAutosave() { - clearTimers(); - saveStateEl.textContent = "Saving…"; - saveStateEl.classList.remove("unsaved"); - saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS); - } - - // Persist the current chapter if it has unsaved edits. Safe to call any time; - // does nothing once the chapter is already clean. - async function flushAutosave() { - saveTimer = null; - if (!selectedId || !dirty) return; - await saveCurrentChapter(); - } - - function handleEditorChange() { - if (!selectedId) return; - const json = JSON.stringify(getEditorDoc(editor)); - dirty = json !== loadedDocJson; - saveStateEl.textContent = dirty ? "Unsaved" : "Saved"; - saveStateEl.classList.toggle("unsaved", dirty); - wordCountEl.textContent = formatWordCount(editor.getText()); - if (dirty) scheduleAutosave(); - } - - function handleSelection() { - const fmt = getFormatState(editor); - headingSelect.value = String(fmt.heading); - if (lineSpacingSelect) lineSpacingSelect.value = fmt.lineHeight || ""; - setActive("bold", fmt.bold); - setActive("italic", fmt.italic); - setActive("strike", fmt.strike); - setActive("underline", fmt.underline); - setActive("highlight", editor.isActive("highlight")); - setActive("code", fmt.code); - setActive("h2", fmt.heading === 2); - setActive("h3", fmt.heading === 3); - setActive("link", fmt.link); - setActive("bullet", fmt.bullet); - setActive("number", fmt.ordered); - setActive("checklist", fmt.task); - setActive("quote", fmt.blockquote); - setActive("align-left", fmt.align === "left"); - setActive("align-center", fmt.align === "center"); - setActive("align-right", fmt.align === "right"); - } - - function status(text: string, ms = 3000) { - saveStateEl.textContent = text; - saveStateEl.classList.add("unsaved"); - setTimeout(() => { - saveStateEl.textContent = dirty ? "Unsaved" : "Saved"; - saveStateEl.classList.toggle("unsaved", dirty); - }, ms); - } - - async function exportMarkdown() { - if (!currentBook) return; - const res = await api.exportMarkdown(); - if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); - else if (res && "filePath" in res) status(`Exported to ${basename(res.filePath)}`); - else if (res && "canceled" in res) status("Export canceled"); - } - - async function exportZip() { - if (!currentBook) return; - const res = await api.exportZip(); - if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); - else if (res && "filePath" in res) status(`Bundle saved to ${basename(res.filePath)}`); - else if (res && "canceled" in res) status("Export canceled"); - } - - function fullBookNotice(text: string) { - fullBookStatus.textContent = text; - fullBookStatus.classList.add("unsaved"); - setTimeout(() => { - fullBookStatus.textContent = ""; - fullBookStatus.classList.remove("unsaved"); - }, 3000); - } - - // Build a native Full Book document from an explicit, ordered set of chapter - // ids. Used for "selected chapters" PDF export so the printed manuscript - // contains exactly those chapters in canonical order. - async function assembleChapters( - ids: string[] - ): Promise<{ nativeDoc: FolioNode; chapterEntries: { id: string; title: string }[] }> { - const res = await api.getBookMeta(); - const inputs: { id: string; title: string; doc: FolioNode }[] = []; - if (res && typeof res === "object" && !("error" in res)) { - const book = res as BookMeta; - for (const id of ids) { - const ch = book.chapters[id]; - if (!ch) continue; - const docRes = await api.loadChapterDoc(id); - const doc = - docRes && "doc" in docRes ? (docRes as { doc: FolioNode }).doc : { type: "doc", content: [] }; - inputs.push({ id, title: ch.title, doc }); - } - } - return buildFullBookDoc(inputs); - } - - // Render a specific set of chapters into the print container for PDF export. - async function renderChaptersToPrint(ids: string[]) { - const asm = await assembleChapters(ids); - printBook.innerHTML = renderPrintChaptersNative(asm.nativeDoc); - } - - // Export the assembled book as a PDF. With no chapter selection it prints the - // whole book (jumping to the Full Book view first if needed); with a selection - // it renders just those chapters into the print container before printing. - async function exportPdf(chapterIds?: string[] | null) { - if (!currentBook) return; - if (chapterIds && chapterIds.length) { - await renderChaptersToPrint(chapterIds); - } else { - if (fullBookView.classList.contains("hidden")) await selectFullBook(); - } - const res = await api.exportPdf(chapterIds && chapterIds.length ? chapterIds : undefined); - if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); - else if (res && "canceled" in res) fullBookNotice("Export canceled"); - else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`); - } - - async function exportDocx(chapterIds?: string[] | null) { - if (!currentBook) return; - const res = await api.exportDocx(chapterIds && chapterIds.length ? chapterIds : undefined); - if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); - else if (res && "canceled" in res) fullBookNotice("Export canceled"); - else if (res && "filePath" in res) fullBookNotice(`DOCX saved to ${basename(res.filePath)}`); - } - - async function exportOdt(chapterIds?: string[] | null) { - if (!currentBook) return; - const res = await api.exportOdt(chapterIds && chapterIds.length ? chapterIds : undefined); - if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); - else if (res && "canceled" in res) fullBookNotice("Export canceled"); - else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`); - } - - async function exportFromMenu(kind: string) { - if (kind === "native") { - await migrateBookNative(); - return; - } - // PDF/DOCX/ODT open the scope/selection workflow so the user can export the - // whole book or just selected chapters. - if (kind === "pdf" || kind === "docx" || kind === "odt") { - openExportWorkflow(kind); - return; - } - } - - // Explicitly migrate the whole book to the native document format. The main - // process performs a recovery backup and leaves the original .md files in - // place; after this, chapters are stored as native JSON but the (still - // Markdown-based) editor and exports keep working via the format bridge. - async function migrateBookNative() { - if (!currentBook) return; - const confirmed = await confirmMessage( - "Migrate this book to the native document format?", - "Your original Markdown files are kept and a recovery backup is created in ~/Folio-Recovery. This only changes how chapters are stored — nothing else." - ); - if (!confirmed) return; - const res = await api.migrateBook(); - if (res && "error" in res) { - await alertMessage("Migration failed", (res as { error: string }).error); - return; - } - await refreshMeta(); - if (!fullBookView.classList.contains("hidden")) await refreshFullBook(); - fullBookNotice("Book migrated to native format"); - } - - function basename(p: string): string { - return p.split(/[/\\]/).pop() || p; - } - - function countWords(text: string) { - const trimmed = text.trim(); - if (!trimmed) return 0; - return trimmed.split(/\s+/).length; - } - - // Word-count label with an approximate reading-time estimate (~200 wpm). - function formatWordCount(text: string): string { - const w = countWords(text); - return w ? `words: ${w} · ~${Math.ceil(w / 200)} min read` : "words: 0"; - } - - async function create() { - if (!meta) return; - const count = meta.chapterOrder.length + 1; - const title = await promptInput("New chapter", `Chapter ${count}`); - if (!title) return; - const res = await api.createChapter(title); - if (res && "error" in res) await alertMessage("Could not create chapter", (res as { error: string }).error); - else { - await refreshMeta(); - if (res && "id" in res) void selectChapter((res as ChapterEntry).id); - } - } - - async function rename(id: string) { - if (!meta || !meta.chapters[id]) return; - const entry = meta.chapters[id]; - const title = await promptInput("Rename chapter", entry.title); - if (!title) return; - const res = await api.renameChapter(id, title); - if (res && "error" in res) await alertMessage("Could not rename chapter", (res as { error: string }).error); - else { - await refreshMeta(); - if (selectedId === id && meta && meta.chapters[id]) { - chapterTitleEl.textContent = meta.chapters[id].title; - } - } - } - - async function del(id: string) { - if (!meta || !meta.chapters[id]) return; - const entry = meta.chapters[id]; - const index = meta.chapterOrder.indexOf(id); - // Flush unsaved edits before snapshotting: the undo snapshot reads from - // disk, so text typed since the last autosave would otherwise be lost - // even through Undo. Also stop a pending autosave timer from firing - // while the chapter is being removed. - if (selectedId === id) { - clearTimers(); - if (dirty) await saveCurrentChapter(); - } - const content = (await api.getChapterContent(id)) as string; - const res = await api.deleteChapter(id); - if (res && "error" in res) { - await alertMessage("Could not delete chapter", (res as { error: string }).error); - return; - } - const wasSelected = selectedId === id; - if (wasSelected) { selectedId = null; showPlaceholder(); } - await refreshMeta(); - showUndoToast(`Deleted "${entry.title}"`, async () => { - const r = await api.restoreChapter({ - id, - title: entry.title, - color: entry.color ?? null, - content, - index, - }); - if (r && "error" in r) { - await alertMessage("Could not restore chapter", (r as { error: string }).error); - return; - } - await refreshMeta(); - if (wasSelected) await selectChapter(id); - }); - } - - async function duplicate(id: string) { - if (!meta || !meta.chapters[id]) return; - const entry = meta.chapters[id]; - const title = await promptInput("Duplicate as", `${entry.title} (copy)`); - if (title === null) return; - const res = await api.duplicateChapter(id, title || undefined); - if (res && "error" in res) await alertMessage("Could not duplicate chapter", (res as { error: string }).error); - else if (res && "id" in res) { - await refreshMeta(); - void selectChapter((res as ChapterEntry).id); - } - } - - async function persistOrder() { - if (!meta) return; - meta.chapterOrder = ordered.map((c) => c.id); - const res = await api.reorderChapters(meta.chapterOrder); - if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error); - else renderChapterList(); - } - - function moveSelectedDown() { - if (!selectedId) return; - const i = ordered.findIndex((c) => c.id === selectedId); - if (i < 0 || i >= ordered.length - 1) return; - ordered = moveInList(ordered, i, i + 1); - meta!.chapterOrder = ordered.map((c) => c.id); - renderChapterList(); - void persistOrder(); - } - - function moveSelectedUp() { - if (!selectedId) return; - const i = ordered.findIndex((c) => c.id === selectedId); - if (i <= 0) return; - ordered = moveInList(ordered, i, i - 1); - meta!.chapterOrder = ordered.map((c) => c.id); - renderChapterList(); - void persistOrder(); - } - - async function handleResult(res: any) { - if (!res) return; - if ("canceled" in res) return; - if ("error" in res) { await alertMessage("Error", (res as { error: string }).error); return; } - if ("path" in res) setCurrentBook({ path: res.path, title: res.title }); - } - - async function startNewBook() { - const name = await promptInput("New book", "My Book"); - if (name === null) return; - const trimmed = name.trim(); - if (!trimmed) return; - const res = await api.newBookNamed(trimmed); - await handleResult(res); - await renderRecents(); - } - - async function startOpenBook() { - const res = await api.openBook(); - await handleResult(res); - await renderRecents(); - } - - async function openPath(p: string) { - const res = await api.openBookPath(p); - await handleResult(res); - await renderRecents(); - } - - async function renderRecents() { - try { - const recents = await api.getRecents(); - recentList.innerHTML = ""; - if (!recents.length) { - const li = document.createElement("li"); - li.className = "muted"; - li.textContent = "No recent books."; - recentList.appendChild(li); - return; - } - for (const r of recents as (Recent & { missing?: boolean })[]) { - const li = document.createElement("li"); - li.className = "recent-item"; - const label = document.createElement("span"); - label.className = "recent-title"; - label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : ""); - label.title = r.path; - label.addEventListener("mousedown", (e) => { - e.preventDefault(); - void openPath(r.path); - }); - const del = document.createElement("button"); - del.className = "recent-delete icon-button"; - del.type = "button"; - del.title = "Delete book"; - del.setAttribute("aria-label", "Delete book"); - del.textContent = "×"; - del.addEventListener("click", async (e) => { - e.stopPropagation(); - const res = await api.deleteBook(r.path); - if (res && "error" in res) { - await alertMessage("Could not delete", (res as { error: string }).error); - return; - } - await renderRecents(); - // Only promise an in-app Undo where restore actually works (Linux - // home trash). Elsewhere the book sits in the OS trash, which the - // user can restore manually — no false "Undo" button. - if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return; - showUndoToast(`Deleted "${r.title || r.path}"`, async () => { - const rr = await api.restoreBook(r.path); - if (rr && "error" in rr) { - await alertMessage("Could not restore", (rr as { error: string }).error); - return; - } - await renderRecents(); - }); - }); - li.appendChild(label); - li.appendChild(del); - recentList.appendChild(li); - } - } catch { - // Recents are best-effort; never block the welcome screen. - } - } - - // ---- pointer-based reordering ---- - // Native HTML5 drag shows a prohibited (no-drop) cursor on Wayland because the - // compositor controls that cursor and ignores dropEffect. Pointer events let - // us use a normal CSS cursor (grab/grabbing), which Wayland respects. - let draggingId: string | null = null; - let dragPointerId: number | null = null; - let dragStartY = 0; - let dragMoved = false; - let dragJustHappened = false; - - chapterList.addEventListener("pointerdown", (e) => { - if (e.button !== 0) return; // left button only - dragJustHappened = false; - const target = e.target as HTMLElement; - if (target.closest("button, input, a, .row-actions")) return; // don't drag from controls - const li = target.closest("li.chapter-row") as HTMLElement | null; - if (!li) return; - dragPointerId = e.pointerId; - draggingId = li.dataset.id ?? null; - dragStartY = e.clientY; - dragMoved = false; - }); - - document.addEventListener("pointermove", (e) => { - if (dragPointerId === null || draggingId === null) return; - if (!dragMoved) { - if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold - dragMoved = true; - const src = chapterList.querySelector( - `li.chapter-row[data-id="${draggingId}"]` - ) as HTMLElement | null; - src?.classList.add("dragging"); - document.body.classList.add("dragging-active"); - } - const overEl = document.elementFromPoint(e.clientX, e.clientY); - const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; - chapterList - .querySelectorAll(".chapter-row") - .forEach((n) => n.classList.remove("drop-target")); - if (overLi && overLi.dataset.id !== draggingId) overLi.classList.add("drop-target"); - }); - - document.addEventListener("pointerup", (e) => { - if (dragPointerId === null) return; - dragPointerId = null; - document.body.classList.remove("dragging-active"); - if (!dragMoved || !draggingId) { - draggingId = null; - return; - } - dragJustHappened = true; - const overEl = document.elementFromPoint(e.clientX, e.clientY); - const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; - const targetId = overLi?.dataset.id ?? null; - const src = chapterList.querySelector( - `li.chapter-row[data-id="${draggingId}"]` - ) as HTMLElement | null; - src?.classList.remove("dragging"); - chapterList - .querySelectorAll(".chapter-row") - .forEach((n) => n.classList.remove("drop-target")); - if (targetId && targetId !== draggingId) { - const from = ordered.findIndex((c) => c.id === draggingId); - const to = ordered.findIndex((c) => c.id === targetId); - if (from >= 0 && to >= 0) { - ordered = moveInList(ordered, from, to); - meta!.chapterOrder = ordered.map((c) => c.id); - renderChapterList(); - // Selecting the dragged row must go through selectChapter: assigning - // selectedId directly would pair the id with whatever document is - // still in the editor, and the next autosave would write that - // document under the dragged chapter's id. - void selectChapter(draggingId); - void persistOrder(); - } - } - draggingId = null; - }); - - $("welcomeNewBook").addEventListener("click", () => void startNewBook()); - $("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); - const homeBtn = $("homeBtn"); - homeBtn?.addEventListener("click", async () => { - clearTimers(); - if (selectedId && dirty) { - // Auto-save silently instead of prompting, so closing the book never - // loses the chapter's text. - await saveCurrentChapter(); - } - void api.closeBook(); - }); - addChapterBtn.addEventListener("click", () => void create()); - exportBtn.addEventListener("click", () => void exportMarkdown()); - exportZipBtn.addEventListener("click", () => void exportZip()); - fullBookBtn.addEventListener("click", () => void selectFullBook()); - fbPrevBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex - 1)); - fbNextBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex + 1)); - fbChapterSelect.addEventListener("change", () => { - const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value); - if (idx >= 0) jumpToChapter(idx); - }); - function closeExportMenu() { - closeEl(exportMenu); - exportMenuToggle.classList.remove("open"); - } - - exportMenuToggle.addEventListener("click", (e) => { - e.stopPropagation(); - const wasOpen = !exportMenu.classList.contains("hidden"); - closeExportMenu(); - if (!wasOpen) openEl(exportMenu); - exportMenuToggle.classList.toggle("open", wasOpen); - }); - - exportMenu.addEventListener("click", (e) => { - const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null; - if (!item) return; - closeExportMenu(); - void exportFromMenu(item.dataset.export ?? ""); - }); - - document.addEventListener("click", (e) => { - if (exportDropdown && !(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu(); - }); - - // ---- export workflow (whole book vs selected chapters) ---- - const exportModal = $("exportModal") as HTMLDivElement; - const exportFormatLabel = $("exportFormatLabel") as HTMLElement; - const exportChapterList = $("exportChapterList") as HTMLElement; - const exportChapterItems = $("exportChapterItems") as HTMLUListElement; - const exportSelectAll = $("exportSelectAll") as HTMLButtonElement; - const exportClearAll = $("exportClearAll") as HTMLButtonElement; - const exportConfirm = $("exportConfirm") as HTMLButtonElement; - const exportCancel = $("exportCancel") as HTMLButtonElement; - const exportError = $("exportError") as HTMLElement; - let pendingExportFormat: "pdf" | "docx" | "odt" | null = null; - - function openExportWorkflow(format: string) { - if (format !== "pdf" && format !== "docx" && format !== "odt") return; - pendingExportFormat = format; - exportFormatLabel.textContent = format.toUpperCase(); - exportError.classList.add("hidden"); - const allRadio = exportModal.querySelector( - 'input[name="exportScope"][value="all"]' - ); - const selRadio = exportModal.querySelector( - 'input[name="exportScope"][value="selected"]' - ); - if (allRadio) allRadio.checked = true; - if (selRadio) selRadio.checked = false; - exportChapterList.classList.add("hidden"); - // Populate the chapter checklist in canonical order (the `ordered` list). - exportChapterItems.innerHTML = ""; - for (const ch of ordered) { - const li = document.createElement("li"); - li.className = "export-chapter-item"; - const label = document.createElement("label"); - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.value = ch.id; - cb.dataset.id = ch.id; - label.appendChild(cb); - const span = document.createElement("span"); - span.textContent = ch.title; - label.appendChild(span); - li.appendChild(label); - exportChapterItems.appendChild(li); - } - openEl(exportModal); - } - - function closeExportModal() { - closeEl(exportModal); - pendingExportFormat = null; - } - - // Selected ids in canonical order (not the order they were clicked). - function getSelectedChapterIds(): string[] { - const checked = new Set( - Array.from( - exportChapterItems.querySelectorAll('input[type="checkbox"]') - ) - .filter((cb) => cb.checked) - .map((cb) => cb.dataset.id as string) - ); - return ordered.filter((ch) => checked.has(ch.id)).map((ch) => ch.id); - } - - exportModal - .querySelectorAll('input[name="exportScope"]') - .forEach((r) => - r.addEventListener("change", () => { - const selected = exportModal.querySelector( - 'input[name="exportScope"]:checked' - )?.value === "selected"; - exportChapterList.classList.toggle("hidden", !selected); - }) - ); - exportSelectAll.addEventListener("click", () => { - exportChapterItems - .querySelectorAll('input[type="checkbox"]') - .forEach((cb) => (cb.checked = true)); - }); - exportClearAll.addEventListener("click", () => { - exportChapterItems - .querySelectorAll('input[type="checkbox"]') - .forEach((cb) => (cb.checked = false)); - }); - exportCancel.addEventListener("click", closeExportModal); - exportModal.addEventListener("click", (e) => { - if (e.target === exportModal) closeExportModal(); - }); - exportConfirm.addEventListener("click", async () => { - if (!pendingExportFormat) return; - const scope = exportModal.querySelector( - 'input[name="exportScope"]:checked' - )?.value; - let chapterIds: string[] | null = null; - if (scope === "selected") { - chapterIds = getSelectedChapterIds(); - if (chapterIds.length === 0) { - exportError.textContent = "Select at least one chapter to export."; - exportError.classList.remove("hidden"); - return; - } - } - const fmt = pendingExportFormat; - closeExportModal(); - if (fmt === "pdf") await exportPdf(chapterIds); - else if (fmt === "docx") await exportDocx(chapterIds); - else if (fmt === "odt") await exportOdt(chapterIds); - }); - - copyAllBtn.addEventListener("click", async () => { - // Copy the assembled manuscript as native content (the combined ProseMirror - // document), not concatenated Markdown. - const doc = getEditorDoc(fullBookEditor); - const text = JSON.stringify(doc); - if (!text || text === JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content: [] } })) { - fullBookStatus.textContent = "Nothing to copy"; - return; - } - const res = await api.copyText(text); - if (res && "error" in res) { - await alertMessage("Could not copy", (res as { error: string }).error); - return; - } - fullBookStatus.textContent = "Full book copied (native)"; - fullBookStatus.classList.add("unsaved"); - setTimeout(() => { - fullBookStatus.textContent = ""; - fullBookStatus.classList.remove("unsaved"); - }, 3000); - }); - $("revealBtn")?.addEventListener("click", () => { - if (currentBook) void api.revealInFolder(currentBook.path); - }); - - api.onBookOpened((data) => setCurrentBook(data)); - api.onBookClosed(showWelcome); - api.onChaptersChanged(() => void refreshMeta()); - api.onNewBookRequested(() => void startNewBook()); - api.onOpenBookRequested(() => void startOpenBook()); - api.onOpenRecent((p) => void openPath(p)); - api.onRevealRequested(async (p) => { - const ok = await api.revealInFolder(p); - showToast( - ok - ? "Opened your book's folder in the file manager — check the taskbar if you don't see it" - : "Could not open the file manager" - ); - }); - api.onExportMarkdownRequested(() => void exportMarkdown()); - api.onExportZipRequested(() => void exportZip()); - api.onExportPdfRequested(() => void exportPdf()); - api.onExportDocxRequested(() => void exportDocx()); - api.onExportOdtRequested(() => void exportOdt()); - api.onUndoRequested(() => { - if (!fullBookView.classList.contains("hidden")) fullBookEditor.commands.undo(); - else editor.commands.undo(); - }); - api.onRedoRequested(() => { - if (!fullBookView.classList.contains("hidden")) fullBookEditor.commands.redo(); - else editor.commands.redo(); - }); - api.onSelectAllRequested(() => { - if (!fullBookView.classList.contains("hidden")) { - fullBookEditor.commands.focus(); - fullBookEditor.commands.selectAll(); - } else { - editor.commands.selectAll(); - } - }); - api.onSaveRequested(() => void saveAndNotify()); - - // ---- editor zoom (writing view only) ---- - // Zoom scales the document content area (the .editor-panes containers) so the - // sidebar/navigation keep their normal size. The chosen level is persisted - // app-wide and re-applied on launch. - let currentZoom = 100; - function applyZoom(pct: number, persist = true) { - const clamped = Math.max(50, Math.min(300, Math.round(pct))); - currentZoom = clamped; - document.querySelectorAll(".editor-panes").forEach((el) => { - el.style.zoom = String(clamped / 100); - }); - document.querySelectorAll(".zoom-level").forEach((el) => { - el.textContent = clamped + "%"; - }); - if (persist) void api.setZoom(clamped); - } - document.querySelectorAll(".zoom-control").forEach((ctrl) => { - ctrl - .querySelector('[data-zoom="out"]') - ?.addEventListener("click", () => applyZoom(currentZoom - 10)); - ctrl - .querySelector('[data-zoom="in"]') - ?.addEventListener("click", () => applyZoom(currentZoom + 10)); - ctrl - .querySelector('[data-zoom="reset"]') - ?.addEventListener("click", () => applyZoom(100)); - }); - api.onZoomInRequested(() => applyZoom(currentZoom + 10)); - api.onZoomOutRequested(() => applyZoom(currentZoom - 10)); - api.onZoomResetRequested(() => applyZoom(100)); - void (async () => { - try { - const z = await api.getZoom(); - if (typeof z === "number" && z > 0) applyZoom(z, false); - } catch { - /* keep default 100% */ - } - })(); - - // Ctrl/Cmd+S saves the current chapter (in addition to the menu item). - window.addEventListener("keydown", (e) => { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { - e.preventDefault(); - void saveAndNotify(); - } - }); - - // Flush any pending auto-save when the window is hidden or loses focus, so the - // last stretch of typing is persisted even if the app closes within the - // debounce window. - window.addEventListener("pagehide", () => void flushAutosave()); - window.addEventListener("blur", () => void flushAutosave()); - - // ---- writing statistics (reading time + counts) ---- - const statsModal = $("statsModal") as HTMLDivElement; - const statsBody = $("statsBody") as HTMLElement; - const statsBtn = $("statsBtn") as HTMLButtonElement; - const statsClose = $("statsClose") as HTMLButtonElement; - - function computeTextStats(text: string) { - const trimmed = text.trim(); - const words = trimmed ? trimmed.split(/\s+/).length : 0; - const chars = text.length; - const charsNoSpace = text.replace(/\s/g, "").length; - const sentences = trimmed - ? trimmed.split(/[.!?]+/).filter((s) => s.trim().length).length - : 0; - const minutes = words ? Math.max(1, Math.ceil(words / 200)) : 0; - return { words, chars, charsNoSpace, sentences, minutes }; - } - - function openStats(): void { - if (!editor) return; - const curText = editor.getText(); - const cur = computeTextStats(curText); - let paragraphs = 0; - editor.state.doc.forEach((node) => { - if (node.isBlock && node.textContent.trim()) paragraphs++; - }); - void (async () => { - let bookWords = 0; - if (meta) { - const list = await api.getChapterList(); - const chapters = Array.isArray(list) ? list : []; - for (const ch of chapters) { - const res = await api.getChapterContent(ch.id); - const md = typeof res === "string" ? res : ""; - bookWords += countWords(markdownToPlain(md)); - } - } - const bookMinutes = bookWords ? Math.max(1, Math.ceil(bookWords / 200)) : 0; - statsBody.innerHTML = ` -

Current chapter

-
    -
  • Words: ${cur.words}
  • -
  • Characters: ${cur.chars} (${cur.charsNoSpace} without spaces)
  • -
  • Paragraphs: ${paragraphs}
  • -
  • Sentences: ${cur.sentences}
  • -
  • Reading time: ~${cur.minutes} min
  • -
-

Whole book

-
    -
  • Words: ${bookWords}
  • -
  • Reading time: ~${bookMinutes} min
  • -
`; - })(); - openEl(statsModal); - } - function closeStats(): void { - closeEl(statsModal); - } - statsBtn.addEventListener("click", openStats); - statsClose.addEventListener("click", closeStats); - statsModal.addEventListener("click", (e) => { - if (e.target === statsModal) closeStats(); - }); - - // ---- comments / annotations ---- - const commentsModal = $("commentsModal") as HTMLDivElement; - const commentsBody = $("commentsList") as HTMLUListElement; - const commentsChapter = $("commentsChapter") as HTMLElement; - const commentsBtn = $("commentsBtn") as HTMLButtonElement; - const commentsClose = $("commentsClose") as HTMLButtonElement; - const addCommentBtn = $("addCommentBtn") as HTMLButtonElement; - - function chapterComments(id: string | null): ChapterComment[] { - if (!id || !meta || !meta.comments) return []; - return meta.comments[id] || []; - } - - function updateCommentsBadge(): void { - const n = chapterComments(selectedId).length; - commentsBtn.textContent = n ? `💬 ${n}` : "💬"; - } - - function renderComments(): void { - commentsBody.innerHTML = ""; - const id = selectedId; - if (!id) { - commentsBody.innerHTML = `
  • Open a chapter first.
  • `; - return; - } - const list = chapterComments(id); - if (!list.length) { - commentsBody.innerHTML = `
  • No comments in this chapter yet.
  • `; - return; - } - for (const c of list) { - const li = document.createElement("li"); - li.className = "comment-item"; - const quote = document.createElement("div"); - quote.className = "comment-quote"; - quote.textContent = c.quote || "(no selection)"; - const txt = document.createElement("div"); - txt.className = "comment-text"; - txt.textContent = c.text; - const metaRow = document.createElement("div"); - metaRow.className = "comment-meta"; - const go = document.createElement("button"); - go.className = "button button-ghost button-small"; - go.textContent = "Go to"; - go.addEventListener("click", () => { - closeComments(); - void scrollToTerm(c.quote, false); - }); - const del = document.createElement("button"); - del.className = "button button-ghost button-small"; - del.textContent = "Delete"; - del.addEventListener("click", () => void deleteComment(c.id)); - metaRow.appendChild(go); - metaRow.appendChild(del); - li.appendChild(quote); - li.appendChild(txt); - li.appendChild(metaRow); - commentsBody.appendChild(li); - } - } - - async function persistComments(): Promise { - if (!meta) return; - await api.setComments(meta.comments || {}); - } - - async function addComment(): Promise { - if (!selectedId) { - await alertMessage("No chapter", "Open a chapter and select text first."); - return; - } - const quote = selectedText().trim(); - if (!quote) { - await alertMessage("No selection", "Select some text in the chapter to comment on."); - return; - } - const text = await promptInput("Comment", ""); - if (text === null) return; - const comment: ChapterComment = { - id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, - chapterId: selectedId, - quote, - text, - createdAt: new Date().toISOString(), - }; - if (!meta!.comments) meta!.comments = {}; - if (!meta!.comments[selectedId]) meta!.comments[selectedId] = []; - meta!.comments[selectedId].push(comment); - await persistComments(); - renderComments(); - updateCommentsBadge(); - } - - async function deleteComment(id: string): Promise { - if (!selectedId || !meta || !meta.comments) return; - const list = meta.comments[selectedId] || []; - meta.comments[selectedId] = list.filter((c) => c.id !== id); - await persistComments(); - renderComments(); - updateCommentsBadge(); - } - - function openComments(): void { - if (!meta) return; - const title = (selectedId && meta.chapters[selectedId]?.title) || ""; - commentsChapter.textContent = title ? `— ${title}` : ""; - renderComments(); - openEl(commentsModal); - } - function closeComments(): void { - closeEl(commentsModal); - } - commentsBtn.addEventListener("click", openComments); - commentsClose.addEventListener("click", closeComments); - addCommentBtn.addEventListener("click", () => void addComment()); - commentsModal.addEventListener("click", (e) => { - if (e.target === commentsModal) closeComments(); - }); - - setChapterButtons(false); - initSearchUI(); - void showWelcome(); - - (window as any).__folioEditor = editor; - (window as any).__folioFullBookEditor = fullBookEditor; -} diff --git a/src/renderer/book/chapter-list.ts b/src/renderer/book/chapter-list.ts new file mode 100644 index 0000000..3570505 --- /dev/null +++ b/src/renderer/book/chapter-list.ts @@ -0,0 +1,384 @@ +// Seam 5: the chapter sidebar list. +// +// Row rendering, the chapter-color popover, CRUD with undo toasts, canonical +// order persistence, and pointer-based drag reordering. Drag uses pointer +// events rather than native HTML5 DnD: Wayland's compositor controls the +// no-drop cursor and ignores dropEffect, but respects a normal CSS cursor +// (grab/grabbing). +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { BookCtx } from "./types.js"; +import { alertMessage, promptInput } from "../dialog.js"; +import { + $, + closeEl, + moveInList, + openEl, + showUndoToast, +} from "./ui.js"; + +export interface ChapterListFeature { + renderChapterList(): void; + setChapterButtons(enabled: boolean): void; +} + +export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { + const { api, state, hooks } = ctx; + + const chapterList = $("chapterList") as HTMLUListElement; + const addChapterBtn = $("addChapterBtn") as HTMLButtonElement; + + function setChapterButtons(enabled: boolean) { + addChapterBtn.disabled = !enabled; + $("fullBookNav").classList.toggle("hidden", !enabled); + } + + // Predefined palette used for the chapter-color picker (kept in sync with the + // text/highlight palettes so the whole app feels consistent). + const CHAPTER_COLORS = [ + "#e11d48", "#f97316", "#eab308", "#22c55e", + "#3b82f6", "#8b5cf6", "#78716c", "#18181b", + ]; + const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; + + let colorPopover: HTMLDivElement | null = null; + function closeColorPopover() { + closeEl(colorPopover); + } + + function openColorPopover( + anchor: HTMLElement, + current: string | null, + onPick: (c: string | null) => void + ) { + if (!colorPopover) { + colorPopover = document.createElement("div"); + colorPopover.className = "color-popover hidden"; + document.body.appendChild(colorPopover); + } + const pop = colorPopover; + pop.innerHTML = ""; + const grid = document.createElement("div"); + grid.className = "cp-grid"; + for (const c of CHAPTER_COLORS) { + const sw = document.createElement("button"); + sw.type = "button"; + sw.className = "cp-swatch" + (current === c ? " active" : ""); + sw.style.background = c; + sw.title = c; + sw.setAttribute("aria-label", c); + sw.addEventListener("click", (e) => { + e.stopPropagation(); + closeColorPopover(); + onPick(c); + }); + grid.appendChild(sw); + } + pop.appendChild(grid); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "cp-remove"; + remove.textContent = current ? "Remove color" : "No color set"; + remove.disabled = !current; + remove.addEventListener("click", (e) => { + e.stopPropagation(); + closeColorPopover(); + onPick(null); + }); + pop.appendChild(remove); + + openEl(pop); + const pw = pop.offsetWidth; + const ph = pop.offsetHeight; + const rect = anchor.getBoundingClientRect(); + let left = rect.left; + let top = rect.bottom + 4; + if (left + pw > window.innerWidth - 8) { + left = Math.max(8, window.innerWidth - pw - 8); + } + if (top + ph > window.innerHeight - 8) { + top = Math.max(8, rect.top - ph - 4); + } + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; + } + + // Click outside the popover closes it (the open button stops propagation so it + // doesn't immediately self-close). + document.addEventListener("click", (e) => { + if (colorPopover && !colorPopover.classList.contains("hidden") && + !colorPopover.contains(e.target as Node)) { + closeColorPopover(); + } + }); + window.addEventListener("scroll", closeColorPopover, true); + window.addEventListener("resize", closeColorPopover); + + async function applyChapterColor(id: string, color: string | null) { + const res = await api.setChapterColor(id, color); + if (res && "error" in res) { + await alertMessage("Could not set chapter color", (res as { error: string }).error); + return; + } + const entry = state.ordered.find((c) => c.id === id); + if (entry) { + if (color === null) delete entry.color; + else entry.color = color; + } + if (state.meta && state.meta.chapters[id]) { + if (color === null) delete state.meta.chapters[id].color; + else state.meta.chapters[id].color = color; + } + renderChapterList(); + } + + function renderChapterList() { + chapterList.innerHTML = ""; + for (const ch of state.ordered) { + const li = document.createElement("li"); + li.className = "chapter-row" + (state.selectedId === ch.id ? " active" : ""); + li.dataset.id = ch.id; + li.draggable = false; + li.tabIndex = -1; + li.innerHTML = ` + ⋮ + ${ch.title} + + + + + + `; + if (ch.color && HEX_RE.test(ch.color)) { + const dot = document.createElement("span"); + dot.className = "row-color-dot"; + dot.style.background = ch.color; + dot.title = "Chapter color: " + ch.color; + li.insertBefore(dot, li.querySelector(".row-title")); + } + li.addEventListener("click", (e) => { + if (dragJustHappened) { + dragJustHappened = false; + e.stopPropagation(); + return; + } + e.stopPropagation(); + li.focus(); + void hooks.selectChapter(ch.id); + }); + li.querySelector('[data-act="color"]')?.addEventListener("click", (e) => { + e.stopPropagation(); + openColorPopover(li, ch.color ?? null, (c) => void applyChapterColor(ch.id, c)); + }); + li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => { + e.stopPropagation(); + void rename(ch.id); + }); + li.querySelector('[data-act="dup"]')?.addEventListener("click", (e) => { + e.stopPropagation(); + void duplicate(ch.id); + }); + li.querySelector('[data-act="del"]')?.addEventListener("click", (e) => { + e.stopPropagation(); + void del(ch.id); + }); + li.addEventListener("keydown", (e) => { + if (!e.altKey) return; + if (e.key === "ArrowDown") { e.preventDefault(); moveSelectedDown(); } + else if (e.key === "ArrowUp") { e.preventDefault(); moveSelectedUp(); } + }); + chapterList.appendChild(li); + } + } + + async function create() { + if (!state.meta) return; + const count = state.meta.chapterOrder.length + 1; + const title = await promptInput("New chapter", `Chapter ${count}`); + if (!title) return; + const res = await api.createChapter(title); + if (res && "error" in res) await alertMessage("Could not create chapter", (res as { error: string }).error); + else { + await hooks.refreshMeta(); + if (res && "id" in res) void hooks.selectChapter((res as { id: string }).id); + } + } + + async function rename(id: string) { + if (!state.meta || !state.meta.chapters[id]) return; + const entry = state.meta.chapters[id]; + const title = await promptInput("Rename chapter", entry.title); + if (!title) return; + const res = await api.renameChapter(id, title); + if (res && "error" in res) await alertMessage("Could not rename chapter", (res as { error: string }).error); + else { + await hooks.refreshMeta(); + if (state.selectedId === id && state.meta && state.meta.chapters[id]) { + $("chapterTitle").textContent = state.meta.chapters[id].title; + } + } + } + + async function del(id: string) { + if (!state.meta || !state.meta.chapters[id]) return; + const entry = state.meta.chapters[id]; + const index = state.meta.chapterOrder.indexOf(id); + // Flush unsaved edits before snapshotting: the undo snapshot reads from + // disk, so text typed since the last autosave would otherwise be lost + // even through Undo. Also stop a pending autosave timer from firing + // while the chapter is being removed. + if (state.selectedId === id) { + hooks.clearTimers(); + if (state.dirty) await hooks.saveCurrentChapter(); + } + const content = (await api.getChapterContent(id)) as string; + const res = await api.deleteChapter(id); + if (res && "error" in res) { + await alertMessage("Could not delete chapter", (res as { error: string }).error); + return; + } + const wasSelected = state.selectedId === id; + if (wasSelected) { state.selectedId = null; hooks.showPlaceholder(); } + await hooks.refreshMeta(); + showUndoToast(`Deleted "${entry.title}"`, async () => { + const r = await api.restoreChapter({ + id, + title: entry.title, + color: entry.color ?? null, + content, + index, + }); + if (r && "error" in r) { + await alertMessage("Could not restore chapter", (r as { error: string }).error); + return; + } + await hooks.refreshMeta(); + if (wasSelected) await hooks.selectChapter(id); + }); + } + + async function duplicate(id: string) { + if (!state.meta || !state.meta.chapters[id]) return; + const entry = state.meta.chapters[id]; + const title = await promptInput("Duplicate as", `${entry.title} (copy)`); + if (title === null) return; + const res = await api.duplicateChapter(id, title || undefined); + if (res && "error" in res) await alertMessage("Could not duplicate chapter", (res as { error: string }).error); + else if (res && "id" in res) { + await hooks.refreshMeta(); + void hooks.selectChapter((res as { id: string }).id); + } + } + + async function persistOrder() { + if (!state.meta) return; + state.meta.chapterOrder = state.ordered.map((c) => c.id); + const res = await api.reorderChapters(state.meta.chapterOrder); + if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error); + else renderChapterList(); + } + + function moveSelectedDown() { + if (!state.selectedId) return; + const i = state.ordered.findIndex((c) => c.id === state.selectedId); + if (i < 0 || i >= state.ordered.length - 1) return; + state.ordered = moveInList(state.ordered, i, i + 1); + state.meta!.chapterOrder = state.ordered.map((c) => c.id); + renderChapterList(); + void persistOrder(); + } + + function moveSelectedUp() { + if (!state.selectedId) return; + const i = state.ordered.findIndex((c) => c.id === state.selectedId); + if (i <= 0) return; + state.ordered = moveInList(state.ordered, i, i - 1); + state.meta!.chapterOrder = state.ordered.map((c) => c.id); + renderChapterList(); + void persistOrder(); + } + + // ---- pointer-based reordering ---- + let draggingId: string | null = null; + let dragPointerId: number | null = null; + let dragStartY = 0; + let dragMoved = false; + let dragJustHappened = false; + + chapterList.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; // left button only + dragJustHappened = false; + const target = e.target as HTMLElement; + if (target.closest("button, input, a, .row-actions")) return; // don't drag from controls + const li = target.closest("li.chapter-row") as HTMLElement | null; + if (!li) return; + dragPointerId = e.pointerId; + draggingId = li.dataset.id ?? null; + dragStartY = e.clientY; + dragMoved = false; + }); + + document.addEventListener("pointermove", (e) => { + if (dragPointerId === null || draggingId === null) return; + if (!dragMoved) { + if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold + dragMoved = true; + const src = chapterList.querySelector( + `li.chapter-row[data-id="${draggingId}"]` + ) as HTMLElement | null; + src?.classList.add("dragging"); + document.body.classList.add("dragging-active"); + } + const overEl = document.elementFromPoint(e.clientX, e.clientY); + const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; + chapterList + .querySelectorAll(".chapter-row") + .forEach((n) => n.classList.remove("drop-target")); + if (overLi && overLi.dataset.id !== draggingId) overLi.classList.add("drop-target"); + }); + + document.addEventListener("pointerup", (e) => { + if (dragPointerId === null) return; + dragPointerId = null; + document.body.classList.remove("dragging-active"); + if (!dragMoved || !draggingId) { + draggingId = null; + return; + } + dragJustHappened = true; + const overEl = document.elementFromPoint(e.clientX, e.clientY); + const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; + const targetId = overLi?.dataset.id ?? null; + const src = chapterList.querySelector( + `li.chapter-row[data-id="${draggingId}"]` + ) as HTMLElement | null; + src?.classList.remove("dragging"); + chapterList + .querySelectorAll(".chapter-row") + .forEach((n) => n.classList.remove("drop-target")); + if (targetId && targetId !== draggingId) { + const from = state.ordered.findIndex((c) => c.id === draggingId); + const to = state.ordered.findIndex((c) => c.id === targetId); + if (from >= 0 && to >= 0) { + state.ordered = moveInList(state.ordered, from, to); + state.meta!.chapterOrder = state.ordered.map((c) => c.id); + renderChapterList(); + // Selecting the dragged row must go through selectChapter: assigning + // selectedId directly would pair the id with whatever document is + // still in the editor, and the next autosave would write that + // document under the dragged chapter's id. + void hooks.selectChapter(draggingId); + void persistOrder(); + } + } + draggingId = null; + }); + + addChapterBtn.addEventListener("click", () => void create()); + + return { + renderChapterList, + setChapterButtons, + }; +} diff --git a/src/renderer/book/comments.ts b/src/renderer/book/comments.ts new file mode 100644 index 0000000..cfd02a2 --- /dev/null +++ b/src/renderer/book/comments.ts @@ -0,0 +1,143 @@ +// Comments / annotations modal. +// +// Lists per-chapter comments, adds a comment from the current editor +// selection, deletes comments, and offers "Go to" navigation through the +// search feature's scrollToTerm hook. +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { Editor } from "@tiptap/core"; +import type { BookCtx } from "./types.js"; +import type { ChapterComment } from "../../main/project.js"; +import { alertMessage, promptInput } from "../dialog.js"; +import { $, closeEl, openEl, selectedText } from "./ui.js"; + +export interface CommentsFeature { + updateCommentsBadge(): void; +} + +export function createCommentsFeature(ctx: BookCtx, editor: Editor): CommentsFeature { + const { api, state, hooks } = ctx; + + const commentsModal = $("commentsModal") as HTMLDivElement; + const commentsBody = $("commentsList") as HTMLUListElement; + const commentsChapter = $("commentsChapter") as HTMLElement; + const commentsBtn = $("commentsBtn") as HTMLButtonElement; + const commentsClose = $("commentsClose") as HTMLButtonElement; + const addCommentBtn = $("addCommentBtn") as HTMLButtonElement; + + function chapterComments(id: string | null): ChapterComment[] { + if (!id || !state.meta || !state.meta.comments) return []; + return state.meta.comments[id] || []; + } + + function updateCommentsBadge(): void { + const n = chapterComments(state.selectedId).length; + commentsBtn.textContent = n ? `💬 ${n}` : "💬"; + } + + function renderComments(): void { + commentsBody.innerHTML = ""; + const id = state.selectedId; + if (!id) { + commentsBody.innerHTML = `
  • Open a chapter first.
  • `; + return; + } + const list = chapterComments(id); + if (!list.length) { + commentsBody.innerHTML = `
  • No comments in this chapter yet.
  • `; + return; + } + for (const c of list) { + const li = document.createElement("li"); + li.className = "comment-item"; + const quote = document.createElement("div"); + quote.className = "comment-quote"; + quote.textContent = c.quote || "(no selection)"; + const txt = document.createElement("div"); + txt.className = "comment-text"; + txt.textContent = c.text; + const metaRow = document.createElement("div"); + metaRow.className = "comment-meta"; + const go = document.createElement("button"); + go.className = "button button-ghost button-small"; + go.textContent = "Go to"; + go.addEventListener("click", () => { + closeComments(); + void hooks.scrollToTerm(c.quote, false); + }); + const del = document.createElement("button"); + del.className = "button button-ghost button-small"; + del.textContent = "Delete"; + del.addEventListener("click", () => void deleteComment(c.id)); + metaRow.appendChild(go); + metaRow.appendChild(del); + li.appendChild(quote); + li.appendChild(txt); + li.appendChild(metaRow); + commentsBody.appendChild(li); + } + } + + async function persistComments(): Promise { + if (!state.meta) return; + await api.setComments(state.meta.comments || {}); + } + + async function addComment(): Promise { + if (!state.selectedId) { + await alertMessage("No chapter", "Open a chapter and select text first."); + return; + } + const quote = selectedText(editor).trim(); + if (!quote) { + await alertMessage("No selection", "Select some text in the chapter to comment on."); + return; + } + const text = await promptInput("Comment", ""); + if (text === null) return; + const comment: ChapterComment = { + id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + chapterId: state.selectedId, + quote, + text, + createdAt: new Date().toISOString(), + }; + if (!state.meta!.comments) state.meta!.comments = {}; + if (!state.meta!.comments[state.selectedId]) state.meta!.comments[state.selectedId] = []; + state.meta!.comments[state.selectedId].push(comment); + await persistComments(); + renderComments(); + updateCommentsBadge(); + } + + async function deleteComment(id: string): Promise { + if (!state.selectedId || !state.meta || !state.meta.comments) return; + const list = state.meta.comments[state.selectedId] || []; + state.meta.comments[state.selectedId] = list.filter((c) => c.id !== id); + await persistComments(); + renderComments(); + updateCommentsBadge(); + } + + function openComments(): void { + if (!state.meta) return; + const title = (state.selectedId && state.meta.chapters[state.selectedId]?.title) || ""; + commentsChapter.textContent = title ? `— ${title}` : ""; + renderComments(); + openEl(commentsModal); + } + function closeComments(): void { + closeEl(commentsModal); + } + commentsBtn.addEventListener("click", openComments); + commentsClose.addEventListener("click", closeComments); + addCommentBtn.addEventListener("click", () => void addComment()); + commentsModal.addEventListener("click", (e) => { + if (e.target === commentsModal) closeComments(); + }); + + return { + updateCommentsBadge, + }; +} diff --git a/src/renderer/book/editor.ts b/src/renderer/book/editor.ts new file mode 100644 index 0000000..9e85ed4 --- /dev/null +++ b/src/renderer/book/editor.ts @@ -0,0 +1,312 @@ +// The chapter editing surface: main Tiptap editor instance, dirty-tracking + +// autosave engine, chapter selection (with stale-load guard), zoom, and the +// writing-statistics modal. +// +// Storage note: state.dirty / state.loadedDocJson are the shared contract +// fields; until Step 7 they are backed by the orchestrator's closure locals +// through the accessor bridge, so writes here are visible to every module. + +import type { Editor } from "@tiptap/core"; +import type { BookCtx } from "./types.js"; +import { alertMessage } from "../dialog.js"; +import { + $, + closeEl, + countWords, + formatWordCount, + markdownToPlain, + openEl, + setActive, + showToast, +} from "./ui.js"; +import { + createTiptapEditor, + setEditorDoc, + getEditorDoc, + getFormatState, +} from "../tiptap-editor.js"; + +export interface EditorFeature { + editor: Editor; + selectChapter(id: string): Promise; + saveCurrentChapter(): Promise; + saveAndNotify(): Promise; + flushAutosave(): Promise; + clearTimers(): void; +} + +export function createEditorFeature(ctx: BookCtx): EditorFeature { + const { api, state, hooks } = ctx; + + // Native rich-text editor (Tiptap / ProseMirror). This is the editing surface + // for a single chapter; its state is native JSON, never Markdown. + const editor = createTiptapEditor($("editorHost"), { + onChange: () => handleEditorChange(), + onSelectionUpdate: () => handleSelection(), + }); + + // Debounce for automatic saves while typing. When the user stops typing for + // this long the chapter is persisted in the background so navigating away + // (or closing the book) never loses text. + const AUTOSAVE_MS = 1000; + let saveTimer: ReturnType | null = null; + const saveStateEl = $("saveState"); + + function clearTimers() { + if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + } + + async function saveCurrentChapter(): Promise { + if (!state.selectedId) return; + clearTimers(); + const res = await api.saveChapterDoc(state.selectedId, getEditorDoc(editor)); + if (!(res && "error" in res)) { + state.loadedDocJson = JSON.stringify(getEditorDoc(editor)); + state.dirty = false; + saveStateEl.textContent = "Saved"; + saveStateEl.classList.remove("unsaved"); + } + } + + // Explicit save (Ctrl/Cmd+S or menu) — always give visible feedback. + async function saveAndNotify(): Promise { + if (!state.selectedId) { + if (hooks.isFullBookVisible?.()) { + showToast("All chapters saved"); + return; + } + showToast(state.currentBook ? "Nothing to save" : "Nothing to save"); + return; + } + await saveCurrentChapter(); + showToast("Saved"); + } + + // Guards against overlapping selections: rapid clicks (or metadata + // refreshes) can interleave loads; only the newest one may commit state. + let selectToken = 0; + async function selectChapter(id: string) { + clearTimers(); + if (state.selectedId && state.dirty) { + // Auto-save silently so navigating away never loses text and returning to + // this chapter shows what was just written. The save targets the + // chapter still selected right now, so it happens before reassignment. + await saveCurrentChapter(); + } + const token = ++selectToken; + let res: { doc?: never; error?: string } | null = null; + try { + res = await api.loadChapterDoc(id); + } catch { + res = null; + } + // A newer selection superseded this one: drop the stale reply instead of + // pairing its document with the wrong chapter id. + if (token !== selectToken) return; + if (!(res && "doc" in res)) { + // Load failed. Keep the current editor contents and selection intact — + // seeding an empty document here would let the next autosave overwrite + // the real chapter file with near-empty content. + const msg = res && "error" in res ? String(res.error) : "The chapter file could not be read."; + void alertMessage("Couldn't open chapter", msg); + return; + } + state.selectedId = id; + hooks.renderChapterList(); + hooks.updateCommentsBadge(); + const doc = (res as { doc: never }).doc; + const title = (state.meta && state.meta.chapters[id]?.title) || id; + $("chapterTitle").textContent = title; + setEditorDoc(editor, doc); + state.loadedDocJson = JSON.stringify(getEditorDoc(editor)); + state.dirty = false; + saveStateEl.textContent = "Saved"; + saveStateEl.classList.remove("unsaved"); + $("wordCount").textContent = formatWordCount(editor.getText()); + editor.commands.focus("end"); + clearTimers(); + $("fullBookBtn").classList.remove("active"); + $("fullBookView").classList.add("hidden"); + $("chapterView").classList.remove("hidden"); + $("editorPlaceholder").classList.add("hidden"); + } + + // Schedule a background save a short time after the last edit. The indicator + // shows "Saving…" while the debounce is pending so the user knows the chapter + // is about to be persisted automatically. + function scheduleAutosave() { + clearTimers(); + saveStateEl.textContent = "Saving…"; + saveStateEl.classList.remove("unsaved"); + saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS); + } + + // Persist the current chapter if it has unsaved edits. Safe to call any time; + // does nothing once the chapter is already clean. + async function flushAutosave() { + saveTimer = null; + if (!state.selectedId || !state.dirty) return; + await saveCurrentChapter(); + } + + function handleEditorChange() { + if (!state.selectedId) return; + const json = JSON.stringify(getEditorDoc(editor)); + state.dirty = json !== state.loadedDocJson; + saveStateEl.textContent = state.dirty ? "Unsaved" : "Saved"; + saveStateEl.classList.toggle("unsaved", state.dirty); + $("wordCount").textContent = formatWordCount(editor.getText()); + if (state.dirty) scheduleAutosave(); + } + + function handleSelection() { + const headingSelect = $("headingSelect") as HTMLSelectElement; + const fontSizeSelect = $("fontSizeSelect") as HTMLSelectElement; + const lineSpacingSelect = $("lineSpacingSelect") as HTMLSelectElement; + const fmt = getFormatState(editor); + headingSelect.value = String(fmt.heading); + if (fontSizeSelect) fontSizeSelect.value = fmt.fontSize || "16px"; + if (lineSpacingSelect) lineSpacingSelect.value = fmt.lineHeight || "1.0"; + setActive("bold", fmt.bold); + setActive("italic", fmt.italic); + setActive("strike", fmt.strike); + setActive("underline", fmt.underline); + setActive("highlight", editor.isActive("highlight")); + setActive("code", fmt.code); + setActive("h2", fmt.heading === 2); + setActive("h3", fmt.heading === 3); + setActive("link", fmt.link); + setActive("bullet", fmt.bullet); + setActive("number", fmt.ordered); + setActive("checklist", fmt.task); + setActive("quote", fmt.blockquote); + setActive("align-left", fmt.align === "left" || fmt.align === ""); + setActive("align-center", fmt.align === "center"); + setActive("align-right", fmt.align === "right"); + } + + // Ctrl/Cmd+S saves the current chapter (in addition to the menu item). + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + void saveAndNotify(); + } + }); + + // Flush any pending auto-save when the window is hidden or loses focus, so the + // last stretch of typing is persisted even if the app closes within the + // debounce window. + window.addEventListener("pagehide", () => void flushAutosave()); + window.addEventListener("blur", () => void flushAutosave()); + + // ---- editor zoom (writing view only) ---- + // Zoom scales the document content area (the .editor-panes containers) so the + // sidebar/navigation keep their normal size. The chosen level is persisted + // app-wide and re-applied on launch. + let currentZoom = 100; + function applyZoom(pct: number, persist = true) { + const clamped = Math.max(50, Math.min(300, Math.round(pct))); + currentZoom = clamped; + document.querySelectorAll(".editor-panes").forEach((el) => { + el.style.zoom = String(clamped / 100); + }); + document.querySelectorAll(".zoom-level").forEach((el) => { + el.textContent = clamped + "%"; + }); + if (persist) void api.setZoom(clamped); + } + document.querySelectorAll(".zoom-control").forEach((ctrl) => { + ctrl + .querySelector('[data-zoom="out"]') + ?.addEventListener("click", () => applyZoom(currentZoom - 10)); + ctrl + .querySelector('[data-zoom="in"]') + ?.addEventListener("click", () => applyZoom(currentZoom + 10)); + ctrl + .querySelector('[data-zoom="reset"]') + ?.addEventListener("click", () => applyZoom(100)); + }); + api.onZoomInRequested(() => applyZoom(currentZoom + 10)); + api.onZoomOutRequested(() => applyZoom(currentZoom - 10)); + api.onZoomResetRequested(() => applyZoom(100)); + void (async () => { + try { + const z = await api.getZoom(); + if (typeof z === "number" && z > 0) applyZoom(z, false); + } catch { + /* keep default 100% */ + } + })(); + + // ---- writing statistics (reading time + counts) ---- + const statsModal = $("statsModal") as HTMLDivElement; + const statsBody = $("statsBody") as HTMLElement; + const statsBtn = $("statsBtn") as HTMLButtonElement; + const statsClose = $("statsClose") as HTMLButtonElement; + + function computeTextStats(text: string) { + const trimmed = text.trim(); + const words = trimmed ? trimmed.split(/\s+/).length : 0; + const chars = text.length; + const charsNoSpace = text.replace(/\s/g, "").length; + const sentences = trimmed + ? trimmed.split(/[.!?]+/).filter((s) => s.trim().length).length + : 0; + const minutes = words ? Math.max(1, Math.ceil(words / 200)) : 0; + return { words, chars, charsNoSpace, sentences, minutes }; + } + + function openStats(): void { + const curText = editor.getText(); + const cur = computeTextStats(curText); + let paragraphs = 0; + editor.state.doc.forEach((node) => { + if (node.isBlock && node.textContent.trim()) paragraphs++; + }); + void (async () => { + let bookWords = 0; + if (state.meta) { + const list = await api.getChapterList(); + const chapters = Array.isArray(list) ? list : []; + for (const ch of chapters) { + const res = await api.getChapterContent(ch.id); + const md = typeof res === "string" ? res : ""; + bookWords += countWords(markdownToPlain(md)); + } + } + const bookMinutes = bookWords ? Math.max(1, Math.ceil(bookWords / 200)) : 0; + statsBody.innerHTML = ` +

    Current chapter

    +
      +
    • Words: ${cur.words}
    • +
    • Characters: ${cur.chars} (${cur.charsNoSpace} without spaces)
    • +
    • Paragraphs: ${paragraphs}
    • +
    • Sentences: ${cur.sentences}
    • +
    • Reading time: ~${cur.minutes} min
    • +
    +

    Whole book

    +
      +
    • Words: ${bookWords}
    • +
    • Reading time: ~${bookMinutes} min
    • +
    `; + })(); + openEl(statsModal); + } + function closeStats(): void { + closeEl(statsModal); + } + statsBtn.addEventListener("click", openStats); + statsClose.addEventListener("click", closeStats); + statsModal.addEventListener("click", (e) => { + if (e.target === statsModal) closeStats(); + }); + + return { + editor, + selectChapter, + saveCurrentChapter, + saveAndNotify, + flushAutosave, + clearTimers, + }; +} diff --git a/src/renderer/book/exports.ts b/src/renderer/book/exports.ts new file mode 100644 index 0000000..cb243a9 --- /dev/null +++ b/src/renderer/book/exports.ts @@ -0,0 +1,266 @@ +// Export workflows: Markdown, zip bundle, PDF/DOCX/ODT (with whole-book vs +// selected-chapters scope modal), menu routing, and native-format migration. +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { BookCtx } from "./types.js"; +import { alertMessage, confirmMessage } from "../dialog.js"; +import { $, basename, closeEl, openEl } from "./ui.js"; + +export interface ExportsFeature { + exportMarkdown(): void; + exportZip(): void; + exportPdf(chapterIds?: string[] | null): Promise; + exportDocx(chapterIds?: string[] | null): Promise; + exportOdt(chapterIds?: string[] | null): Promise; + migrateBookNative(): Promise; + exportFromMenu(kind: string): Promise; +} + +export function createExportsFeature(ctx: BookCtx): ExportsFeature { + const { api, state, hooks } = ctx; + + function status(text: string, ms = 3000) { + const saveStateEl = $("saveState"); + saveStateEl.textContent = text; + saveStateEl.classList.add("unsaved"); + setTimeout(() => { + saveStateEl.textContent = state.dirty ? "Unsaved" : "Saved"; + saveStateEl.classList.toggle("unsaved", state.dirty); + }, ms); + } + + function exportMarkdown(): void { + if (!state.currentBook) return; + void (async () => { + const res = await api.exportMarkdown(); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); + else if (res && "filePath" in res) status(`Exported to ${basename(res.filePath as string)}`); + else if (res && "canceled" in res) status("Export canceled"); + })(); + } + + function exportZip(): void { + if (!state.currentBook) return; + void (async () => { + const res = await api.exportZip(); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); + else if (res && "filePath" in res) status(`Bundle saved to ${basename(res.filePath as string)}`); + else if (res && "canceled" in res) status("Export canceled"); + })(); + } + + // Export the assembled book as a PDF. With no chapter selection it prints the + // whole book (jumping to the Full Book view first if needed); with a selection + // it renders just those chapters into the print container before printing. + async function exportPdf(chapterIds?: string[] | null): Promise { + if (!state.currentBook) return; + if (chapterIds && chapterIds.length) { + await hooks.renderChaptersToPrint(chapterIds); + } else { + if (!hooks.isFullBookVisible()) await hooks.selectFullBook(); + } + const res = await api.exportPdf(chapterIds && chapterIds.length ? chapterIds : undefined); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); + else if (res && "canceled" in res) hooks.fullBookNotice("Export canceled"); + else if (res && "filePath" in res) hooks.fullBookNotice(`PDF saved to ${basename(res.filePath as string)}`); + } + + async function exportDocx(chapterIds?: string[] | null): Promise { + if (!state.currentBook) return; + const res = await api.exportDocx(chapterIds && chapterIds.length ? chapterIds : undefined); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); + else if (res && "canceled" in res) hooks.fullBookNotice("Export canceled"); + else if (res && "filePath" in res) hooks.fullBookNotice(`DOCX saved to ${basename(res.filePath as string)}`); + } + + async function exportOdt(chapterIds?: string[] | null): Promise { + if (!state.currentBook) return; + const res = await api.exportOdt(chapterIds && chapterIds.length ? chapterIds : undefined); + if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); + else if (res && "canceled" in res) hooks.fullBookNotice("Export canceled"); + else if (res && "filePath" in res) hooks.fullBookNotice(`ODT saved to ${basename(res.filePath as string)}`); + } + + async function exportFromMenu(kind: string): Promise { + if (kind === "native") { + await migrateBookNative(); + return; + } + // PDF/DOCX/ODT open the scope/selection workflow so the user can export the + // whole book or just selected chapters. + if (kind === "pdf" || kind === "docx" || kind === "odt") { + openExportWorkflow(kind); + } + } + + // Explicitly migrate the whole book to the native document format. The main + // process performs a recovery backup and leaves the original .md files in + // place; after this, chapters are stored as native JSON but the (still + // Markdown-based) editor and exports keep working via the format bridge. + async function migrateBookNative(): Promise { + if (!state.currentBook) return; + const confirmed = await confirmMessage( + "Migrate this book to the native document format?", + "Your original Markdown files are kept and a recovery backup is created in ~/Folio-Recovery. This only changes how chapters are stored — nothing else." + ); + if (!confirmed) return; + const res = await api.migrateBook(); + if (res && "error" in res) { + await alertMessage("Migration failed", (res as { error: string }).error); + return; + } + await hooks.refreshMeta(); + if (hooks.isFullBookVisible()) await hooks.refreshFullBook(); + hooks.fullBookNotice("Book migrated to native format"); + } + + // ---- toolbar buttons + dropdown menu ---- + $("exportBtn").addEventListener("click", () => void exportMarkdown()); + $("exportZipBtn").addEventListener("click", () => void exportZip()); + + const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement; + const exportMenu = $("exportMenu"); + + function closeExportMenu() { + closeEl(exportMenu); + exportMenuToggle.classList.remove("open"); + } + + exportMenuToggle.addEventListener("click", (e) => { + e.stopPropagation(); + const wasOpen = !exportMenu.classList.contains("hidden"); + closeExportMenu(); + if (!wasOpen) openEl(exportMenu); + exportMenuToggle.classList.toggle("open", wasOpen); + }); + + exportMenu.addEventListener("click", (e) => { + const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null; + if (!item) return; + closeExportMenu(); + void exportFromMenu(item.dataset.export ?? ""); + }); + + document.addEventListener("click", (e) => { + if (!(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu(); + }); + + // ---- export workflow (whole book vs selected chapters) ---- + const exportModal = $("exportModal") as HTMLDivElement; + const exportFormatLabel = $("exportFormatLabel") as HTMLElement; + const exportChapterList = $("exportChapterList") as HTMLElement; + const exportChapterItems = $("exportChapterItems") as HTMLUListElement; + const exportSelectAll = $("exportSelectAll") as HTMLButtonElement; + const exportClearAll = $("exportClearAll") as HTMLButtonElement; + const exportConfirm = $("exportConfirm") as HTMLButtonElement; + const exportCancel = $("exportCancel") as HTMLButtonElement; + const exportError = $("exportError") as HTMLElement; + let pendingExportFormat: "pdf" | "docx" | "odt" | null = null; + + function openExportWorkflow(format: string) { + if (format !== "pdf" && format !== "docx" && format !== "odt") return; + pendingExportFormat = format; + exportFormatLabel.textContent = format.toUpperCase(); + exportError.classList.add("hidden"); + const allRadio = exportModal.querySelector( + 'input[name="exportScope"][value="all"]' + ); + const selRadio = exportModal.querySelector( + 'input[name="exportScope"][value="selected"]' + ); + if (allRadio) allRadio.checked = true; + if (selRadio) selRadio.checked = false; + exportChapterList.classList.add("hidden"); + // Populate the chapter checklist in canonical order (the `ordered` list). + exportChapterItems.innerHTML = ""; + for (const ch of state.ordered) { + const li = document.createElement("li"); + li.className = "export-chapter-item"; + const label = document.createElement("label"); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.value = ch.id; + cb.dataset.id = ch.id; + label.appendChild(cb); + const span = document.createElement("span"); + span.textContent = ch.title; + label.appendChild(span); + li.appendChild(label); + exportChapterItems.appendChild(li); + } + openEl(exportModal); + } + + function closeExportModal() { + closeEl(exportModal); + pendingExportFormat = null; + } + + // Selected ids in canonical order (not the order they were clicked). + function getSelectedChapterIds(): string[] { + const checked = new Set( + Array.from( + exportChapterItems.querySelectorAll('input[type="checkbox"]') + ) + .filter((cb) => cb.checked) + .map((cb) => cb.dataset.id as string) + ); + return state.ordered.filter((ch) => checked.has(ch.id)).map((ch) => ch.id); + } + + exportModal + .querySelectorAll('input[name="exportScope"]') + .forEach((r) => + r.addEventListener("change", () => { + const selected = + exportModal.querySelector('input[name="exportScope"]:checked') + ?.value === "selected"; + exportChapterList.classList.toggle("hidden", !selected); + }) + ); + exportSelectAll.addEventListener("click", () => { + exportChapterItems + .querySelectorAll('input[type="checkbox"]') + .forEach((cb) => (cb.checked = true)); + }); + exportClearAll.addEventListener("click", () => { + exportChapterItems + .querySelectorAll('input[type="checkbox"]') + .forEach((cb) => (cb.checked = false)); + }); + exportCancel.addEventListener("click", closeExportModal); + exportModal.addEventListener("click", (e) => { + if (e.target === exportModal) closeExportModal(); + }); + exportConfirm.addEventListener("click", async () => { + if (!pendingExportFormat) return; + const scope = exportModal.querySelector( + 'input[name="exportScope"]:checked' + )?.value; + let chapterIds: string[] | null = null; + if (scope === "selected") { + chapterIds = getSelectedChapterIds(); + if (chapterIds.length === 0) { + exportError.textContent = "Select at least one chapter to export."; + exportError.classList.remove("hidden"); + return; + } + } + const fmt = pendingExportFormat; + closeExportModal(); + if (fmt === "pdf") await exportPdf(chapterIds); + else if (fmt === "docx") await exportDocx(chapterIds); + else if (fmt === "odt") await exportOdt(chapterIds); + }); + + return { + exportMarkdown, + exportZip, + exportPdf, + exportDocx, + exportOdt, + migrateBookNative, + exportFromMenu, + }; +} diff --git a/src/renderer/book/full-book.ts b/src/renderer/book/full-book.ts new file mode 100644 index 0000000..bf2da1d --- /dev/null +++ b/src/renderer/book/full-book.ts @@ -0,0 +1,297 @@ +// Seam 3: the read-only assembled Full Book view. +// +// Renders every chapter's native document back-to-back in a single ProseMirror +// surface, separated by ChapterBreak nodes. Those nodes carry the chapter id as +// a data attribute so navigation can scroll to them via the DOM, and render the +// on-screen separator / print page break. Navigation state tracks every chapter +// in canonical order along with its DOM anchor — the same boundary data that +// drives page breaks, so navigation can never drift from the assembled order. +// The current chapter is kept in sync with the scroll position (manual +// scrolling updates the selector too). +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { Editor } from "@tiptap/core"; +import type { FolioNode } from "../../shared/native-doc-core.js"; +import type { BookMeta } from "../../main/project.js"; +import type { WikilinkTarget } from "../wikilinks.js"; +import type { BookCtx } from "./types.js"; +import { alertMessage } from "../dialog.js"; +import { $, countWords } from "./ui.js"; +import { + createTiptapEditor, + setEditorDoc, + getEditorDoc, + ChapterBreak, +} from "../tiptap-editor.js"; +import { + buildFullBookDoc, + nativeNodeText, +} from "../native-assembly.js"; +import { renderPrintChaptersNative } from "../print-format.js"; + +export interface FullBookFeature { + fullBookEditor: Editor; + isFullBookVisible(): boolean; + refreshFullBook(): Promise; + selectFullBook(): Promise; + renderChaptersToPrint(ids: string[]): Promise; + resetFullBookNav(): void; + fullBookNotice(text: string): void; +} + +export function createFullBookFeature(ctx: BookCtx): FullBookFeature { + const { api, state, hooks } = ctx; + + const fullBookView = $("fullBookView"); + const fullBookHost = $("fullBookHost"); + const fullBookStats = $("fullBookStats"); + const printBook = $("printBook"); + const fullBookStatus = $("fullBookStatus"); + const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; + const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement; + const fbNextBtn = $("fbNextBtn") as HTMLButtonElement; + const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement; + + const fullBookEditor: Editor = createTiptapEditor( + fullBookHost, + { onChange: () => undefined }, + { editable: false, extensions: [ChapterBreak] } + ); + + interface FullBookChapterNav { + id: string; + title: string; + el: HTMLElement | null; + } + let fullBookChapterEls: HTMLElement[] = []; + let fullBookChapters: FullBookChapterNav[] = []; + let currentChapterIndex = 0; + let navScrollRaf: number | null = null; + + function populateChapterNav(entries: { id: string; title: string }[]) { + fbChapterSelect.innerHTML = ""; + for (const e of entries) { + const opt = document.createElement("option"); + opt.value = e.id; + opt.textContent = e.title; + fbChapterSelect.appendChild(opt); + } + setCurrentChapter(0); + } + + function setCurrentChapter(index: number) { + if (!fullBookChapters.length) currentChapterIndex = 0; + else currentChapterIndex = Math.max(0, Math.min(index, fullBookChapters.length - 1)); + updateChapterNavUI(); + } + + function updateChapterNavUI() { + const n = fullBookChapters.length; + fbPrevBtn.disabled = n === 0 || currentChapterIndex <= 0; + fbNextBtn.disabled = n === 0 || currentChapterIndex >= n - 1; + const entry = fullBookChapters[currentChapterIndex]; + if (entry && fbChapterSelect.value !== entry.id) fbChapterSelect.value = entry.id; + } + + // Scroll the assembled manuscript so the given chapter's anchor sits near the + // top of the Full Book viewport. This only touches the rendered editor, never + // the chapter files or the assembled content. + function jumpToChapter(index: number) { + if (!fullBookChapters.length) return; + const clamped = Math.max(0, Math.min(index, fullBookChapters.length - 1)); + fullBookChapterEls[clamped]?.scrollIntoView({ block: "start" }); + setCurrentChapter(clamped); + } + + // Map the element currently at the top of the Full Book viewport back to the + // chapter whose anchor is the last one above it, so manual scrolling updates + // the selector. + function detectChapterFromScroll(): number { + if (!fullBookChapters.length) return 0; + const scroller = fullBookEditor.view.dom as HTMLElement; + const rect = scroller.getBoundingClientRect(); + if (rect.height === 0) return currentChapterIndex; + const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2; + if (atBottom) return fullBookChapters.length - 1; + let idx = 0; + for (let i = 0; i < fullBookChapters.length; i++) { + const el = fullBookChapterEls[i]; + if (!el) continue; + const r = el.getBoundingClientRect(); + if (r.top <= rect.top + 4) idx = i; + else break; + } + return idx; + } + + (fullBookEditor.view.dom as HTMLElement).addEventListener("scroll", () => { + if (!fullBookChapters.length) return; + if (navScrollRaf !== null) cancelAnimationFrame(navScrollRaf); + navScrollRaf = requestAnimationFrame(() => { + navScrollRaf = null; + setCurrentChapter(detectChapterFromScroll()); + }); + }); + + // Assemble the whole book directly from native chapter documents. The view is + // a single ProseMirror doc with a ChapterBreak anchor before every chapter + // (the first one unmarked). Print/export still use the Markdown chapters + // computed alongside, so export formats are unchanged. The pure logic lives + // in native-assembly.ts; here we only fetch the per-chapter data. + async function assembleFullBook(): Promise<{ + nativeDoc: FolioNode; + chapterEntries: { id: string; title: string }[]; + targets: WikilinkTarget[]; + }> { + const res = await api.getBookMeta(); + if (!res || typeof res !== "object" || "error" in res) { + return { + nativeDoc: { type: "doc", content: [] }, + chapterEntries: [], + targets: [], + }; + } + const book = res as BookMeta; + const inputs = []; + for (const id of book.chapterOrder) { + const ch = book.chapters[id]; + if (!ch) continue; + const docRes = await api.loadChapterDoc(ch.id); + const doc = docRes && "doc" in docRes ? (docRes as { doc: FolioNode }).doc : { type: "doc", content: [] }; + inputs.push({ id: ch.id, title: ch.title, doc }); + } + return buildFullBookDoc(inputs); + } + + async function renderFullBook() { + const { nativeDoc, chapterEntries } = await assembleFullBook(); + setEditorDoc(fullBookEditor, nativeDoc); + fullBookChapterEls = Array.from( + fullBookHost.querySelectorAll("[data-chapter-id]") + ); + fullBookChapters = chapterEntries.map((e, i) => ({ ...e, el: fullBookChapterEls[i] ?? null })); + populateChapterNav(chapterEntries); + const text = nativeNodeText(nativeDoc); + fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length} · ~${countWords(text) ? Math.ceil(countWords(text) / 200) : 0} min read`; + printBook.innerHTML = renderPrintChaptersNative(nativeDoc); + // Re-render replaces the whole document, which resets the scroll position, + // so re-derive the current chapter once layout has settled. + requestAnimationFrame(() => { + requestAnimationFrame(() => setCurrentChapter(detectChapterFromScroll())); + }); + } + + async function selectFullBook() { + if (state.selectedId && state.dirty) { + await hooks.saveCurrentChapter(); + state.dirty = false; + $("saveState").textContent = "Saved"; + $("saveState").classList.remove("unsaved"); + } + hooks.clearTimers(); + state.selectedId = null; + $("chapterList").querySelectorAll(".chapter-row").forEach((r) => r.classList.remove("active")); + $("fullBookBtn").classList.add("active"); + $("chapterView").classList.add("hidden"); + $("editorPlaceholder").classList.add("hidden"); + fullBookView.classList.remove("hidden"); + await renderFullBook(); + fullBookEditor.commands.focus(); + } + + async function refreshFullBook() { + if (!isFullBookVisible()) return; + await renderFullBook(); + } + + function isFullBookVisible(): boolean { + return !fullBookView.classList.contains("hidden"); + } + + function resetFullBookNav(): void { + setEditorDoc(fullBookEditor, { type: "doc", content: [] }); + fullBookChapters = []; + fullBookChapterEls = []; + currentChapterIndex = 0; + fbChapterSelect.innerHTML = ""; + updateChapterNavUI(); + } + + function fullBookNotice(text: string) { + fullBookStatus.textContent = text; + fullBookStatus.classList.add("unsaved"); + setTimeout(() => { + fullBookStatus.textContent = ""; + fullBookStatus.classList.remove("unsaved"); + }, 3000); + } + + // Build a native Full Book document from an explicit, ordered set of chapter + // ids. Used for "selected chapters" PDF export so the printed manuscript + // contains exactly those chapters in canonical order. + async function assembleChapters( + ids: string[] + ): Promise<{ nativeDoc: FolioNode; chapterEntries: { id: string; title: string }[] }> { + const res = await api.getBookMeta(); + const inputs: { id: string; title: string; doc: FolioNode }[] = []; + if (res && typeof res === "object" && !("error" in res)) { + const book = res as BookMeta; + for (const id of ids) { + const ch = book.chapters[id]; + if (!ch) continue; + const docRes = await api.loadChapterDoc(id); + const doc = + docRes && "doc" in docRes ? (docRes as { doc: FolioNode }).doc : { type: "doc", content: [] }; + inputs.push({ id, title: ch.title, doc }); + } + } + return buildFullBookDoc(inputs); + } + + // Render a specific set of chapters into the print container for PDF export. + async function renderChaptersToPrint(ids: string[]) { + const asm = await assembleChapters(ids); + printBook.innerHTML = renderPrintChaptersNative(asm.nativeDoc); + } + + $("fullBookBtn").addEventListener("click", () => void selectFullBook()); + fbPrevBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex - 1)); + fbNextBtn.addEventListener("click", () => jumpToChapter(currentChapterIndex + 1)); + fbChapterSelect.addEventListener("change", () => { + const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value); + if (idx >= 0) jumpToChapter(idx); + }); + + copyAllBtn.addEventListener("click", async () => { + // Copy the assembled manuscript as native content (the combined ProseMirror + // document), not concatenated Markdown. + const doc = getEditorDoc(fullBookEditor); + const text = JSON.stringify(doc); + if (!text || text === JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content: [] } })) { + fullBookStatus.textContent = "Nothing to copy"; + return; + } + const res = await api.copyText(text); + if (res && "error" in res) { + await alertMessage("Could not copy", (res as { error: string }).error); + return; + } + fullBookStatus.textContent = "Full book copied (native)"; + fullBookStatus.classList.add("unsaved"); + setTimeout(() => { + fullBookStatus.textContent = ""; + fullBookStatus.classList.remove("unsaved"); + }, 3000); + }); + + return { + fullBookEditor, + isFullBookVisible, + refreshFullBook, + selectFullBook, + renderChaptersToPrint, + resetFullBookNav, + fullBookNotice, + }; +} diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts new file mode 100644 index 0000000..6925034 --- /dev/null +++ b/src/renderer/book/index.ts @@ -0,0 +1,183 @@ +import type { BookMeta, ChapterComment } from "../../main/project.js"; +import { alertMessage, confirmMessage, promptInput } from "../dialog.js"; +import type { ChapterEntry, FolioAPI, Recent } from "./types.js"; +import type { BookCtx, BookHooks, SharedState } from "./types.js"; +import { createEditorFeature } from "./editor.js"; +import { createToolbarFeature } from "./toolbar.js"; +import { createSearchFeature } from "./search.js"; +import { createFullBookFeature } from "./full-book.js"; +import { createChapterListFeature } from "./chapter-list.js"; +import { createWelcomeFeature } from "./welcome.js"; +import { createExportsFeature } from "./exports.js"; +import { createCommentsFeature } from "./comments.js"; +import { + $, + basename, + closeEl, + hideUndoToast, + openEl, + selectedText, + showUndoToast, + showToast, +} from "./ui.js"; + +export type { ChapterSnapshot, FolioAPI } from "./types.js"; + +export function initBook(api: FolioAPI) { + let currentBook: { path: string; title: string } | null = null; + let meta: BookMeta | null = null; + let ordered: ChapterEntry[] = []; + let selectedId: string | null = null; + let dirty = false; + let loadedDocJson = ""; + + // ---- P7 decomposition bridge ------------------------------------------- + // Temporary: exposes these closure locals through the SharedState contract so + // extracted feature modules share one source of truth. Removed in Step 7 when + // the last feature leaves and state becomes a plain object literal. + const state: SharedState = { + get currentBook() { return currentBook; }, + set currentBook(v) { currentBook = v; }, + get meta() { return meta; }, + set meta(v) { meta = v; }, + get ordered() { return ordered; }, + set ordered(v) { ordered = v; }, + get selectedId() { return selectedId; }, + set selectedId(v) { selectedId = v; }, + get dirty() { return dirty; }, + set dirty(v) { dirty = v; }, + get loadedDocJson() { return loadedDocJson; }, + set loadedDocJson(v) { loadedDocJson = v; }, + }; + const hooks = {} as BookHooks; + const ctx: BookCtx = { api, state, hooks }; + + // Feature: chapter editing surface + autosave engine + zoom + stats modal. + const ed = createEditorFeature(ctx); + const editor = ed.editor; + hooks.selectChapter = ed.selectChapter; + hooks.saveCurrentChapter = ed.saveCurrentChapter; + hooks.saveAndNotify = ed.saveAndNotify; + hooks.flushAutosave = ed.flushAutosave; + hooks.clearTimers = ed.clearTimers; + + // Feature: formatting toolbar + font controls. + createToolbarFeature(ctx, editor); + + // Feature: cross-chapter search & replace. + hooks.scrollToTerm = createSearchFeature(ctx, editor).scrollToTerm; + + // Feature: read-only assembled Full Book view + navigation. + const fb = createFullBookFeature(ctx); + hooks.refreshFullBook = fb.refreshFullBook; + hooks.selectFullBook = fb.selectFullBook; + hooks.renderChaptersToPrint = fb.renderChaptersToPrint; + hooks.resetFullBookNav = fb.resetFullBookNav; + hooks.isFullBookVisible = fb.isFullBookVisible; + hooks.fullBookNotice = fb.fullBookNotice; + + // Feature: chapter sidebar list, color popover, CRUD, drag reorder. + const cl = createChapterListFeature(ctx); + hooks.renderChapterList = cl.renderChapterList; + hooks.setChapterButtons = cl.setChapterButtons; + + // Feature: welcome screen + recents + session coordination. + const welcome = createWelcomeFeature(ctx); + hooks.refreshMeta = welcome.refreshMeta; + hooks.showPlaceholder = welcome.showPlaceholder; + + // Feature: export workflow (whole book vs selected chapters). + const exports = createExportsFeature(ctx); + // Wire export facades from the extracted module. + hooks.exportMarkdown = exports.exportMarkdown; + hooks.exportZip = exports.exportZip; + hooks.exportPdf = exports.exportPdf; + hooks.exportDocx = exports.exportDocx; + hooks.exportOdt = exports.exportOdt; + hooks.migrateBookNative = exports.migrateBookNative; + hooks.exportFromMenu = exports.exportFromMenu; + + // Feature: comments / annotations modal. + const comments = createCommentsFeature(ctx, editor); + // Wire comment facade from the extracted module. + hooks.updateCommentsBadge = comments.updateCommentsBadge; + + // ---- element consts (kept from the original file) ---- + const editorPlaceholder = $("editorPlaceholder"); + const chapterView = $("chapterView"); + const chapterTitleEl = $("chapterTitle"); + const saveStateEl = $("saveState"); + const exportBtn = $("exportBtn") as HTMLButtonElement; + const exportZipBtn = $("exportZipBtn") as HTMLButtonElement; + const projectBar = $("projectBar"); + const fullBookNav = $("fullBookNav"); + const fullBookBtn = $("fullBookBtn") as HTMLButtonElement; + const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement; + const exportMenu = $("exportMenu"); + const exportDropdown = $("exportDropdown"); + + const welcomeEl = $("welcome"); + const recentList = $("recentList") as HTMLUListElement; + + // ---- welcome / recents logic (moved from inline into createWelcomeFeature) ---- + // The welcome module owns its own DOM chrome and wiring; here we only keep the + // top-level constructor call and the bridge assignments it already set above. + + // ---- export workflow (whole book vs selected chapters) ---- + // The exports module owns its own DOM chrome and wiring; here we only keep the + // top-level constructor call and the bridge assignments it already set above. + + // ---- comments / annotations ---- + // The comments module owns its own DOM chrome and wiring; here we only keep the + // top-level constructor call and the bridge assignments it already set above. + + // ---- api menu handlers ---- + // These now delegate to the extracted feature facades rather than calling + // inline functions defined in this module. + api.onBookOpened((data) => welcome.setCurrentBook(data)); + api.onBookClosed(() => void welcome.showWelcome()); + api.onChaptersChanged(() => void hooks.refreshMeta()); + api.onNewBookRequested(() => void welcome.startNewBook()); + api.onOpenBookRequested(() => void welcome.startOpenBook()); + api.onOpenRecent((p) => void welcome.openPath(p)); + api.onRevealRequested(async (p) => { + const ok = await api.revealInFolder(p); + showToast( + ok + ? "Opened your book's folder in the file manager — check the taskbar if you don't see it" + : "Could not open the file manager" + ); + }); + api.onExportMarkdownRequested(() => void hooks.exportMarkdown()); + api.onExportZipRequested(() => void hooks.exportZip()); + api.onExportPdfRequested(() => void hooks.exportPdf()); + api.onExportDocxRequested(() => void hooks.exportDocx()); + api.onExportOdtRequested(() => void hooks.exportOdt()); + api.onUndoRequested(() => { + if (hooks.isFullBookVisible()) fb.fullBookEditor.commands.undo(); + else editor.commands.undo(); + }); + api.onRedoRequested(() => { + if (hooks.isFullBookVisible()) fb.fullBookEditor.commands.redo(); + else editor.commands.redo(); + }); + api.onSelectAllRequested(() => { + if (hooks.isFullBookVisible()) { + fb.fullBookEditor.commands.focus(); + fb.fullBookEditor.commands.selectAll(); + } else { + editor.commands.selectAll(); + } + }); + api.onSaveRequested(() => void hooks.saveAndNotify()); + + // ---- comments / annotations wiring ---- + // Already wired through the comments feature's internal listeners; the + // badge is kept in sync via hooks.updateCommentsBadge from the bridge. + + hooks.setChapterButtons(false); + void welcome.showWelcome(); + + (window as any).__folioEditor = editor; + (window as any).__folioFullBookEditor = fb.fullBookEditor; +} \ No newline at end of file diff --git a/src/renderer/book/search.ts b/src/renderer/book/search.ts new file mode 100644 index 0000000..c19c9da --- /dev/null +++ b/src/renderer/book/search.ts @@ -0,0 +1,279 @@ +// Seam 1: cross-chapter search & replace (#searchModal). +// +// The search flattens chapter Markdown to reader-visible plain text; replace +// operates on native chapter documents via throwaway editors so native JSON +// stays the storage format. Results navigate through hooks.selectChapter + +// scrollToTerm. Historically this wiring lived inside showWelcome() and +// re-registered ~15 listeners per Home trip; it has been bind-once since P6 +// and now binds exactly once at construction (P7). +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { Editor } from "@tiptap/core"; +import type { FolioNode } from "../../shared/native-doc-core.js"; +import type { BookCtx } from "./types.js"; +import { alertMessage } from "../dialog.js"; +import { $, closeEl, findMatches, makeSnippet, markdownToPlain, openEl } from "./ui.js"; +import { + createTiptapEditor, + setEditorDoc, + getEditorDoc, +} from "../tiptap-editor.js"; + +export interface SearchFeature { + scrollToTerm(term: string, matchCase: boolean): void; +} + +export function createSearchFeature(ctx: BookCtx, editor: Editor): SearchFeature { + const { api, state, hooks } = ctx; + + function scrollToTerm(term: string, matchCase: boolean): void { + if (!term) return; + const needle = matchCase ? term : term.toLowerCase(); + let found: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (found !== null) return false; + if (node.isText && node.text) { + const t = matchCase ? node.text : node.text.toLowerCase(); + const idx = t.indexOf(needle); + if (idx !== -1) { + found = pos + idx; + return false; + } + } + return true; + }); + if (found === null) return; + const to = Math.min(found + term.length, editor.state.doc.content.size); + editor.commands.focus(); + editor.commands.setTextSelection({ from: found, to }); + const dom = (editor.view as any).domAt(found); + const node = (dom.node.nodeType === 3 ? dom.node.parentElement : dom.node) as HTMLElement | null; + node?.scrollIntoView({ block: "center" }); + } + + // ---- search across all chapters ---- + const searchBtn = $("searchBtn") as HTMLButtonElement; + const searchModal = $("searchModal") as HTMLDivElement; + const searchInput = $("searchInput") as HTMLInputElement; + const searchCaseCb = $("searchCase") as HTMLInputElement; + const searchMeta = $("searchMeta") as HTMLElement; + const searchResults = $("searchResults") as HTMLUListElement; + const searchClose = $("searchClose") as HTMLButtonElement; + const replaceInput = $("replaceInput") as HTMLInputElement; + const replaceBtn = $("replaceBtn") as HTMLButtonElement; + const replaceAllBtn = $("replaceAllBtn") as HTMLButtonElement; + let searchTimer: number | undefined; + + function openSearch(): void { + if (!state.meta) { + void alertMessage("No book open", "Open a book before searching."); + return; + } + openEl(searchModal); + searchInput.value = ""; + searchResults.innerHTML = ""; + searchMeta.textContent = ""; + setTimeout(() => searchInput.focus(), 0); + } + function closeSearch(): void { + closeEl(searchModal); + } + + function runSearchNow(): void { + const term = searchInput.value.trim(); + searchResults.innerHTML = ""; + if (!term) { + searchMeta.textContent = ""; + return; + } + if (!state.meta) { + searchMeta.textContent = "Open a book to search."; + return; + } + const matchCase = searchCaseCb.checked; + void (async () => { + const list = await api.getChapterList(); + const chapters = Array.isArray(list) ? list : []; + const byChapter: { id: string; title: string; plain: string; positions: number[] }[] = []; + let total = 0; + for (const ch of chapters) { + if (total >= 200) break; + const res = await api.getChapterContent(ch.id); + const md = typeof res === "string" ? res : ""; + const plain = markdownToPlain(md); + const positions = findMatches(plain, term, matchCase); + if (positions.length) { + byChapter.push({ id: ch.id, title: ch.title || ch.id, plain, positions }); + total += positions.length; + } + } + searchMeta.textContent = total + ? `${total} match${total === 1 ? "" : "es"} in ${byChapter.length} chapter${byChapter.length === 1 ? "" : "s"}` + : "No matches."; + for (const r of byChapter) { + const head = document.createElement("li"); + head.className = "search-chapter-head"; + head.textContent = `${r.title} (${r.positions.length})`; + searchResults.appendChild(head); + for (const pos of r.positions) { + const li = document.createElement("li"); + li.className = "search-result"; + li.innerHTML = makeSnippet(r.plain, pos, term.length); + li.addEventListener("click", () => void goToResult(r.id, pos, term, matchCase)); + searchResults.appendChild(li); + } + } + })(); + } + + function goToResult(id: string, pos: number, term: string, matchCase: boolean): void { + closeSearch(); + void (async () => { + await hooks.selectChapter(id); + scrollToTerm(term, matchCase); + })(); + } + + // --- find & replace (operates on native chapter documents) --- + // Build a literal (or case-insensitive) RegExp from a user term. Replace + // treats the term as a literal string to avoid surprising the user. + function termToRegExp(term: string, matchCase: boolean): RegExp { + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escaped, matchCase ? "g" : "gi"); + } + + // Find every occurrence of `term` within a Tiptap editor's text and replace + // it with `replacement`. Matches are gathered per text node (positions are + // ProseMirror positions) and applied in reverse so earlier positions stay + // valid. Returns the number of replacements made. + function findAndReplaceInEditor( + ed: Editor, + term: string, + replacement: string, + matchCase: boolean + ): number { + if (!term) return 0; + const re = termToRegExp(term, matchCase); + const matches: { from: number; to: number }[] = []; + ed.state.doc.descendants((node, pos) => { + if (node.isText && node.text) { + let m: RegExpExecArray | null; + re.lastIndex = 0; + while ((m = re.exec(node.text)) !== null) { + if (m[0].length === 0) { + re.lastIndex++; + continue; + } + matches.push({ from: pos + m.index, to: pos + m.index + m[0].length }); + } + } + return true; + }); + if (!matches.length) return 0; + // Apply highest-position match first so lower positions are unaffected. + const { state: edState, view } = ed; + let tr = edState.tr; + for (let i = matches.length - 1; i >= 0; i--) { + tr = tr.insertText(replacement, matches[i].from, matches[i].to); + } + view.dispatch(tr); + return matches.length; + } + + // Load a chapter's native document into a throwaway editor, run find & replace + // on it, and persist the result. Returns the number of replacements. + async function replaceInChapterDoc( + id: string, + term: string, + replacement: string, + matchCase: boolean + ): Promise { + const res = await api.loadChapterDoc(id); + if (!res || typeof res !== "object" || "error" in res) return 0; + const doc = (res as { doc?: unknown }).doc; + if (!doc) return 0; + const host = document.createElement("div"); + host.style.position = "absolute"; + host.style.left = "-99999px"; + host.style.top = "0"; + document.body.appendChild(host); + const tmp = createTiptapEditor(host); + let count = 0; + try { + setEditorDoc(tmp, doc as FolioNode); + count = findAndReplaceInEditor(tmp, term, replacement, matchCase); + if (count > 0) await api.saveChapterDoc(id, getEditorDoc(tmp)); + } finally { + tmp.destroy(); + host.remove(); + } + return count; + } + + // Flush current edits, then replace either in the current chapter or the whole + // book. Afterward the UI is refreshed so the changes are visible. + async function doReplace(all: boolean): Promise { + const term = searchInput.value.trim(); + const replacement = replaceInput.value; + if (!term) { + searchMeta.textContent = "Enter a search term to replace."; + return; + } + const meta = state.meta; + if (!meta) return; + await hooks.saveCurrentChapter(); + const ids = all ? meta.chapterOrder.slice() : state.selectedId ? [state.selectedId] : []; + if (!ids.length) { + searchMeta.textContent = "No chapter to replace in."; + return; + } + let total = 0; + let chaptersTouched = 0; + for (const id of ids) { + const c = await replaceInChapterDoc(id, term, replacement, searchCaseCb.checked); + if (c > 0) { + total += c; + chaptersTouched++; + } + } + if (total === 0) { + searchMeta.textContent = "No matches to replace."; + return; + } + if (state.selectedId) await hooks.selectChapter(state.selectedId); + if (!hooks.isFullBookVisible()) await hooks.refreshFullBook(); + searchMeta.textContent = `Replaced ${total} occurrence${total === 1 ? "" : "s"} in ${chaptersTouched} chapter${chaptersTouched === 1 ? "" : "s"}.`; + void runSearchNow(); + } + + searchInput.addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = window.setTimeout(runSearchNow, 200); + }); + searchCaseCb.addEventListener("change", runSearchNow); + replaceBtn.addEventListener("click", () => void doReplace(false)); + replaceAllBtn.addEventListener("click", () => void doReplace(true)); + searchBtn.addEventListener("click", openSearch); + searchClose.addEventListener("click", closeSearch); + searchModal.addEventListener("click", (e) => { + if (e.target === searchModal) closeSearch(); + }); + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") { + e.preventDefault(); + openSearch(); + } else if (e.key === "Escape" && !searchModal.classList.contains("hidden")) { + closeSearch(); + } else if ( + e.key === "Enter" && + !searchModal.classList.contains("hidden") && + document.activeElement === searchInput + ) { + const first = searchResults.querySelector(".search-result"); + if (first) first.click(); + } + }); + + return { scrollToTerm }; +} diff --git a/src/renderer/book/toolbar.ts b/src/renderer/book/toolbar.ts new file mode 100644 index 0000000..00417cc --- /dev/null +++ b/src/renderer/book/toolbar.ts @@ -0,0 +1,402 @@ +// The formatting toolbar: command dispatch, special insert actions, dropdown +// pinning, heading/size/spacing controls — plus the custom font-family menu +// (merged here in P7: font selection is a UI control of the toolbar, not a +// standalone feature domain). +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { Editor } from "@tiptap/core"; +import type { FolioNode } from "../../shared/native-doc-core.js"; +import type { BookCtx } from "./types.js"; +import { alertMessage, promptInput } from "../dialog.js"; +import { + $, + closeEl, + openEl, + selectedText, + setActive, +} from "./ui.js"; +import { + createTiptapEditor, + setEditorDoc, + getEditorDoc, + runCommand, +} from "../tiptap-editor.js"; + +export interface ToolbarFeature {} + +export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeature { + const { api, state, hooks } = ctx; + + const toolbar = $("editorToolbar"); + + function closeDropdowns() { + toolbar.querySelectorAll(".dropdown-menu").forEach((m) => closeEl(m)); + toolbar.querySelectorAll(".dropdown-toggle").forEach((t) => t.classList.remove("open")); + } + + async function toggleFullscreen() { + const isFs = await api.toggleFullscreen(); + setActive("fullscreen", !!isFs); + } + + document.addEventListener("fullscreenchange", () => { + setActive("fullscreen", !!document.fullscreenElement); + }); + + function toggleFocus() { + const on = document.body.classList.toggle("focus-mode"); + setActive("focus", on); + } + + async function attachFile() { + const res = await api.importAttachment(); + if (!res) return; + if ("canceled" in res) return; + if ("error" in res) { + await alertMessage("Could not attach file", (res as { error: string }).error); + return; + } + if ("relPath" in res) { + runCommand(editor, "wikilink", { + href: (res as { relPath: string }).relPath, + text: (res as { name: string }).name, + }); + } + } + + async function handleSpecial(item: HTMLElement) { + const action = item.dataset.action; + switch (action) { + case "wikilink": { + const sel = selectedText(editor); + const name = await promptInput("Wikilink to chapter", sel); + if (name === null) return; + runCommand(editor, "wikilink", { + href: `folio:chapter/${name}`, + text: sel && sel !== name ? sel : name, + }); + break; + } + case "image": { + const res = await api.importImageAsset(); + if (!res || "canceled" in res) return; + if ("error" in res) { + await alertMessage("Could not insert image", (res as { error: string }).error); + return; + } + if ("relPath" in res) { + runCommand(editor, "image", { + src: (res as { relPath: string }).relPath, + }); + } + break; + } + case "attach": { + await attachFile(); + return; + } + case "callout": { + // The native editor has no callout node; insert a styled blockquote. + runCommand(editor, "quote"); + break; + } + case "table": { + const rowsEl = $("tableRows") as HTMLInputElement; + const colsEl = $("tableCols") as HTMLInputElement; + const rows = Math.max(1, Math.min(12, parseInt(rowsEl.value, 10) || 3)); + const cols = Math.max(1, Math.min(8, parseInt(colsEl.value, 10) || 2)); + runCommand(editor, "table", { rows, cols }); + break; + } + default: + return; + } + editor.commands.focus(); + } + + toolbar.addEventListener("pointerdown", (e) => { + // Prevent default only for buttons so the editor keeps its selection/focus + // when a formatting command is invoked. Never prevent it for can't render each option + // in its own font reliably, so we build buttons whose label uses its own + // font-family. Fonts are bundled locally (see fonts.css) so each name shows + // in its actual typeface, and a search box + categories keep the long list + // manageable. Selecting a font applies it book-wide. + type FontDef = { name: string; family: string; cat: string }; + const FONT_LIST: FontDef[] = [ + { name: "Inter", family: '"Inter", sans-serif', cat: "Sans-serif" }, + { name: "Roboto", family: '"Roboto", sans-serif', cat: "Sans-serif" }, + { name: "Open Sans", family: '"Open Sans", sans-serif', cat: "Sans-serif" }, + { name: "Lato", family: "Lato, sans-serif", cat: "Sans-serif" }, + { name: "Montserrat", family: "Montserrat, sans-serif", cat: "Sans-serif" }, + { name: "Poppins", family: "Poppins, sans-serif", cat: "Sans-serif" }, + { name: "Oswald", family: "Oswald, sans-serif", cat: "Sans-serif" }, + { name: "Raleway", family: "Raleway, sans-serif", cat: "Sans-serif" }, + { name: "Nunito", family: "Nunito, sans-serif", cat: "Sans-serif" }, + { name: "Ubuntu", family: "Ubuntu, sans-serif", cat: "Sans-serif" }, + { name: "Source Sans 3", family: '"Source Sans 3", sans-serif', cat: "Sans-serif" }, + { name: "Noto Sans", family: '"Noto Sans", sans-serif', cat: "Sans-serif" }, + { name: "PT Sans", family: '"PT Sans", sans-serif', cat: "Sans-serif" }, + { name: "DM Sans", family: '"DM Sans", sans-serif', cat: "Sans-serif" }, + { name: "Quicksand", family: "Quicksand, sans-serif", cat: "Sans-serif" }, + { name: "Work Sans", family: '"Work Sans", sans-serif', cat: "Sans-serif" }, + { name: "Roboto Slab", family: '"Roboto Slab", serif', cat: "Serif" }, + { name: "Merriweather", family: "Merriweather, serif", cat: "Serif" }, + { name: "Playfair Display", family: '"Playfair Display", serif', cat: "Serif" }, + { name: "Libre Baskerville", family: '"Libre Baskerville", serif', cat: "Serif" }, + ]; + const fontMenu = $("fontMenu") as HTMLDivElement; + const fontLabel = $("fontLabel") as HTMLElement; + + function setFontLabel(name: string, family: string) { + fontLabel.textContent = name; + fontLabel.style.fontFamily = family; + } + + async function selectFont(name: string, family: string) { + // Apply font to the current selection (or cursor position). + // Book-wide propagation is handled separately via applyFontToAllChapters. + editor.chain().focus().setMark("fontFamily", { fontFamily: family }).run(); + setFontLabel(name, family); + if (state.selectedId) await api.saveChapterDoc(state.selectedId, getEditorDoc(editor)); + await applyFontToAllChapters(family, state.selectedId ?? undefined); + if (!hooks.isFullBookVisible()) await hooks.refreshFullBook(); + } + + // Search box (filters the list without closing the menu). + const fontSearch = document.createElement("input"); + fontSearch.type = "text"; + fontSearch.placeholder = "Search fonts…"; + fontSearch.className = "font-search"; + fontSearch.addEventListener("click", (e) => e.stopPropagation()); + fontMenu.appendChild(fontSearch); + + const fontToggle = $("fontToggle") as HTMLButtonElement; + let activeIndex = 0; + const visibleOptions = () => optionEls.filter((el) => el.style.display !== "none"); + function highlight(index: number) { + optionEls.forEach((el) => el.classList.remove("is-active")); + const vis = visibleOptions(); + if (!vis.length) return; + activeIndex = Math.max(0, Math.min(index, vis.length - 1)); + const el = vis[activeIndex]; + if (el) { + el.classList.add("is-active"); + el.scrollIntoView({ block: "nearest" }); + } + } + // Open the menu with the search box focused so arrow-key navigation works + // immediately. + fontToggle.addEventListener("click", () => { + if (!fontMenu.classList.contains("hidden")) { + setTimeout(() => { fontSearch.focus(); highlight(activeIndex); }, 0); + } + }); + fontMenu.addEventListener("keydown", (e) => { + if (e.target !== fontSearch) return; + e.stopPropagation(); + const vis = visibleOptions(); + if (e.key === "ArrowDown") { + e.preventDefault(); + highlight(activeIndex + 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + highlight(activeIndex - 1); + } else if (e.key === "Enter") { + e.preventDefault(); + const el = vis[activeIndex]; + if (el) el.click(); + } + }); + + const fontListWrap = document.createElement("div"); + fontMenu.appendChild(fontListWrap); + + const optionEls: HTMLButtonElement[] = []; + const categories = [...new Set(FONT_LIST.map((f) => f.cat))]; + for (const cat of categories) { + const header = document.createElement("div"); + header.className = "font-cat"; + header.textContent = cat; + fontListWrap.appendChild(header); + for (const opt of FONT_LIST.filter((f) => f.cat === cat)) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "dropdown-item font-option"; + item.textContent = opt.name; + item.style.fontFamily = opt.family; + item.dataset.name = opt.name.toLowerCase(); + item.addEventListener("click", (e) => { + e.stopPropagation(); + void selectFont(opt.name, opt.family); + closeDropdowns(); + }); + fontListWrap.appendChild(item); + optionEls.push(item); + } + } + + fontSearch.addEventListener("input", () => { + const q = fontSearch.value.trim().toLowerCase(); + for (const el of optionEls) { + el.style.display = !q || (el.dataset.name || "").includes(q) ? "" : "none"; + } + fontListWrap.querySelectorAll(".font-cat").forEach((h) => { + const cat = (h.textContent || "").trim(); + const anyVisible = FONT_LIST.filter((f) => f.cat === cat).some( + (f) => !q || f.name.toLowerCase().includes(q) + ); + h.style.display = anyVisible ? "" : "none"; + }); + activeIndex = 0; + highlight(activeIndex); + }); + + // Default writing font is Inter (the first bundled font). + setFontLabel(FONT_LIST[0].name, FONT_LIST[0].family); + + // Write the given font family into every chapter's content so the change + // propagates to all chapters (and survives reloads and exports). + async function applyFontToAllChapters(fontFamily: string, excludeId?: string) { + if (!state.meta) return; + const host = document.createElement("div"); + host.style.position = "absolute"; + host.style.left = "-99999px"; + host.style.top = "0"; + document.body.appendChild(host); + const tmp = createTiptapEditor(host); + try { + for (const ch of state.ordered) { + if (excludeId && ch.id === excludeId) continue; + try { + const res = await api.loadChapterDoc(ch.id); + if (!res || typeof res !== "object" || "error" in res) continue; + const doc = (res as { doc?: unknown }).doc; + if (!doc) continue; + setEditorDoc(tmp, doc as FolioNode); + if (fontFamily) + tmp.chain().selectAll().setMark("fontFamily", { fontFamily }).run(); + else tmp.chain().selectAll().unsetMark("fontFamily").run(); + await api.saveChapterDoc(ch.id, getEditorDoc(tmp)); + } catch (err) { + console.error("applyFontToAllChapters failed for", ch.id, err); + } + } + } finally { + tmp.destroy(); + host.remove(); + } + } + + const fontSizeSelect = $("fontSizeSelect") as HTMLSelectElement; + fontSizeSelect?.addEventListener("change", () => { + runCommand(editor, "fontsize", { fontSize: fontSizeSelect.value }); + editor.commands.focus(); + }); + + const lineSpacingSelect = $("lineSpacingSelect") as HTMLSelectElement; + lineSpacingSelect?.addEventListener("change", () => { + runCommand(editor, "linespacing", { lineHeight: lineSpacingSelect.value }); + editor.commands.focus(); + }); + + return {}; +} diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts new file mode 100644 index 0000000..e99daee --- /dev/null +++ b/src/renderer/book/types.ts @@ -0,0 +1,150 @@ +// Shared contracts for the decomposed book UI (P7). Feature modules receive a +// single BookCtx and never import each other, which keeps the dependency graph +// acyclic: types.ts + ui.ts are the only shared leaves. +export type { ChapterComment } from "../../main/project.js"; +import type { BookMeta } from "../../main/project.js"; + +export interface ChapterSnapshot { + id: string; + title: string; + color?: string | null; + content: string; + index: number; +} + +export interface ChapterEntry { + id: string; + title: string; + file: string; + color?: string; +} + +export interface Recent { + path: string; + title: string; + lastOpened: string; +} + +export interface OpenedBook { + path: string; + title: string; +} + +export type FolioAPI = { + newBookNamed: (name: string) => Promise; + deleteBook: (p: string) => Promise; + restoreBook: (p: string) => Promise; + openBook: () => Promise; + openBookPath: (p: string) => Promise; + revealInFolder: (p: string) => Promise; + getRecents: () => Promise; + closeBook: () => Promise; + showContextMenu: (x: number, y: number) => void; + getBookMeta: () => Promise; + setComments: (comments: Record) => Promise; + getChapterList: () => Promise; + getChapterContent: (id: string) => Promise; + createChapter: (title: string) => Promise; + renameChapter: (id: string, title: string) => Promise; + setChapterColor: (id: string, color: string | null) => Promise; + deleteChapter: (id: string) => Promise; + restoreChapter: (snap: ChapterSnapshot) => Promise; + duplicateChapter: (id: string, title?: string) => Promise; + reorderChapters: (newOrder: string[]) => Promise; + saveChapter: (id: string, content: string) => Promise; + migrateChapter: (id: string) => Promise; + migrateBook: () => Promise; + loadChapterDoc: (id: string) => Promise; + saveChapterDoc: (id: string, doc: unknown) => Promise; + importImageAsset: () => Promise; + exportMarkdown: () => Promise; + exportZip: () => Promise; + exportPdf: (chapterIds?: string[] | null) => Promise; + exportDocx: (chapterIds?: string[] | null) => Promise; + exportOdt: (chapterIds?: string[] | null) => Promise; + importAttachment: () => Promise; + copyText: (text: string) => Promise; + getClipboardText: () => Promise; + toggleFullscreen: () => Promise; + getTheme: () => Promise; + setTheme: (id: string) => Promise; + getZoom: () => Promise; + setZoom: (pct: number) => Promise; + onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; + onBookClosed: (cb: () => void) => void; + onChaptersChanged: (cb: () => void) => void; + onNewBookRequested: (cb: () => void) => void; + onOpenBookRequested: (cb: () => void) => void; + onOpenRecent: (cb: (p: string) => void) => void; + onRevealRequested: (cb: (p: string) => void) => void; + onExportMarkdownRequested: (cb: () => void) => void; + onExportZipRequested: (cb: () => void) => void; + onExportPdfRequested: (cb: () => void) => void; + onExportDocxRequested: (cb: () => void) => void; + onExportOdtRequested: (cb: () => void) => void; + onUndoRequested: (cb: () => void) => void; + onRedoRequested: (cb: () => void) => void; + onSelectAllRequested: (cb: () => void) => void; + onSaveRequested: (cb: () => void) => void; + onZoomInRequested: (cb: () => void) => void; + onZoomOutRequested: (cb: () => void) => void; + onZoomResetRequested: (cb: () => void) => void; +}; + +// ---- P7 decomposition contracts ---- + +// Mutable session state shared by every feature module. The fields mirror the +// former initBook closure locals; the object is passed by reference so +// mutations stay visible everywhere (identical semantics to the closure). +export interface SharedState { + currentBook: OpenedBook | null; + meta: BookMeta | null; + ordered: ChapterEntry[]; + selectedId: string | null; + dirty: boolean; + loadedDocJson: string; +} + +// Late-bound cross-feature operations. Features call these at event time only +// (never during construction), and the orchestrator populates the registry +// before initBook finishes wiring listeners, so no feature needs to import +// another. +export interface BookHooks { + // editor feature + selectChapter(id: string): Promise; + saveCurrentChapter(): Promise; + saveAndNotify(): Promise; + flushAutosave(): Promise; + clearTimers(): void; + // session coordinators (orchestrator) + refreshMeta(): Promise; + showPlaceholder(): void; + // full-book feature + refreshFullBook(): Promise; + selectFullBook(): Promise; + renderChaptersToPrint(ids: string[]): Promise; + resetFullBookNav(): void; + isFullBookVisible(): boolean; + fullBookNotice(text: string): void; + // chapter-list feature + renderChapterList(): void; + setChapterButtons(enabled: boolean): void; + // comments feature + updateCommentsBadge(): void; + // search feature + scrollToTerm(term: string, matchCase: boolean): void; + // exports feature + exportMarkdown(): void; + exportZip(): void; + exportPdf(chapterIds?: string[] | null): Promise; + exportDocx(chapterIds?: string[] | null): Promise; + exportOdt(chapterIds?: string[] | null): Promise; + migrateBookNative(): Promise; + exportFromMenu(kind: string): Promise; +} + +export interface BookCtx { + api: FolioAPI; + state: SharedState; + hooks: BookHooks; +} diff --git a/src/renderer/book/ui.ts b/src/renderer/book/ui.ts new file mode 100644 index 0000000..da1e513 --- /dev/null +++ b/src/renderer/book/ui.ts @@ -0,0 +1,171 @@ +// Shared DOM + text helpers for the book feature modules. Everything here is +// stateless (or owns only its own widget state) and depends on nothing else +// in the book/ folder. + +import type { Editor } from "@tiptap/core"; + +export const $ = (id: string) => document.getElementById(id) as HTMLElement; + +// Concatenated plain text of the current editor selection (" " between block +// nodes). Used by link/wikilink prompts and comment quotes. +export function selectedText(editor: Editor): string { + const { from, to } = editor.state.selection; + return editor.state.doc.textBetween(from, to, " "); +} + +// Open/close helpers that drive the CSS open/close animations. `closeEl` +// adds `.closing` so the exit animation can play, then hides after it ends +// (with a fallback timer). `openEl` cancels any pending close so rapid +// toggles don't leave an element stuck hidden. +export function openEl(el: HTMLElement) { + const pending = (el as unknown as { __close?: () => void }).__close; + if (pending) pending(); + el.classList.remove("hidden", "closing"); +} + +export function closeEl(el: HTMLElement | null) { + if (!el || el.classList.contains("hidden")) return; + el.classList.add("closing"); + let done = false; + const finish = () => { + if (done) return; + done = true; + el.classList.add("hidden"); + el.classList.remove("closing"); + el.removeEventListener("animationend", onEnd); + delete (el as unknown as { __close?: () => void }).__close; + }; + const onEnd = () => finish(); + el.addEventListener("animationend", onEnd); + const timer = setTimeout(finish, 420); + (el as unknown as { __close?: () => void }).__close = () => { + done = true; + clearTimeout(timer); + el.removeEventListener("animationend", onEnd); + el.classList.remove("closing"); + delete (el as unknown as { __close?: () => void }).__close; + }; +} + +export function setActive(cmd: string, on: boolean) { + document.querySelectorAll(`[data-cmd="${cmd}"]`).forEach((el) => el.classList.toggle("active", on)); +} + +let toastTimer: ReturnType | null = null; +let toastEl: HTMLElement | null = null; +export function showToast(msg: string) { + if (!toastEl) toastEl = $("toast"); + if (!toastEl) return; + toastEl.textContent = msg; + toastEl.classList.remove("hidden"); + toastEl.classList.add("show"); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toastEl!.classList.remove("show"); + toastTimer = setTimeout(() => toastEl!.classList.add("hidden"), 200); + }, 1400); +} + +// Toast with an Undo action, used after destructive operations (delete book, +// delete/cut chapter). Auto-dismisses; the action runs once if tapped. +let undoToastTimer: ReturnType | null = null; +export function hideUndoToast(): void { + const el = $("undoToast"); + if (!el) return; + el.classList.remove("show"); + // Drop the handler so a second click during the hide animation cannot + // run the undo twice (restore would duplicate the chapter entry). + const btn = $("undoToastBtn") as HTMLButtonElement | null; + if (btn) btn.onclick = null; + setTimeout(() => el.classList.add("hidden"), 200); +} +export function showUndoToast(msg: string, onUndo: () => void, timeout = 9000): void { + const el = $("undoToast"); + if (!el) { + onUndo(); + return; + } + const msgEl = $("undoToastMsg"); + const btn = $("undoToastBtn") as HTMLButtonElement | null; + if (msgEl) msgEl.textContent = msg; + if (btn) + btn.onclick = () => { + // Single-fire: detach before running so double-clicks are ignored. + btn.onclick = null; + hideUndoToast(); + onUndo(); + }; + el.classList.remove("hidden"); + el.classList.add("show"); + if (undoToastTimer) clearTimeout(undoToastTimer); + undoToastTimer = setTimeout(hideUndoToast, timeout); +} + +export function basename(p: string): string { + return p.split(/[/\\]/).pop() || p; +} + +export function countWords(text: string): number { + const trimmed = text.trim(); + if (!trimmed) return 0; + return trimmed.split(/\s+/).length; +} + +// Word-count label with an approximate reading-time estimate (~200 wpm). +export function formatWordCount(text: string): string { + const w = countWords(text); + return w ? `words: ${w} · ~${Math.ceil(w / 200)} min read` : "words: 0"; +} + +export function moveInList(items: T[], fromIndex: number, toIndex: number): T[] { + if (fromIndex === toIndex) return items; + const copy = items.slice(); + const [moved] = copy.splice(fromIndex, 1); + copy.splice(toIndex, 0, moved); + return copy; +} + +// Reduce Markdown to searchable plain text (headings, emphasis, links, etc. +// are flattened so a search matches what the reader actually sees). +export function markdownToPlain(md: string): string { + return md + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^ {0,3}#{1,6}\s+/gm, "") + .replace(/^\s{0,3}>+\s?/gm, "") + .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1") + .replace(/^\s*[-*+]\s+/gm, "• ") + .replace(/\r?\n\s*[-*_]{3,}\s*\r?\n/g, " ") + .replace(/\|/g, " ") + .replace(/[ \t]+/g, " "); +} + +export function findMatches(plain: string, term: string, matchCase: boolean): number[] { + const out: number[] = []; + const hay = matchCase ? plain : plain.toLowerCase(); + const needle = matchCase ? term : term.toLowerCase(); + if (!needle) return out; + let idx = hay.indexOf(needle); + while (idx !== -1 && out.length < 200) { + out.push(idx); + idx = hay.indexOf(needle, idx + needle.length); + } + return out; +} + +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +export function makeSnippet(plain: string, pos: number, len: number): string { + const start = Math.max(0, pos - 40); + const end = Math.min(plain.length, pos + len + 40); + const before = plain.slice(start, pos); + const match = plain.slice(pos, pos + len); + const after = plain.slice(pos + len, end); + const pre = start > 0 ? "…" : ""; + const post = end < plain.length ? "…" : ""; + return escapeHtml(pre + before) + "" + escapeHtml(match) + "" + escapeHtml(after) + post; +} diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts new file mode 100644 index 0000000..0c2bee1 --- /dev/null +++ b/src/renderer/book/welcome.ts @@ -0,0 +1,248 @@ +// Welcome screen, recents, and session coordination (open/close/new book). +// +// Owns the welcome DOM chrome and the book-session entry points. Metadata +// loading (loadMeta/refreshMeta) lives here as the orchestrator-side +// coordinator; features reach it via hooks.refreshMeta. +// +// Cross-feature calls go through BookHooks exclusively — no sibling imports. + +import type { BookCtx } from "./types.js"; +import type { ChapterEntry, Recent } from "./types.js"; +import type { BookMeta } from "../../main/project.js"; +import { alertMessage, promptInput } from "../dialog.js"; +import { $, showUndoToast } from "./ui.js"; + +export interface WelcomeFeature { + showWelcome(): Promise; + showPlaceholder(): void; + setCurrentBook(data: { path: string; title: string }): void; + refreshMeta(): Promise; + startNewBook(): Promise; + startOpenBook(): Promise; + openPath(p: string): Promise; +} + +export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { + const { api, state, hooks } = ctx; + + const welcomeEl = $("welcome"); + const chapterView = $("chapterView"); + const fullBookView = $("fullBookView"); + const editorPlaceholder = $("editorPlaceholder"); + const projectBar = $("projectBar"); + const fullBookBtn = $("fullBookBtn") as HTMLButtonElement; + const bookTitleEl = $("bookTitle"); + const bookPathEl = $("bookPath"); + const recentList = $("recentList") as HTMLUListElement; + const homeBtn = $("homeBtn") as HTMLButtonElement | null; + + async function showWelcome() { + welcomeEl.classList.remove("hidden"); + chapterView.classList.add("hidden"); + fullBookView.classList.add("hidden"); + editorPlaceholder.classList.add("hidden"); + // Keep the project bar visible so the Home button is a persistent nav + // control. On Home it is shown disabled/active (you are already here). + projectBar.classList.remove("hidden"); + bookTitleEl.textContent = ""; + bookTitleEl.classList.add("hidden"); + bookPathEl.classList.add("hidden"); + bookPathEl.title = ""; + $("revealBtn")?.classList.add("hidden"); + if (homeBtn) { + homeBtn.disabled = true; + homeBtn.classList.add("active"); + homeBtn.setAttribute("aria-current", "page"); + } + state.currentBook = null; + state.meta = null; + state.ordered = []; + state.selectedId = null; + hooks.setChapterButtons(false); + fullBookBtn.classList.remove("active"); + hooks.resetFullBookNav(); + hooks.renderChapterList(); + void renderRecents(); + } + + function showPlaceholder() { + chapterView.classList.add("hidden"); + fullBookView.classList.add("hidden"); + fullBookBtn.classList.remove("active"); + editorPlaceholder.classList.remove("hidden"); + } + + function setCurrentBook(data: { path: string; title: string }) { + state.currentBook = data; + // The "Home" label is part of the button itself (see index.html), so the + // legacy bookTitle span stays out of the way. + bookTitleEl.textContent = ""; + bookTitleEl.classList.add("hidden"); + bookPathEl.classList.add("hidden"); + $("revealBtn")?.classList.remove("hidden"); + if (homeBtn) { + homeBtn.disabled = false; + homeBtn.classList.remove("active"); + homeBtn.removeAttribute("aria-current"); + } + projectBar.classList.remove("hidden"); + welcomeEl.classList.add("hidden"); + state.selectedId = null; + void loadMeta(); + } + + async function loadMeta() { + const res = await api.getBookMeta(); + if (res && typeof res === "object" && "error" in res) { + await alertMessage("Could not load book", (res as { error: string }).error); + state.meta = null; + } else { + const meta = res as BookMeta; + state.meta = meta; + state.ordered = meta.chapterOrder + .map((id) => meta.chapters[id]) + .filter(Boolean) as ChapterEntry[]; + } + hooks.setChapterButtons(!!state.meta); + hooks.renderChapterList(); + // Preserve the chapter currently being read. Metadata refreshes fire on + // every rename/color/reorder, and jumping back to the first chapter + // here would yank the editor out from under the user mid-session. + const keepId = + state.selectedId && state.meta?.chapters[state.selectedId] ? state.selectedId : null; + if (keepId) { + const entry = state.meta!.chapters[keepId]; + $("chapterTitle").textContent = entry.title || keepId; + hooks.updateCommentsBadge(); + await hooks.refreshFullBook(); + return; + } + if (state.ordered.length) { + await hooks.selectChapter(state.ordered[0].id); + } else { + showPlaceholder(); + } + await hooks.refreshFullBook(); + } + + async function refreshMeta() { + await loadMeta(); + if (state.meta && state.ordered.length === 0) { + state.selectedId = null; + showPlaceholder(); + } + } + + async function handleResult(res: any) { + if (!res) return; + if ("canceled" in res) return; + if ("error" in res) { await alertMessage("Error", (res as { error: string }).error); return; } + if ("path" in res) setCurrentBook({ path: res.path, title: res.title }); + } + + async function startNewBook() { + const name = await promptInput("New book", "My Book"); + if (name === null) return; + const trimmed = name.trim(); + if (!trimmed) return; + const res = await api.newBookNamed(trimmed); + await handleResult(res); + await renderRecents(); + } + + async function startOpenBook() { + const res = await api.openBook(); + await handleResult(res); + await renderRecents(); + } + + async function openPath(p: string) { + const res = await api.openBookPath(p); + await handleResult(res); + await renderRecents(); + } + + async function renderRecents() { + try { + const recents = await api.getRecents(); + recentList.innerHTML = ""; + if (!recents.length) { + const li = document.createElement("li"); + li.className = "muted"; + li.textContent = "No recent books."; + recentList.appendChild(li); + return; + } + for (const r of recents as (Recent & { missing?: boolean })[]) { + const li = document.createElement("li"); + li.className = "recent-item"; + const label = document.createElement("span"); + label.className = "recent-title"; + label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : ""); + label.title = r.path; + label.addEventListener("mousedown", (e) => { + e.preventDefault(); + void openPath(r.path); + }); + const del = document.createElement("button"); + del.className = "recent-delete icon-button"; + del.type = "button"; + del.title = "Delete book"; + del.setAttribute("aria-label", "Delete book"); + del.textContent = "×"; + del.addEventListener("click", async (e) => { + e.stopPropagation(); + const res = await api.deleteBook(r.path); + if (res && "error" in res) { + await alertMessage("Could not delete", (res as { error: string }).error); + return; + } + await renderRecents(); + // Only promise an in-app Undo where restore actually works (Linux + // home trash). Elsewhere the book sits in the OS trash, which the + // user can restore manually — no false "Undo" button. + if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return; + showUndoToast(`Deleted "${r.title || r.path}"`, async () => { + const rr = await api.restoreBook(r.path); + if (rr && "error" in rr) { + await alertMessage("Could not restore", (rr as { error: string }).error); + return; + } + await renderRecents(); + }); + }); + li.appendChild(label); + li.appendChild(del); + recentList.appendChild(li); + } + } catch { + // Recents are best-effort; never block the welcome screen. + } + } + + // ---- wiring (bind once at construction) ---- + $("welcomeNewBook").addEventListener("click", () => void startNewBook()); + $("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); + homeBtn?.addEventListener("click", async () => { + hooks.clearTimers(); + if (state.selectedId && state.dirty) { + // Auto-save silently instead of prompting, so closing the book never + // loses the chapter's text. + await hooks.saveCurrentChapter(); + } + void api.closeBook(); + }); + $("revealBtn")?.addEventListener("click", () => { + if (state.currentBook) void api.revealInFolder(state.currentBook.path); + }); + + return { + showWelcome, + showPlaceholder, + setCurrentBook, + refreshMeta, + startNewBook, + startOpenBook, + openPath, + }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 513a261..31dcf2d 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -229,23 +229,23 @@ html, body { } .full-book-btn { - width: 100%; - text-align: left; - border: 1px solid var(--color-border); + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px dashed var(--color-border); border-radius: var(--radius); background: transparent; - color: var(--color-text); - font-family: var(--font-serif); - font-size: 0.95rem; - padding: 8px 10px; + color: var(--color-muted); + font-family: var(--font-sans); + font-size: 0.85rem; + padding: 6px 12px; cursor: pointer; transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + min-width: 100px; } .full-book-btn:hover { - background: var(--color-accent); - color: var(--color-on-accent); - border-color: var(--color-accent); + border-style: solid; } .full-book-btn.active { @@ -289,7 +289,7 @@ html, body { flex: 1; display: flex; background: var(--color-bg); - overflow: hidden; + overflow: visible; } .editor-placeholder { @@ -383,10 +383,27 @@ html, body { gap: 4px; } +/* Label lives inside the button so Home reads consistently on every + screen (welcome included). Matches the old book-title typography. */ +#homeBtn .home-label { + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; +} + /* The chapters "+" must stay clearly visible (white) on dark surfaces. */ :root[data-theme="dark-mode"] #addChapterBtn, :root[data-theme="neon"] #addChapterBtn, -:root[data-theme="meridian"] #addChapterBtn { +:root[data-theme="meridian"] #addChapterBtn, +:root[data-theme="impeccable-dark"] #addChapterBtn { + color: #ffffff; +} + +/* Home label: black text in light mode, white text in dark/neon/meridian themes. */ +:root[data-theme="dark-mode"] #homeBtn .home-label, +:root[data-theme="neon"] #homeBtn .home-label, +:root[data-theme="meridian"] #homeBtn .home-label, +:root[data-theme="impeccable-dark"] #homeBtn .home-label { color: #ffffff; } @@ -403,7 +420,9 @@ html, body { :root[data-theme="neon"] .brand-icon-light, :root[data-theme="neon"] .welcome-brand-light, :root[data-theme="meridian"] .brand-icon-light, -:root[data-theme="meridian"] .welcome-brand-light { +:root[data-theme="meridian"] .welcome-brand-light, +:root[data-theme="impeccable-dark"] .brand-icon-light, +:root[data-theme="impeccable-dark"] .welcome-brand-light { display: none; } :root[data-theme="dark-mode"] .brand-icon-dark, @@ -411,7 +430,9 @@ html, body { :root[data-theme="neon"] .brand-icon-dark, :root[data-theme="neon"] .welcome-brand-dark, :root[data-theme="meridian"] .brand-icon-dark, -:root[data-theme="meridian"] .welcome-brand-dark { +:root[data-theme="meridian"] .welcome-brand-dark, +:root[data-theme="impeccable-dark"] .brand-icon-dark, +:root[data-theme="impeccable-dark"] .welcome-brand-dark { display: inline-block; } @@ -732,7 +753,7 @@ body.dragging-active * { z-index: 1000; background: var(--color-panel); color: var(--color-text); - border: 1px solid var(--color-border); + border: none; border-radius: var(--radius); padding: 8px; box-shadow: 0 8px 24px var(--color-shadow-strong); @@ -977,7 +998,7 @@ body.dragging-active * { border-radius: 2px; } #printBook .callout { - border-left: 4px solid #3b82f6; + border-left: 1px solid #3b82f6; background: #f0f6ff; padding: 0.6em 1em; margin: 0 0 1.7em; @@ -1025,7 +1046,7 @@ body.dragging-active * { display: flex; align-items: center; flex-wrap: nowrap; - overflow-x: auto; + overflow: visible; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--color-border); @@ -1185,9 +1206,9 @@ body.dragging-active * { z-index: 60; min-width: 190px; background: var(--color-panel); - border: 1px solid var(--color-border); + border: none; border-radius: var(--radius); - box-shadow: 0 10px 30px var(--color-shadow); + box-shadow: 0 8px 16px var(--color-shadow); padding: 4px; display: flex; flex-direction: column; @@ -1410,11 +1431,11 @@ body.focus-mode .editor { .modal { background: var(--color-panel); - border: 1px solid var(--color-border); + border: none; border-radius: var(--radius); padding: var(--gap); width: min(420px, calc(100vw - 48px)); - box-shadow: 0 12px 40px var(--color-shadow-strong); + box-shadow: 0 8px 16px var(--color-shadow-strong); display: flex; flex-direction: column; gap: 12px; @@ -1550,7 +1571,7 @@ body.focus-mode .editor { font-size: 0.82rem; font-style: italic; color: var(--color-muted); - border-left: 3px solid var(--color-accent); + border-left: 2px solid var(--color-accent); padding-left: 8px; white-space: pre-wrap; } @@ -1586,7 +1607,7 @@ body.focus-mode .editor { min-height: 100%; overflow-y: auto; font-family: var(--font-sans); - font-size: 1.05rem; + font-size: 16px; line-height: 1.7; color: var(--color-text); background: var(--color-bg); @@ -1606,7 +1627,7 @@ body.focus-mode .editor { .folio-prose p { margin: 0 0 0.8em; } .folio-prose ul, .folio-prose ol { padding-left: 1.4em; margin: 0 0 0.8em; } .folio-prose blockquote { - border-left: 3px solid var(--color-accent); + border-left: 2px solid var(--color-accent); margin: 0 0 0.8em; padding: 0.2em 0 0.2em 1em; color: var(--color-text-dim); diff --git a/src/renderer/index.html b/src/renderer/index.html index 7c67f30..b3ea9bc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -20,7 +20,7 @@ @@ -37,6 +37,8 @@ + + @@ -46,7 +48,7 @@ Folio Folio

    Folio

    -

    Your stories, in folders you choose.

    +

    Your documents, organized your way.

    @@ -105,23 +107,29 @@
    diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 1c6fe56..09c6712 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,5 +1,5 @@ -import { initBook } from "./book.js"; -import type { FolioAPI } from "./book.js"; +import { initBook } from "./book/index.js"; +import type { FolioAPI } from "./book/index.js"; import { initTheme } from "./theme.js"; declare global { diff --git a/src/renderer/native-assembly.ts b/src/renderer/native-assembly.ts index 324144e..b6bb8c3 100644 --- a/src/renderer/native-assembly.ts +++ b/src/renderer/native-assembly.ts @@ -4,12 +4,12 @@ // in a single read-only ProseMirror surface, separated by ChapterBreak anchors. // These helpers are kept free of DOM/IPC so they can be unit-tested directly // and reused by the renderer (book.ts). -import type { FolioNode } from "../main/native-doc.js"; +import type { FolioNode } from "../shared/native-doc-core.js"; import { nativeNodeText, nativeHasTitleHeading, buildChapterNativeContent, -} from "../main/native-doc.js"; +} from "../shared/native-doc-core.js"; import type { WikilinkTarget } from "./wikilinks.js"; // The title-heading helpers are shared with the DOCX/ODT exporter and live diff --git a/src/renderer/print-format.ts b/src/renderer/print-format.ts index 673a28b..acf89c2 100644 --- a/src/renderer/print-format.ts +++ b/src/renderer/print-format.ts @@ -5,11 +5,11 @@ // formatting feature the editor can write. Chapters are emitted as //