refactor(renderer): split book.ts into 12 focused modules
- Extract chapter-list, editor, exports, full-book, search, toolbar, ui, welcome, comments, types - Add shared native-doc-core.ts for document contracts - Update main process, menu, and native-doc for new structure - Theme and CSS updates
This commit is contained in:
parent
b5db582538
commit
a2d2d4858b
26 changed files with 3298 additions and 2554 deletions
49
.impeccable/config.json
Normal file
49
.impeccable/config.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -144,9 +144,13 @@ function notifyChange() {
|
||||||
// unhandled promise rejection that the UI would silently swallow.
|
// unhandled promise rejection that the UI would silently swallow.
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
function handleIpc(channel: string, fn: (...args: any[]) => unknown): void {
|
function handleIpc(channel: string, fn: (...args: any[]) => unknown): void {
|
||||||
ipcMain.handle(channel, async (_e, ...args: any[]) => {
|
ipcMain.handle(channel, async (event, ...args: any[]) => {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[folio] ${channel} failed: ${msg}`);
|
console.error(`[folio] ${channel} failed: ${msg}`);
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ export function buildMenu(
|
||||||
const fileMenu: MenuItem = {
|
const fileMenu: MenuItem = {
|
||||||
label: "File",
|
label: "File",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew },
|
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
|
||||||
{ label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
|
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "Save",
|
label: "Save",
|
||||||
|
|
|
||||||
|
|
@ -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
|
// The pure document model — types, format constants, serialization and
|
||||||
// migrated to the native format. The document is a ProseMirror/Tiptap JSON
|
// migration logic — lives in src/shared/native-doc-core.ts so the renderer
|
||||||
// tree, not Markdown. See Stage 2 of the rich-text migration plan.
|
// 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):
|
// Persistence layout (per book):
|
||||||
// folio.json -> book metadata (chapterOrder, chapters, docFormat, ...)
|
// folio.json -> book metadata (chapterOrder, chapters, docFormat, ...)
|
||||||
// chapters/<id>.json -> { schemaVersion, doc } (native content)
|
// chapters/<id>.json -> { schemaVersion, doc } (native content)
|
||||||
// assets/ -> copied image assets referenced by chapters
|
// 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 fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
@ -19,135 +18,14 @@ import {
|
||||||
saveMeta,
|
saveMeta,
|
||||||
chaptersDir,
|
chaptersDir,
|
||||||
} from "./project.js";
|
} from "./project.js";
|
||||||
|
import {
|
||||||
|
nativeChapterFile,
|
||||||
|
serializeChapter,
|
||||||
|
parseChapter,
|
||||||
|
type FolioChapterDoc,
|
||||||
|
} from "../shared/native-doc-core.js";
|
||||||
|
|
||||||
export interface FolioMark {
|
export * from "../shared/native-doc-core.js";
|
||||||
type: string;
|
|
||||||
attrs?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FolioNode {
|
|
||||||
type: string;
|
|
||||||
attrs?: Record<string, unknown>;
|
|
||||||
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/<id>. 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<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};
|
|
||||||
|
|
||||||
// File name for a native chapter (replaces <id>.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<string, any>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read a native chapter document (throws if the chapter is not native JSON).
|
// Read a native chapter document (throws if the chapter is not native JSON).
|
||||||
export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc {
|
export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc {
|
||||||
|
|
@ -170,9 +48,4 @@ export function setChapterDoc(
|
||||||
fs.writeFileSync(file, serializeChapter(doc), "utf-8");
|
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 };
|
export { loadBook, saveMeta };
|
||||||
|
|
|
||||||
2365
src/renderer/book.ts
2365
src/renderer/book.ts
File diff suppressed because it is too large
Load diff
384
src/renderer/book/chapter-list.ts
Normal file
384
src/renderer/book/chapter-list.ts
Normal file
|
|
@ -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 = `
|
||||||
|
<span class="drag-handle" title="Drag to reorder">⋮</span>
|
||||||
|
<span class="row-title" title="${ch.title}">${ch.title}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button class="icon-btn" data-act="color" title="Chapter color">●</button>
|
||||||
|
<button class="icon-btn" data-act="rename" title="Rename">✎</button>
|
||||||
|
<button class="icon-btn" data-act="dup" title="Duplicate">⧉</button>
|
||||||
|
<button class="icon-btn" data-act="del" title="Delete">×</button>
|
||||||
|
</span>`;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
143
src/renderer/book/comments.ts
Normal file
143
src/renderer/book/comments.ts
Normal file
|
|
@ -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 = `<li class="muted">Open a chapter first.</li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = chapterComments(id);
|
||||||
|
if (!list.length) {
|
||||||
|
commentsBody.innerHTML = `<li class="muted">No comments in this chapter yet.</li>`;
|
||||||
|
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<void> {
|
||||||
|
if (!state.meta) return;
|
||||||
|
await api.setComments(state.meta.comments || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComment(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
312
src/renderer/book/editor.ts
Normal file
312
src/renderer/book/editor.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
saveCurrentChapter(): Promise<void>;
|
||||||
|
saveAndNotify(): Promise<void>;
|
||||||
|
flushAutosave(): Promise<void>;
|
||||||
|
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<typeof setTimeout> | null = null;
|
||||||
|
const saveStateEl = $("saveState");
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentChapter(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<HTMLElement>(".editor-panes").forEach((el) => {
|
||||||
|
el.style.zoom = String(clamped / 100);
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLElement>(".zoom-level").forEach((el) => {
|
||||||
|
el.textContent = clamped + "%";
|
||||||
|
});
|
||||||
|
if (persist) void api.setZoom(clamped);
|
||||||
|
}
|
||||||
|
document.querySelectorAll<HTMLElement>(".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 = `
|
||||||
|
<h4>Current chapter</h4>
|
||||||
|
<ul class="stats-list">
|
||||||
|
<li>Words: <strong>${cur.words}</strong></li>
|
||||||
|
<li>Characters: <strong>${cur.chars}</strong> (${cur.charsNoSpace} without spaces)</li>
|
||||||
|
<li>Paragraphs: <strong>${paragraphs}</strong></li>
|
||||||
|
<li>Sentences: <strong>${cur.sentences}</strong></li>
|
||||||
|
<li>Reading time: <strong>~${cur.minutes} min</strong></li>
|
||||||
|
</ul>
|
||||||
|
<h4>Whole book</h4>
|
||||||
|
<ul class="stats-list">
|
||||||
|
<li>Words: <strong>${bookWords}</strong></li>
|
||||||
|
<li>Reading time: <strong>~${bookMinutes} min</strong></li>
|
||||||
|
</ul>`;
|
||||||
|
})();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
266
src/renderer/book/exports.ts
Normal file
266
src/renderer/book/exports.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
exportDocx(chapterIds?: string[] | null): Promise<void>;
|
||||||
|
exportOdt(chapterIds?: string[] | null): Promise<void>;
|
||||||
|
migrateBookNative(): Promise<void>;
|
||||||
|
exportFromMenu(kind: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<HTMLInputElement>(
|
||||||
|
'input[name="exportScope"][value="all"]'
|
||||||
|
);
|
||||||
|
const selRadio = exportModal.querySelector<HTMLInputElement>(
|
||||||
|
'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<HTMLInputElement>('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<HTMLInputElement>('input[name="exportScope"]')
|
||||||
|
.forEach((r) =>
|
||||||
|
r.addEventListener("change", () => {
|
||||||
|
const selected =
|
||||||
|
exportModal.querySelector<HTMLInputElement>('input[name="exportScope"]:checked')
|
||||||
|
?.value === "selected";
|
||||||
|
exportChapterList.classList.toggle("hidden", !selected);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
exportSelectAll.addEventListener("click", () => {
|
||||||
|
exportChapterItems
|
||||||
|
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
|
||||||
|
.forEach((cb) => (cb.checked = true));
|
||||||
|
});
|
||||||
|
exportClearAll.addEventListener("click", () => {
|
||||||
|
exportChapterItems
|
||||||
|
.querySelectorAll<HTMLInputElement>('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<HTMLInputElement>(
|
||||||
|
'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,
|
||||||
|
};
|
||||||
|
}
|
||||||
297
src/renderer/book/full-book.ts
Normal file
297
src/renderer/book/full-book.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
selectFullBook(): Promise<void>;
|
||||||
|
renderChaptersToPrint(ids: string[]): Promise<void>;
|
||||||
|
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<HTMLElement>("[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,
|
||||||
|
};
|
||||||
|
}
|
||||||
183
src/renderer/book/index.ts
Normal file
183
src/renderer/book/index.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
279
src/renderer/book/search.ts
Normal file
279
src/renderer/book/search.ts
Normal file
|
|
@ -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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<HTMLElement>(".search-result");
|
||||||
|
if (first) first.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { scrollToTerm };
|
||||||
|
}
|
||||||
402
src/renderer/book/toolbar.ts
Normal file
402
src/renderer/book/toolbar.ts
Normal file
|
|
@ -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<HTMLElement>(".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 <select>
|
||||||
|
// elements — doing so blocks the native dropdown from opening.
|
||||||
|
if ((e.target as HTMLElement).closest("button")) e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!(e.target as HTMLElement).closest(".toolbar-dropdown")) closeDropdowns();
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbar.addEventListener("click", (e) => {
|
||||||
|
const item = (e.target as HTMLElement).closest("[data-cmd], [data-action]") as HTMLElement | null;
|
||||||
|
if (!item) return;
|
||||||
|
closeDropdowns();
|
||||||
|
const cmd = item.dataset.cmd;
|
||||||
|
if (cmd === "fullscreen") { void toggleFullscreen(); return; }
|
||||||
|
if (cmd === "focus") { toggleFocus(); return; }
|
||||||
|
if (cmd === "painter") return; // painter removed in native editor
|
||||||
|
if (cmd) {
|
||||||
|
execToolbarCmd(cmd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleSpecial(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map a toolbar data-cmd to a Tiptap editor command.
|
||||||
|
function execToolbarCmd(cmd: string): void {
|
||||||
|
if (cmd === "link") {
|
||||||
|
void (async () => {
|
||||||
|
const sel = selectedText(editor);
|
||||||
|
const url = await promptInput("Link URL", sel.startsWith("http") ? sel : "https://");
|
||||||
|
if (url === null) return;
|
||||||
|
runCommand(editor, "link", { href: url || "https://" });
|
||||||
|
editor.commands.focus();
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === "removecolor") {
|
||||||
|
runCommand(editor, "color", {});
|
||||||
|
} else if (cmd === "removehl") {
|
||||||
|
runCommand(editor, "highlight", {});
|
||||||
|
} else if (cmd.startsWith("color:")) {
|
||||||
|
runCommand(editor, "color", { color: cmd.slice(6) });
|
||||||
|
} else if (cmd.startsWith("hcolor:")) {
|
||||||
|
runCommand(editor, "highlight", { color: cmd.slice(7) });
|
||||||
|
} else {
|
||||||
|
runCommand(editor, cmd);
|
||||||
|
}
|
||||||
|
editor.commands.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const customTextColor = toolbar.querySelector<HTMLInputElement>("#customTextColor");
|
||||||
|
customTextColor?.addEventListener("change", () => {
|
||||||
|
runCommand(editor, "color", { color: customTextColor.value });
|
||||||
|
editor.commands.focus();
|
||||||
|
closeDropdowns();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind toolbar dropdown toggles. The toolbar itself is a horizontal scroll
|
||||||
|
// container (overflow-x: auto), which would clip an absolutely-positioned
|
||||||
|
// menu, so an open menu is pinned to the viewport under its toggle instead.
|
||||||
|
toolbar.querySelectorAll<HTMLButtonElement>("button[data-dropdown]").forEach((toggle) => {
|
||||||
|
const menu = document.getElementById(toggle.dataset.dropdown ?? "");
|
||||||
|
if (!menu) return;
|
||||||
|
toggle.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const wasOpen = !menu.classList.contains("hidden");
|
||||||
|
closeDropdowns();
|
||||||
|
if (!wasOpen) {
|
||||||
|
openEl(menu as HTMLElement);
|
||||||
|
toggle.classList.add("open");
|
||||||
|
const rect = toggle.getBoundingClientRect();
|
||||||
|
const menuWidth = menu.offsetWidth;
|
||||||
|
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8));
|
||||||
|
menu.style.position = "fixed";
|
||||||
|
menu.style.top = `${rect.bottom + 4}px`;
|
||||||
|
menu.style.left = `${left}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A pinned menu goes stale if the page scrolls or the window resizes, so
|
||||||
|
// any of those closes the dropdowns.
|
||||||
|
window.addEventListener("scroll", closeDropdowns, true);
|
||||||
|
window.addEventListener("resize", closeDropdowns);
|
||||||
|
// Electron has no default right-click menu, so show one with Cut/Copy/Paste.
|
||||||
|
window.addEventListener("contextmenu", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
api.showContextMenu(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headingSelect = $("headingSelect") as HTMLSelectElement;
|
||||||
|
headingSelect.addEventListener("change", () => {
|
||||||
|
const v = Number(headingSelect.value);
|
||||||
|
if (v === 0) runCommand(editor, "paragraph");
|
||||||
|
else runCommand(editor, `h${v}`);
|
||||||
|
editor.commands.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom font-family dropdown. A native <select> 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<HTMLElement>(".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 {};
|
||||||
|
}
|
||||||
150
src/renderer/book/types.ts
Normal file
150
src/renderer/book/types.ts
Normal file
|
|
@ -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<any>;
|
||||||
|
deleteBook: (p: string) => Promise<any>;
|
||||||
|
restoreBook: (p: string) => Promise<any>;
|
||||||
|
openBook: () => Promise<any>;
|
||||||
|
openBookPath: (p: string) => Promise<any>;
|
||||||
|
revealInFolder: (p: string) => Promise<boolean>;
|
||||||
|
getRecents: () => Promise<any[]>;
|
||||||
|
closeBook: () => Promise<boolean>;
|
||||||
|
showContextMenu: (x: number, y: number) => void;
|
||||||
|
getBookMeta: () => Promise<BookMeta | { error: string }>;
|
||||||
|
setComments: (comments: Record<string, unknown>) => Promise<any>;
|
||||||
|
getChapterList: () => Promise<any[]>;
|
||||||
|
getChapterContent: (id: string) => Promise<string | { error: string }>;
|
||||||
|
createChapter: (title: string) => Promise<any>;
|
||||||
|
renameChapter: (id: string, title: string) => Promise<any>;
|
||||||
|
setChapterColor: (id: string, color: string | null) => Promise<any>;
|
||||||
|
deleteChapter: (id: string) => Promise<any>;
|
||||||
|
restoreChapter: (snap: ChapterSnapshot) => Promise<any>;
|
||||||
|
duplicateChapter: (id: string, title?: string) => Promise<any>;
|
||||||
|
reorderChapters: (newOrder: string[]) => Promise<any>;
|
||||||
|
saveChapter: (id: string, content: string) => Promise<any>;
|
||||||
|
migrateChapter: (id: string) => Promise<any>;
|
||||||
|
migrateBook: () => Promise<any>;
|
||||||
|
loadChapterDoc: (id: string) => Promise<any>;
|
||||||
|
saveChapterDoc: (id: string, doc: unknown) => Promise<any>;
|
||||||
|
importImageAsset: () => Promise<any>;
|
||||||
|
exportMarkdown: () => Promise<any>;
|
||||||
|
exportZip: () => Promise<any>;
|
||||||
|
exportPdf: (chapterIds?: string[] | null) => Promise<any>;
|
||||||
|
exportDocx: (chapterIds?: string[] | null) => Promise<any>;
|
||||||
|
exportOdt: (chapterIds?: string[] | null) => Promise<any>;
|
||||||
|
importAttachment: () => Promise<any>;
|
||||||
|
copyText: (text: string) => Promise<any>;
|
||||||
|
getClipboardText: () => Promise<string>;
|
||||||
|
toggleFullscreen: () => Promise<boolean>;
|
||||||
|
getTheme: () => Promise<string>;
|
||||||
|
setTheme: (id: string) => Promise<any>;
|
||||||
|
getZoom: () => Promise<number>;
|
||||||
|
setZoom: (pct: number) => Promise<any>;
|
||||||
|
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<void>;
|
||||||
|
saveCurrentChapter(): Promise<void>;
|
||||||
|
saveAndNotify(): Promise<void>;
|
||||||
|
flushAutosave(): Promise<void>;
|
||||||
|
clearTimers(): void;
|
||||||
|
// session coordinators (orchestrator)
|
||||||
|
refreshMeta(): Promise<void>;
|
||||||
|
showPlaceholder(): void;
|
||||||
|
// full-book feature
|
||||||
|
refreshFullBook(): Promise<void>;
|
||||||
|
selectFullBook(): Promise<void>;
|
||||||
|
renderChaptersToPrint(ids: string[]): Promise<void>;
|
||||||
|
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<void>;
|
||||||
|
exportDocx(chapterIds?: string[] | null): Promise<void>;
|
||||||
|
exportOdt(chapterIds?: string[] | null): Promise<void>;
|
||||||
|
migrateBookNative(): Promise<void>;
|
||||||
|
exportFromMenu(kind: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookCtx {
|
||||||
|
api: FolioAPI;
|
||||||
|
state: SharedState;
|
||||||
|
hooks: BookHooks;
|
||||||
|
}
|
||||||
171
src/renderer/book/ui.ts
Normal file
171
src/renderer/book/ui.ts
Normal file
|
|
@ -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<typeof setTimeout> | 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<typeof setTimeout> | 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<T>(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, "<").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) + "<mark>" + escapeHtml(match) + "</mark>" + escapeHtml(after) + post;
|
||||||
|
}
|
||||||
248
src/renderer/book/welcome.ts
Normal file
248
src/renderer/book/welcome.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
showPlaceholder(): void;
|
||||||
|
setCurrentBook(data: { path: string; title: string }): void;
|
||||||
|
refreshMeta(): Promise<void>;
|
||||||
|
startNewBook(): Promise<void>;
|
||||||
|
startOpenBook(): Promise<void>;
|
||||||
|
openPath(p: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -229,23 +229,23 @@ html, body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-book-btn {
|
.full-book-btn {
|
||||||
width: 100%;
|
display: inline-flex;
|
||||||
text-align: left;
|
align-items: center;
|
||||||
border: 1px solid var(--color-border);
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-muted);
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.95rem;
|
font-size: 0.85rem;
|
||||||
padding: 8px 10px;
|
padding: 6px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-book-btn:hover {
|
.full-book-btn:hover {
|
||||||
background: var(--color-accent);
|
border-style: solid;
|
||||||
color: var(--color-on-accent);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-book-btn.active {
|
.full-book-btn.active {
|
||||||
|
|
@ -289,7 +289,7 @@ html, body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-placeholder {
|
.editor-placeholder {
|
||||||
|
|
@ -383,10 +383,27 @@ html, body {
|
||||||
gap: 4px;
|
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. */
|
/* The chapters "+" must stay clearly visible (white) on dark surfaces. */
|
||||||
:root[data-theme="dark-mode"] #addChapterBtn,
|
:root[data-theme="dark-mode"] #addChapterBtn,
|
||||||
:root[data-theme="neon"] #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;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -403,7 +420,9 @@ html, body {
|
||||||
:root[data-theme="neon"] .brand-icon-light,
|
:root[data-theme="neon"] .brand-icon-light,
|
||||||
:root[data-theme="neon"] .welcome-brand-light,
|
:root[data-theme="neon"] .welcome-brand-light,
|
||||||
:root[data-theme="meridian"] .brand-icon-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;
|
display: none;
|
||||||
}
|
}
|
||||||
:root[data-theme="dark-mode"] .brand-icon-dark,
|
: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"] .brand-icon-dark,
|
||||||
:root[data-theme="neon"] .welcome-brand-dark,
|
:root[data-theme="neon"] .welcome-brand-dark,
|
||||||
:root[data-theme="meridian"] .brand-icon-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;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,7 +753,7 @@ body.dragging-active * {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
box-shadow: 0 8px 24px var(--color-shadow-strong);
|
box-shadow: 0 8px 24px var(--color-shadow-strong);
|
||||||
|
|
@ -977,7 +998,7 @@ body.dragging-active * {
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
#printBook .callout {
|
#printBook .callout {
|
||||||
border-left: 4px solid #3b82f6;
|
border-left: 1px solid #3b82f6;
|
||||||
background: #f0f6ff;
|
background: #f0f6ff;
|
||||||
padding: 0.6em 1em;
|
padding: 0.6em 1em;
|
||||||
margin: 0 0 1.7em;
|
margin: 0 0 1.7em;
|
||||||
|
|
@ -1025,7 +1046,7 @@ body.dragging-active * {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
overflow-x: auto;
|
overflow: visible;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
@ -1185,9 +1206,9 @@ body.dragging-active * {
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
min-width: 190px;
|
min-width: 190px;
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 10px 30px var(--color-shadow);
|
box-shadow: 0 8px 16px var(--color-shadow);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1410,11 +1431,11 @@ body.focus-mode .editor {
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: var(--gap);
|
padding: var(--gap);
|
||||||
width: min(420px, calc(100vw - 48px));
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -1550,7 +1571,7 @@ body.focus-mode .editor {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
border-left: 3px solid var(--color-accent);
|
border-left: 2px solid var(--color-accent);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
@ -1586,7 +1607,7 @@ body.focus-mode .editor {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 1.05rem;
|
font-size: 16px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
|
@ -1606,7 +1627,7 @@ body.focus-mode .editor {
|
||||||
.folio-prose p { margin: 0 0 0.8em; }
|
.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 ul, .folio-prose ol { padding-left: 1.4em; margin: 0 0 0.8em; }
|
||||||
.folio-prose blockquote {
|
.folio-prose blockquote {
|
||||||
border-left: 3px solid var(--color-accent);
|
border-left: 2px solid var(--color-accent);
|
||||||
margin: 0 0 0.8em;
|
margin: 0 0 0.8em;
|
||||||
padding: 0.2em 0 0.2em 1em;
|
padding: 0.2em 0 0.2em 1em;
|
||||||
color: var(--color-text-dim);
|
color: var(--color-text-dim);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
|
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-bar hidden" id="projectBar">
|
<div class="project-bar hidden" id="projectBar">
|
||||||
<button class="icon-button" id="homeBtn" title="Home" aria-label="Home"><span class="home-icon">🏠</span></button>
|
<button class="icon-button" id="homeBtn" title="Home" aria-label="Home"><span class="home-icon">🏠</span><span class="home-label">Home</span></button>
|
||||||
<span class="book-title" id="bookTitle"></span>
|
<span class="book-title" id="bookTitle"></span>
|
||||||
<span class="book-path" id="bookPath" title=""></span>
|
<span class="book-path" id="bookPath" title=""></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,6 +37,8 @@
|
||||||
<option value="dark-mode">Dark Mode</option>
|
<option value="dark-mode">Dark Mode</option>
|
||||||
<option value="neon">Neon</option>
|
<option value="neon">Neon</option>
|
||||||
<option value="meridian">Meridian</option>
|
<option value="meridian">Meridian</option>
|
||||||
|
<option value="impeccable">Impeccable Light</option>
|
||||||
|
<option value="impeccable-dark">Impeccable Dark</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,7 +48,7 @@
|
||||||
<img class="welcome-brand welcome-brand-light" src="./brand-icon-black.png" alt="Folio" draggable="false" />
|
<img class="welcome-brand welcome-brand-light" src="./brand-icon-black.png" alt="Folio" draggable="false" />
|
||||||
<img class="welcome-brand welcome-brand-dark" src="./brand-icon.png" alt="Folio" draggable="false" />
|
<img class="welcome-brand welcome-brand-dark" src="./brand-icon.png" alt="Folio" draggable="false" />
|
||||||
<h1 class="app-name">Folio</h1>
|
<h1 class="app-name">Folio</h1>
|
||||||
<p class="app-tagline">Your stories, in folders you choose.</p>
|
<p class="app-tagline">Your documents, organized your way.</p>
|
||||||
<div class="welcome-actions">
|
<div class="welcome-actions">
|
||||||
<button class="button" id="welcomeNewBook">New</button>
|
<button class="button" id="welcomeNewBook">New</button>
|
||||||
<button class="button button-ghost" id="welcomeOpenBook">Open</button>
|
<button class="button button-ghost" id="welcomeOpenBook">Open</button>
|
||||||
|
|
@ -105,23 +107,29 @@
|
||||||
<div class="dropdown-menu font-menu hidden" id="fontMenu"></div>
|
<div class="dropdown-menu font-menu hidden" id="fontMenu"></div>
|
||||||
</div>
|
</div>
|
||||||
<select id="fontSizeSelect" class="toolbar-select" title="Font size">
|
<select id="fontSizeSelect" class="toolbar-select" title="Font size">
|
||||||
<option value="">17px</option>
|
<option value="8px">8px</option>
|
||||||
|
<option value="9px">9px</option>
|
||||||
|
<option value="10px">10px</option>
|
||||||
|
<option value="11px">11px</option>
|
||||||
<option value="12px">12px</option>
|
<option value="12px">12px</option>
|
||||||
<option value="14px">14px</option>
|
<option value="14px">14px</option>
|
||||||
<option value="16px">16px</option>
|
<option value="16px" selected>16px</option>
|
||||||
<option value="18px">18px</option>
|
<option value="18px">18px</option>
|
||||||
<option value="20px">20px</option>
|
<option value="20px">20px</option>
|
||||||
<option value="24px">24px</option>
|
<option value="24px">24px</option>
|
||||||
|
<option value="28px">28px</option>
|
||||||
<option value="32px">32px</option>
|
<option value="32px">32px</option>
|
||||||
|
<option value="36px">36px</option>
|
||||||
|
<option value="48px">48px</option>
|
||||||
|
<option value="72px">72px</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="lineSpacingSelect" class="toolbar-select" title="Line spacing">
|
<select id="lineSpacingSelect" class="toolbar-select" title="Line spacing">
|
||||||
<option value="">1.7</option>
|
<option value="1.0" selected>1.0</option>
|
||||||
<option value="1">1.0</option>
|
|
||||||
<option value="1.15">1.15</option>
|
<option value="1.15">1.15</option>
|
||||||
|
<option value="1.25">1.25</option>
|
||||||
<option value="1.5">1.5</option>
|
<option value="1.5">1.5</option>
|
||||||
<option value="2">2.0</option>
|
<option value="1.75">1.75</option>
|
||||||
<option value="2.5">2.5</option>
|
<option value="2.0">2.0</option>
|
||||||
<option value="3">3.0</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { initBook } from "./book.js";
|
import { initBook } from "./book/index.js";
|
||||||
import type { FolioAPI } from "./book.js";
|
import type { FolioAPI } from "./book/index.js";
|
||||||
import { initTheme } from "./theme.js";
|
import { initTheme } from "./theme.js";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
// in a single read-only ProseMirror surface, separated by ChapterBreak anchors.
|
// 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
|
// These helpers are kept free of DOM/IPC so they can be unit-tested directly
|
||||||
// and reused by the renderer (book.ts).
|
// 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 {
|
import {
|
||||||
nativeNodeText,
|
nativeNodeText,
|
||||||
nativeHasTitleHeading,
|
nativeHasTitleHeading,
|
||||||
buildChapterNativeContent,
|
buildChapterNativeContent,
|
||||||
} from "../main/native-doc.js";
|
} from "../shared/native-doc-core.js";
|
||||||
import type { WikilinkTarget } from "./wikilinks.js";
|
import type { WikilinkTarget } from "./wikilinks.js";
|
||||||
|
|
||||||
// The title-heading helpers are shared with the DOCX/ODT exporter and live
|
// The title-heading helpers are shared with the DOCX/ODT exporter and live
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@
|
||||||
// formatting feature the editor can write. Chapters are emitted as
|
// formatting feature the editor can write. Chapters are emitted as
|
||||||
// <section class="print-chapter"> blocks, delimited by the chapterBreak nodes
|
// <section class="print-chapter"> blocks, delimited by the chapterBreak nodes
|
||||||
// the Full Book assembly inserts between chapters.
|
// the Full Book assembly inserts between chapters.
|
||||||
import type { FolioMark, FolioNode } from "../main/native-doc.js";
|
import type { FolioMark, FolioNode } from "../shared/native-doc-core.js";
|
||||||
import {
|
import {
|
||||||
CALLOUT_TITLE_CLASS,
|
CALLOUT_TITLE_CLASS,
|
||||||
CHAPTER_LINK_PREFIX,
|
CHAPTER_LINK_PREFIX,
|
||||||
} from "../main/native-doc.js";
|
} from "../shared/native-doc-core.js";
|
||||||
|
|
||||||
// Native-document print rendering for the PDF-exported manuscript (#printBook).
|
// Native-document print rendering for the PDF-exported manuscript (#printBook).
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const LIGHT = "light";
|
||||||
// Themes with a real entry in themes.css + the selector. Anything else that
|
// Themes with a real entry in themes.css + the selector. Anything else that
|
||||||
// might linger in saved settings (a removed theme, a typo) falls back to
|
// might linger in saved settings (a removed theme, a typo) falls back to
|
||||||
// Light so the <select> never desyncs from its <option> list.
|
// Light so the <select> never desyncs from its <option> list.
|
||||||
const KNOWN = new Set(["light", "dark-mode", "neon", "meridian"]);
|
const KNOWN = new Set(["light", "dark-mode", "neon", "meridian", "impeccable", "impeccable-dark"]);
|
||||||
|
|
||||||
function normalizeTheme(id: unknown): string {
|
function normalizeTheme(id: unknown): string {
|
||||||
if (typeof id !== "string" || !id || id === "default") return LIGHT;
|
if (typeof id !== "string" || !id || id === "default") return LIGHT;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
--color-panel: #f5f1e9;
|
--color-panel: #f5f1e9;
|
||||||
--color-border: #e4e0d5;
|
--color-border: #e4e0d5;
|
||||||
--color-text: #2b2925;
|
--color-text: #2b2925;
|
||||||
--color-muted: #7c786d;
|
--color-muted: #6f6a60;
|
||||||
--color-accent: #2f6b5f;
|
--color-accent: #2f6b5f;
|
||||||
--color-accent-hover: #26584e;
|
--color-accent-hover: #26584e;
|
||||||
|
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
:root[data-theme="meridian"] .color-popover {
|
:root[data-theme="meridian"] .color-popover {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.09),
|
inset 0 1px 0 rgba(255, 255, 255, 0.09),
|
||||||
0 12px 40px var(--color-shadow-strong);
|
0 8px 16px var(--color-shadow-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The manuscript pane joins the glass: the writing region carries the same
|
/* The manuscript pane joins the glass: the writing region carries the same
|
||||||
|
|
@ -195,6 +195,13 @@
|
||||||
:root[data-theme="meridian"] .folio-prose {
|
:root[data-theme="meridian"] .folio-prose {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
/* Override index.css `.editor-panes .folio-prose.ProseMirror { background: var(--color-bg) }`
|
||||||
|
which otherwise wins by specificity/order and leaves a solid prose column
|
||||||
|
whose edges read as a dark seam in the middle of the glass field. */
|
||||||
|
:root[data-theme="meridian"] .editor-panes .folio-prose.ProseMirror {
|
||||||
|
background: transparent !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* If the runtime can't blur, drop the glass pretense and raise opacity so
|
/* If the runtime can't blur, drop the glass pretense and raise opacity so
|
||||||
text contrast never depends on backdrop-filter support. */
|
text contrast never depends on backdrop-filter support. */
|
||||||
|
|
@ -204,3 +211,172 @@
|
||||||
--color-panel: rgba(22, 22, 22, 0.94);
|
--color-panel: rgba(22, 22, 22, 0.94);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Impeccable (light) ----
|
||||||
|
Verbatim editorial system from https://impeccable.style (light variant):
|
||||||
|
lacquer/kinpaku/patina tokens, Alumni Sans display. Copied from
|
||||||
|
Nostr_Keynctr/frontend/src/styles.css :root[data-theme='impeccable'] — mapped
|
||||||
|
to Folio's --color-* tokens (original --bg/--surface naming kept as alias). */
|
||||||
|
:root[data-theme="impeccable"] {
|
||||||
|
--color-bg: oklch(97% 0.012 95);
|
||||||
|
--color-panel: oklch(99% 0.008 95);
|
||||||
|
--color-border: oklch(25% 0.02 95 / 0.16);
|
||||||
|
--color-text: oklch(25% 0.018 95);
|
||||||
|
--color-muted: oklch(45% 0.015 95);
|
||||||
|
--color-accent: oklch(77% 0.13 82);
|
||||||
|
--color-accent-hover: oklch(61% 0.085 78);
|
||||||
|
--color-on-accent: oklch(14% 0.018 95);
|
||||||
|
--color-on-accent-soft: oklch(77% 0.13 82 / 0.14);
|
||||||
|
--color-on-accent-border: oklch(25% 0.02 95 / 0.22);
|
||||||
|
--color-selection: oklch(77% 0.13 82 / 0.22);
|
||||||
|
--color-selection-strong: oklch(77% 0.13 82 / 0.42);
|
||||||
|
--color-shadow: oklch(25% 0.02 95 / 0.1);
|
||||||
|
--color-shadow-strong: oklch(25% 0.02 95 / 0.16);
|
||||||
|
--color-backdrop: oklch(25% 0.02 95 / 0.4);
|
||||||
|
--color-scrollbar: oklch(25% 0.02 95 / 0.22);
|
||||||
|
--color-scrollbar-hover: oklch(25% 0.02 95 / 0.38);
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 3px;
|
||||||
|
/* aliases for Nostr naming compatibility */
|
||||||
|
--bg: oklch(97% 0.012 95);
|
||||||
|
--surface: oklch(99% 0.008 95);
|
||||||
|
--surface-2: oklch(91% 0.012 95);
|
||||||
|
--surface-hover: oklch(92% 0.014 95);
|
||||||
|
--border: oklch(25% 0.02 95 / 0.16);
|
||||||
|
--border-strong: oklch(25% 0.02 95 / 0.42);
|
||||||
|
--text: oklch(25% 0.018 95);
|
||||||
|
--text-muted: oklch(45% 0.015 95);
|
||||||
|
--primary: oklch(77% 0.13 82);
|
||||||
|
--primary-hover: oklch(61% 0.085 78);
|
||||||
|
--primary-soft: oklch(77% 0.13 82 / 0.14);
|
||||||
|
--on-primary: oklch(14% 0.018 95);
|
||||||
|
--focus: oklch(77% 0.13 82);
|
||||||
|
--shadow: 0 2px 6px oklch(25% 0.02 95 / 0.1);
|
||||||
|
--shadow-modal: 0 10px 28px oklch(25% 0.02 95 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="impeccable"] h1,
|
||||||
|
html[data-theme="impeccable"] h2 {
|
||||||
|
font-family: "Alumni Sans", "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-weight: 300;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.05;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] .theme-label,
|
||||||
|
html[data-theme="impeccable"] .search-case,
|
||||||
|
html[data-theme="impeccable"] .recents-title,
|
||||||
|
html[data-theme="impeccable"] .chapter-header .word-count {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] .button.button-primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] .button.button-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] .button.button-ghost {
|
||||||
|
background: var(--color-panel);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] .chapter-list li.is-active {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-left: 1px solid var(--color-accent);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] .sidebar,
|
||||||
|
html[data-theme="impeccable"] .modal,
|
||||||
|
html[data-theme="impeccable"] .color-popover,
|
||||||
|
html[data-theme="impeccable"] .editor {
|
||||||
|
border-width: 1px;
|
||||||
|
box-shadow: 0 2px 6px var(--color-shadow);
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] code,
|
||||||
|
html[data-theme="impeccable"] .folio-prose code {
|
||||||
|
background: oklch(91% 0.012 95);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable"] input[type="text"],
|
||||||
|
html[data-theme="impeccable"] input[type="search"],
|
||||||
|
html[data-theme="impeccable"] select,
|
||||||
|
html[data-theme="impeccable"] textarea,
|
||||||
|
html[data-theme="impeccable"] .modal-input {
|
||||||
|
background: var(--color-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Impeccable Dark ----
|
||||||
|
Same kinpaku system, lacquer-deep base from the site's dark default.
|
||||||
|
Copied from Nostr_Keynctr/frontend/src/styles.css :root[data-theme='impeccable-dark'] */
|
||||||
|
:root[data-theme="impeccable-dark"] {
|
||||||
|
--color-bg: oklch(15% 0.006 95);
|
||||||
|
--color-panel: oklch(20% 0.006 95);
|
||||||
|
--color-border: oklch(78% 0 0 / 0.22);
|
||||||
|
--color-text: oklch(88% 0 0);
|
||||||
|
--color-muted: oklch(72% 0 0);
|
||||||
|
--color-accent: oklch(84% 0.19 80.46);
|
||||||
|
--color-accent-hover: oklch(77% 0.13 82);
|
||||||
|
--color-on-accent: oklch(14% 0.018 95);
|
||||||
|
--color-on-accent-soft: oklch(77% 0.13 82 / 0.14);
|
||||||
|
--color-on-accent-border: oklch(78% 0 0 / 0.35);
|
||||||
|
--color-selection: oklch(84% 0.19 80.46 / 0.28);
|
||||||
|
--color-selection-strong: oklch(84% 0.19 80.46 / 0.45);
|
||||||
|
--color-shadow: oklch(0% 0 0 / 0.35);
|
||||||
|
--color-shadow-strong: oklch(0% 0 0 / 0.45);
|
||||||
|
--color-backdrop: oklch(0% 0 0 / 0.55);
|
||||||
|
--color-scrollbar: oklch(78% 0 0 / 0.22);
|
||||||
|
--color-scrollbar-hover: oklch(78% 0 0 / 0.38);
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 3px;
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: oklch(15% 0.006 95);
|
||||||
|
--surface: oklch(20% 0.006 95);
|
||||||
|
--surface-2: oklch(25% 0.008 95);
|
||||||
|
--surface-hover: oklch(30% 0.008 95);
|
||||||
|
--border: oklch(78% 0 0 / 0.22);
|
||||||
|
--border-strong: oklch(80% 0 0 / 0.5);
|
||||||
|
--text: oklch(88% 0 0);
|
||||||
|
--text-muted: oklch(72% 0 0);
|
||||||
|
--primary: oklch(84% 0.19 80.46);
|
||||||
|
--primary-hover: oklch(77% 0.13 82);
|
||||||
|
--primary-soft: oklch(77% 0.13 82 / 0.14);
|
||||||
|
--on-primary: oklch(14% 0.018 95);
|
||||||
|
--focus: oklch(84% 0.19 80.46);
|
||||||
|
--shadow: 0 2px 6px oklch(0% 0 0 / 0.35);
|
||||||
|
--shadow-modal: 0 10px 28px oklch(0% 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable-dark"] h1,
|
||||||
|
html[data-theme="impeccable-dark"] h2 {
|
||||||
|
font-family: "Alumni Sans", "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable-dark"] .theme-label,
|
||||||
|
html[data-theme="impeccable-dark"] .search-case,
|
||||||
|
html[data-theme="impeccable-dark"] .recents-title {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable-dark"] .sidebar {
|
||||||
|
background: oklch(12% 0.006 95);
|
||||||
|
border-right-color: var(--color-border);
|
||||||
|
}
|
||||||
|
html[data-theme="impeccable-dark"] .chapter-list li.is-active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ import Table from "@tiptap/extension-table";
|
||||||
import TableRow from "@tiptap/extension-table-row";
|
import TableRow from "@tiptap/extension-table-row";
|
||||||
import TableHeader from "@tiptap/extension-table-header";
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
import TableCell from "@tiptap/extension-table-cell";
|
import TableCell from "@tiptap/extension-table-cell";
|
||||||
import type { FolioChapterDoc, FolioNode } from "../main/native-doc.js";
|
import type { FolioChapterDoc, FolioNode } from "../shared/native-doc-core.js";
|
||||||
|
|
||||||
// --- Custom marks matching the native Folio schema -------------------------
|
// --- Custom marks matching the native Folio schema -------------------------
|
||||||
// The native model uses `textColor` (not Tiptap's `textStyle`) so the
|
// The native model uses `textColor` (not Tiptap's `textStyle`) so the
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { MarkdownIt, StateInline } from "markdown-it";
|
import type { MarkdownIt, StateInline } from "markdown-it";
|
||||||
import { CHAPTER_LINK_PREFIX } from "../main/native-doc.js";
|
import { CHAPTER_LINK_PREFIX } from "../shared/native-doc-core.js";
|
||||||
|
|
||||||
export interface WikilinkTarget {
|
export interface WikilinkTarget {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
143
src/shared/native-doc-core.ts
Normal file
143
src/shared/native-doc-core.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// Native Folio rich-text document model — pure core.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Everything here must stay free of Node builtins (fs/path) and Electron:
|
||||||
|
// it is imported by both processes and bundled into the renderer. Disk IO
|
||||||
|
// lives in src/main/native-doc.ts, which re-exports this module.
|
||||||
|
|
||||||
|
export interface FolioMark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolioNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
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/<id>. 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<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};
|
||||||
|
|
||||||
|
// File name for a native chapter (replaces <id>.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<string, any>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue