Replace markdown editor with native Tiptap editor and add Home navigation

- Migrate renderer from md-editor/md-format to a Tiptap-based native editor
  (native-doc pipeline: native-doc, markdown-to-native, native-to-markdown,
  native-assembly, migration, backup)
- Use pointer-event-based chapter reordering with grab/grabbing cursors to
  avoid the Wayland prohibited-drop cursor while preserving reordering
- Add a persistent Home button that closes the open book and returns to the
  Folio library/welcome screen
- Update app icons, package deps, theme styles, and tests
This commit is contained in:
avi 2026-08-20 11:12:17 -05:00
commit 786a65ae2a
48 changed files with 5762 additions and 3999 deletions

39
src/main/backup.ts Normal file
View file

@ -0,0 +1,39 @@
// Recovery backups for legacy Markdown books before migration.
//
// Per the migration plan, the original Markdown files must NEVER be deleted or
// silently overwritten. Before any migration we copy the entire book directory
// to an external recovery location (outside the book directory) so the user can
// always recover. Backups are created at most once per book per process run.
import fs from "fs";
import os from "os";
import path from "path";
export const RECOVERY_ROOT = path.join(os.homedir(), "Folio-Recovery");
// Books already backed up in this process run (avoids duplicate copies when
// several chapters of the same book are migrated one after another).
const sessionBackedUp = new Set<string>();
// Returns the created backup directory, or null if it was already backed up
// during this process run.
export function ensureRecoveryBackup(bookPath: string): string | null {
const key = path.resolve(bookPath);
if (sessionBackedUp.has(key)) return null;
const bookName = path.basename(key) || "book";
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = path.join(RECOVERY_ROOT, `${bookName}-${stamp}`);
fs.mkdirSync(RECOVERY_ROOT, { recursive: true });
fs.cpSync(key, dest, { recursive: true });
sessionBackedUp.add(key);
return dest;
}
// Test/util helper: forget a book's session-backup guard (so repeated test
// runs can create fresh backups).
export function _resetSessionBackupGuard(): void {
sessionBackedUp.clear();
}

View file

@ -9,6 +9,22 @@ import {
makeChapterId,
saveMeta,
} from "./project.js";
import {
getChapterDoc,
setChapterDoc,
nativeChapterFile,
CURRENT_DOC_SCHEMA_VERSION,
type FolioChapterDoc,
} from "./native-doc.js";
import { markdownToNative } from "./markdown-to-native.js";
import { nativeToMarkdown } from "./native-to-markdown.js";
// A chapter stored as native Folio JSON (vs. legacy Markdown). The entry.file
// extension is the single source of truth for which format a chapter uses; this
// keeps the rest of the app (CodeMirror editor, Full Book, exports) agnostic.
function isNativeFile(file: string): boolean {
return file.endsWith(".json");
}
export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] {
return meta.chapterOrder
@ -16,24 +32,68 @@ export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] {
.filter(Boolean) as ChapterEntry[];
}
// Read a chapter's content as Markdown. Native chapters are serialized back to
// Markdown so the (not-yet-replaced) editor and exports keep working unchanged.
export function getChapterContent(bookPath: string, id: string): string {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
try {
if (isNativeFile(entry.file)) {
return nativeToMarkdown(getChapterDoc(bookPath, id));
}
return fs.readFileSync(path.join(bookPath, entry.file), "utf-8");
} catch {
return "";
}
}
// Write a chapter. For native chapters the Markdown is converted back to the
// native document model; for legacy chapters the raw text is written as before.
export function setChapterContent(bookPath: string, id: string, content: string): void {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
if (isNativeFile(entry.file)) {
setChapterDoc(bookPath, id, markdownToNative(content));
return;
}
fs.writeFileSync(path.join(bookPath, entry.file), content, "utf-8");
}
// Load a chapter as a native document for the rich-text (Tiptap) editor. Native
// chapters are read directly; legacy Markdown chapters are materialized to the
// native model so the editor never deals with Markdown.
export function loadChapterDoc(bookPath: string, id: string): FolioChapterDoc {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
if (isNativeFile(entry.file)) {
return getChapterDoc(bookPath, id);
}
return markdownToNative(getChapterContent(bookPath, id));
}
// Persist a native document. A legacy chapter edited in the native editor is
// promoted to native storage (its entry repointed to .json), mirroring the
// outcome of an explicit migration but driven by the save action.
export function saveChapterDoc(
bookPath: string,
id: string,
doc: FolioChapterDoc
): void {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
setChapterDoc(bookPath, id, doc);
if (!isNativeFile(entry.file)) {
entry.file = path.posix.join("chapters", nativeChapterFile(id));
meta.docFormat = "folio-doc";
meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION;
saveMeta(bookPath, meta);
}
}
export function createChapter(bookPath: string, title: string): ChapterEntry {
if (!title.trim()) throw new Error("Chapter title cannot be empty");
const meta = loadBook(bookPath);

View file

@ -2,7 +2,8 @@ import { ZipArchive } from "archiver";
import fs from "fs";
import path from "path";
import { loadBook } from "./project.js";
import { listChaptersInOrder } from "./chapters.js";
import { listChaptersInOrder, loadChapterDoc } from "./chapters.js";
import { nativeToMarkdown } from "./native-to-markdown.js";
export function combineMarkdown(bookPath: string): string {
const meta = loadBook(bookPath);
@ -12,9 +13,12 @@ export function combineMarkdown(bookPath: string): string {
parts.push("");
parts.push("_A book compiled with Folio._");
for (const ch of chapters) {
// Markdown export is a compatibility option: it is generated from the
// native document model rather than stored as the internal representation.
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
const doc = loadChapterDoc(bookPath, ch.id);
content = nativeToMarkdown(doc);
} catch {
content = "";
}

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron";
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron";
import type { OpenDialogOptions, SaveDialogOptions } from "electron";
import fs from "fs";
import path from "path";
@ -25,6 +25,8 @@ import {
import { combineMarkdown, buildZip } from "./export.js";
import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js";
import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
import { loadChapterDoc, saveChapterDoc } from "./chapters.js";
let mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null;
@ -120,9 +122,9 @@ function notifyChange() {
getWindow()?.webContents.send("folio:chapters-changed");
}
// Route undo/redo shortcuts to the renderer so CodeMirror's own history is
// used when the editor has focus, instead of Electron's native (menu-role)
// undo/redo which does not understand the editor's history.
// Route undo/redo/select-all shortcuts to the renderer so the active editor's
// own history is used when it has focus, instead of Electron's native
// (menu-role) undo/redo which does not understand the editor's history.
function forwardEditShortcuts(win: BrowserWindow) {
win.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || !input.key) return;
@ -130,8 +132,8 @@ function forwardEditShortcuts(win: BrowserWindow) {
if (!mod) return;
const key = input.key.toLowerCase();
if (key === "a" && !input.shift && !input.alt) {
// Route Ctrl+A to the renderer so a read-only CodeMirror view (Full Book)
// can select its whole document instead of the native page-wide selectAll.
// Route Ctrl+A to the renderer so a read-only view (Full Book) can select
// its whole document instead of the native page-wide selectAll.
event.preventDefault();
win.webContents.send("folio:selectAll-requested");
} else if (key === "z" && !input.shift) {
@ -422,6 +424,75 @@ ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
}
});
// Explicit, opt-in migration of a single chapter (or the whole book) to the
// native Folio document format. Never automatic — the UI triggers this. The
// migration pipeline makes a recovery backup and keeps the original .md files.
ipcMain.handle("folio:migrateChapter", (_e, id: string) => {
try {
return migrateChapterToNative(requireBook(), id);
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:migrateBook", () => {
try {
return migrateBookToNative(requireBook());
} catch (e) {
return { error: (e as Error).message };
}
});
// Load a chapter as a native document for the Tiptap editor. Legacy Markdown
// chapters are converted on the fly (see chapters.loadChapterDoc).
ipcMain.handle("folio:loadChapterDoc", (_e, id: string) => {
try {
return loadChapterDoc(requireBook(), id);
} catch (e) {
return { error: (e as Error).message };
}
});
// Persist a native document from the editor. Autosave writes directly to the
// native JSON model (no Markdown round-trip).
ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => {
try {
saveChapterDoc(requireBook(), id, doc as never);
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});
// Copy a user-chosen image into the book's assets/ directory and return a path
// relative to the book root (e.g. "assets/photo.png") for embedding.
ipcMain.handle("folio:importImageAsset", async () => {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({
title: "Insert image",
properties: ["openFile"],
filters: [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const src = filePaths[0];
const dir = path.join(bp, "assets");
fs.mkdirSync(dir, { recursive: true });
const base = path.basename(src);
const ext = path.extname(base);
const stem = path.basename(base, ext);
let name = base;
let counter = 2;
while (fs.existsSync(path.join(dir, name))) {
name = `${stem}-${counter}${ext}`;
counter += 1;
}
fs.copyFileSync(src, path.join(dir, name));
const relPath = `assets/${name}`;
return { ok: true, name, relPath };
});
ipcMain.handle("folio:copyText", (_e, text: string) => {
clipboard.writeText(typeof text === "string" ? text : "");
return { ok: true };
@ -431,6 +502,30 @@ ipcMain.handle("folio:getClipboardText", () => {
return clipboard.readText();
});
// Full screen is driven by the Electron window (not the DOM Fullscreen API,
// which is unreliable inside Electron and fails silently here).
ipcMain.handle("folio:toggleFullscreen", () => {
const win = getWindow();
if (!win) return false;
win.setFullScreen(!win.isFullScreen());
return win.isFullScreen();
});
// Right-click context menu (Electron has none by default), so Cut/Copy/Paste
// via the mouse work. The roles act on whatever element is focused.
ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const menu = Menu.buildFromTemplate([
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" },
]);
menu.popup({ window: win, x, y });
});
// Theme preference. Stored as a plain string so future themes can be added
// without touching the main process. "default" from older builds maps to
// "light" in the renderer, so it is normalized here too.

View file

@ -0,0 +1,566 @@
// Convert legacy Markdown chapter content into the native Folio document model.
//
// The converter reuses the SAME markdown-it rules Folio already applies for
// print/PDF rendering (Obsidian ==highlight==, > [!callout], and [[wikilinks]])
// so migrated content matches what the writer already sees in exports. A small
// strikethrough rule is added for ~~text~~ (markdown-it has no built-in).
//
// The output is a ProseMirror/Tiptap JSON tree (FolioChapterDoc). It deliberately
// does NOT produce any Markdown; the document is the source of truth.
import MarkdownIt from "markdown-it";
import type {
MarkdownIt as MarkdownItInstance,
StateCore,
StateInline,
Token,
} from "markdown-it";
import { wikilinkPlugin, type WikilinkTarget } from "../renderer/wikilinks.js";
import {
CURRENT_DOC_SCHEMA_VERSION,
type FolioChapterDoc,
type FolioMark,
type FolioNode,
} from "./native-doc.js";
// ---------------------------------------------------------------------------
// Inline markdown-it plugins (mirrors src/renderer/print-format.ts so that
// ==highlight==, > [!callout] and [[wikilinks]] survive the migration exactly
// as they render today). Kept local to avoid pulling renderer DOM code into the
// main process.
// ---------------------------------------------------------------------------
// ==text== -> <mark>text</mark>
function highlightPlugin(md: MarkdownItInstance) {
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x3d /* = */ || src.charCodeAt(start + 1) !== 0x3d)
return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x0a /* newline */) return false;
if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) {
end = i;
break;
}
}
if (end === -1) return false;
const content = src.slice(start + 2, end);
if (!content.trim()) return false;
if (silent) {
state.pos = end + 2;
return true;
}
state.push("highlight_open", "mark", 1);
state.push("text", "", 0).content = content;
state.push("highlight_close", "mark", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("emphasis", "highlight", parse);
md.renderer.rules.highlight_open = () => "<mark>";
md.renderer.rules.highlight_close = () => "</mark>";
}
// ~~text~~ -> <s>text</s>
function strikethroughPlugin(md: MarkdownItInstance) {
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x7e /* ~ */ || src.charCodeAt(start + 1) !== 0x7e)
return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x0a /* newline */) return false;
if (ch === 0x7e && src.charCodeAt(i + 1) === 0x7e) {
end = i;
break;
}
}
if (end === -1) return false;
const content = src.slice(start + 2, end);
if (!content.trim()) return false;
if (silent) {
state.pos = end + 2;
return true;
}
state.push("s_open", "s", 1);
state.push("text", "", 0).content = content;
state.push("s_close", "s", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("emphasis", "strikethrough", parse);
}
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/;
// > [!note] Title followed by "> body" lines becomes a styled callout. Runs
// after inline parsing so the blockquote paragraph already has children.
function calloutPlugin(md: MarkdownItInstance) {
md.core.ruler.after("inline", "callout", (state: StateCore) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const open = tokens[i];
if (open.type !== "blockquote_open") continue;
let depth = 1;
let closeIdx = -1;
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === "blockquote_open") depth += 1;
else if (t.type === "blockquote_close") {
depth -= 1;
if (depth === 0) {
closeIdx = j;
break;
}
}
}
if (closeIdx === -1) continue;
const paraOpen = tokens[i + 1];
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
const inline = tokens[i + 2];
if (!inline || inline.type !== "inline" || !inline.children) continue;
const first = inline.children[0];
if (!first || first.type !== "text") continue;
const m = CALLOUT_RE.exec(first.content);
if (!m) continue;
const calloutType = m[1].toLowerCase();
const title = m[2].trim();
open.type = "callout_open";
open.tag = "div";
open.attrs = [["class", `callout callout-${calloutType}`]];
const close = tokens[closeIdx];
close.type = "callout_close";
close.tag = "div";
first.content = "";
inline.children = inline.children.filter(
(c) => !(c.type === "text" && c.content === "")
);
if (inline.children[0]?.type === "softbreak") inline.children.shift();
if (title) {
const titleOpen = new state.Token("paragraph_open", "p", 1);
titleOpen.attrs = [["class", "callout-title"]];
const titleInline = new state.Token("inline", "", 0);
titleInline.content = title;
const titleText = new state.Token("text", "", 0);
titleText.content = title;
titleInline.children = [titleText];
const titleClose = new state.Token("paragraph_close", "p", -1);
tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose);
}
}
});
}
function makeMd(targets: WikilinkTarget[]): MarkdownItInstance {
const md = new MarkdownIt({ html: true, linkify: true });
md.use(wikilinkPlugin, { chapters: targets });
md.use(strikethroughPlugin);
md.use(highlightPlugin);
md.use(calloutPlugin);
return md;
}
// ---------------------------------------------------------------------------
// Token walking
// ---------------------------------------------------------------------------
export interface MarkdownToNativeOpts {
// Resolve an image source (relative paths can be copied into assets/).
resolveImage?: (src: string) => string;
// Chapter targets so [[wikilinks]] resolve to folio:chapter/<id>.
targets?: WikilinkTarget[];
}
interface WalkState {
resolveImage?: (src: string) => string;
}
function calloutType(token: Token): string {
const cls = String(token.attrGet("class") ?? "");
const m = /callout-([a-zA-Z0-9_-]+)/.exec(cls);
return m ? m[1] : "note";
}
// Convert a markdown-it token's attrs (array of [key, value]) into a plain
// object so node-level attributes (e.g. the callout-title paragraph class)
// survive into the native document.
function tokenAttrObj(token: Token): Record<string, string> {
const a = token.attrs;
if (!a) return {};
const out: Record<string, string> = {};
for (const [k, v] of a) out[k] = String(v);
return out;
}
function mergeText(nodes: FolioNode[]): FolioNode[] {
const sig = (marks?: FolioMark[]) => JSON.stringify(marks ?? []);
const out: FolioNode[] = [];
for (const n of nodes) {
const prev = out[out.length - 1];
if (
n.type === "text" &&
prev &&
prev.type === "text" &&
sig(prev.marks) === sig(n.marks)
) {
prev.text = (prev.text ?? "") + (n.text ?? "");
} else {
out.push(n);
}
}
return out;
}
function walkInline(children: Token[] | null, state: WalkState): FolioNode[] {
const out: FolioNode[] = [];
const markStack: FolioMark[] = [];
const topMarks = () => markStack.slice();
if (!children) return out;
for (const t of children) {
switch (t.type) {
case "text":
out.push({ type: "text", text: t.content, marks: topMarks() });
break;
case "softbreak":
out.push({ type: "text", text: " ", marks: topMarks() });
break;
case "hardbreak":
out.push({ type: "hard_break" });
break;
case "code_inline":
out.push({
type: "text",
text: t.content,
marks: [...topMarks(), { type: "code" }],
});
break;
case "strong_open":
markStack.push({ type: "bold" });
break;
case "strong_close":
markStack.pop();
break;
case "em_open":
markStack.push({ type: "italic" });
break;
case "em_close":
markStack.pop();
break;
case "s_open":
markStack.push({ type: "strike" });
break;
case "s_close":
markStack.pop();
break;
case "highlight_open":
markStack.push({ type: "highlight" });
break;
case "highlight_close":
markStack.pop();
break;
case "underline_open":
markStack.push({ type: "underline" });
break;
case "underline_close":
markStack.pop();
break;
case "link_open": {
const href = String(t.attrGet("href") ?? "");
const title = String(t.attrGet("title") ?? "");
const attrs: Record<string, unknown> = { href };
if (title) attrs.title = title;
markStack.push({ type: "link", attrs });
break;
}
case "link_close":
markStack.pop();
break;
case "image": {
let src = String(t.attrGet("src") ?? "");
if (state.resolveImage) src = state.resolveImage(src);
out.push({
type: "image",
attrs: {
src,
alt: String(t.content ?? ""),
title: String(t.attrGet("title") ?? ""),
},
});
break;
}
case "html_inline": {
const c = t.content;
if (c === "<u>") markStack.push({ type: "underline" });
else if (c === "</u>") markStack.pop();
else if (c === "</span>") markStack.pop();
else {
const open = /^<span([^>]*)>$/i.exec(c);
if (open) {
const style = /style\s*=\s*"([^"]*)"/i.exec(open[1]);
if (style) {
for (const decl of style[1].split(";")) {
const idx = decl.indexOf(":");
if (idx < 0) continue;
const prop = decl.slice(0, idx).trim().toLowerCase();
const val = decl.slice(idx + 1).trim();
if (!val) continue;
if (prop === "color")
markStack.push({ type: "textColor", attrs: { color: val } });
else if (prop === "background" || prop === "background-color")
markStack.push({ type: "highlight", attrs: { color: val } });
}
}
}
}
break;
}
default:
break;
}
}
return mergeText(out);
}
function collectUntil(
tokens: Token[],
openIdx: number,
openType: string,
closeType: string
): number {
let depth = 1;
let close = -1;
for (let j = openIdx + 1; j < tokens.length; j++) {
if (tokens[j].type === openType) depth += 1;
else if (tokens[j].type === closeType) {
depth -= 1;
if (depth === 0) {
close = j;
break;
}
}
}
return close;
}
function firstTextNode(node: FolioNode): FolioNode | null {
for (const c of node.content ?? []) {
if (c.type === "text") return c;
if (c.content) {
const r = firstTextNode(c);
if (r) return r;
}
}
return null;
}
// Strip a leading "[ ] " / "[x] " checkbox from a list item, returns checked.
function extractCheckbox(item: FolioNode): boolean {
const tn = firstTextNode(item);
if (tn && tn.text) {
const m = /^\[([ xX])\]\s?/.exec(tn.text);
if (m) {
tn.text = tn.text.slice(m[0].length);
return m[1].toLowerCase() === "x";
}
}
return false;
}
function walkBlocks(
tokens: Token[],
start: number,
end: number,
state: WalkState
): FolioNode[] {
const out: FolioNode[] = [];
let i = start;
while (i < end) {
const t = tokens[i];
switch (t.type) {
case "heading_open": {
const level = parseInt(t.tag.slice(1), 10) || 1;
const inline = tokens[i + 1];
const content = inline?.children
? walkInline(inline.children, state)
: [];
out.push({ type: "heading", attrs: { level }, content });
i += 3;
break;
}
case "paragraph_open": {
const inline = tokens[i + 1];
const content = inline?.children
? walkInline(inline.children, state)
: [];
const attrs = tokenAttrObj(t);
out.push({
type: "paragraph",
...(Object.keys(attrs).length ? { attrs } : {}),
content,
});
i += 3;
break;
}
case "bullet_list_open":
case "ordered_list_open": {
const [node, next] = walkList(tokens, i, state);
out.push(node);
i = next + 1;
break;
}
case "blockquote_open": {
const close = collectUntil(tokens, i, "blockquote_open", "blockquote_close");
const content = walkBlocks(tokens, i + 1, close, state);
out.push({ type: "blockquote", content });
i = close + 1;
break;
}
case "callout_open": {
const type = calloutType(t);
const close = collectUntil(tokens, i, "callout_open", "callout_close");
const content = walkBlocks(tokens, i + 1, close, state);
out.push({ type: "blockquote", attrs: { callout: type }, content });
i = close + 1;
break;
}
case "fence":
case "code_block": {
const lang = (String(t.info ?? "").trim()) || null;
out.push({
type: "codeBlock",
attrs: { language: lang },
content: [{ type: "text", text: t.content }],
});
i += 1;
break;
}
case "hr":
out.push({ type: "horizontalRule" });
i += 1;
break;
case "table_open": {
const [node, next] = walkTable(tokens, i, state);
out.push(node);
i = next + 1;
break;
}
case "html_block":
i += 1;
break;
default:
i += 1;
break;
}
}
return out;
}
function walkList(tokens: Token[], i: number, state: WalkState): [FolioNode, number] {
const open = tokens[i];
const isOrdered = open.type === "ordered_list_open";
const closeType = isOrdered ? "ordered_list_close" : "bullet_list_close";
const closeIdx = collectUntil(tokens, i, open.type, closeType);
const items: FolioNode[] = [];
let j = i + 1;
while (j < closeIdx) {
if (tokens[j].type === "list_item_open") {
const itemClose = collectUntil(tokens, j, "list_item_open", "list_item_close");
const content = walkBlocks(tokens, j + 1, itemClose, state);
items.push({ type: "listItem", content });
j = itemClose + 1;
} else {
j += 1;
}
}
// Obsidian-style checklists: "- [ ] " / "- [x] " in a bullet list.
let isTask = false;
if (!isOrdered && items[0]) {
const tn = firstTextNode(items[0]);
isTask = !!tn && /^\[([ xX])\]/.test(tn.text ?? "");
}
if (isTask) {
const taskItems = items.map((it) => ({
type: "taskItem",
attrs: { checked: extractCheckbox(it) },
content: it.content,
}));
return [{ type: "taskList", content: taskItems }, closeIdx];
}
const listType = isOrdered ? "orderedList" : "bulletList";
return [{ type: listType, content: items }, closeIdx];
}
function walkTable(tokens: Token[], i: number, state: WalkState): [FolioNode, number] {
const closeIdx = collectUntil(tokens, i, "table_open", "table_close");
const rows: FolioNode[] = [];
let j = i + 1;
while (j < closeIdx) {
const t = tokens[j];
if (t.type === "tr_open") {
const rc = collectUntil(tokens, j, "tr_open", "tr_close");
const cells: FolioNode[] = [];
let c = j + 1;
while (c < rc) {
if (tokens[c].type === "th_open" || tokens[c].type === "td_open") {
const cellOpen = tokens[c].type;
const cellClose = cellOpen === "th_open" ? "th_close" : "td_close";
const cellType = cellOpen === "th_open" ? "tableHeader" : "tableCell";
const cc = collectUntil(tokens, c, cellOpen, cellClose);
// Table cell content is an inline token (not wrapped in a paragraph
// block), so walk the inline children directly.
let inner: FolioNode[] = [];
for (let k = c + 1; k < cc; k++) {
if (tokens[k].type === "inline") {
inner = walkInline(tokens[k].children, state);
break;
}
}
cells.push({
type: cellType,
content: inner.length
? [{ type: "paragraph", content: inner }]
: [{ type: "paragraph", content: [] }],
});
c = cc + 1;
} else {
c += 1;
}
}
rows.push({ type: "tableRow", content: cells });
j = rc + 1;
} else {
j += 1;
}
}
return [{ type: "table", content: rows }, closeIdx];
}
export function markdownToNative(source: string, opts: MarkdownToNativeOpts = {}): FolioChapterDoc {
const md = makeMd(opts.targets ?? []);
const tokens = md.parse(source, {});
const state: WalkState = { resolveImage: opts.resolveImage };
const blocks = walkBlocks(tokens, 0, tokens.length, state);
return {
schemaVersion: CURRENT_DOC_SCHEMA_VERSION,
doc: { type: "doc", content: blocks },
};
}

View file

@ -27,9 +27,16 @@ export function buildMenu(
const fileMenu: MenuItem = {
label: "File",
submenu: [
{ label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{ type: "separator" },
{ label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{ type: "separator" },
{
label: "Save",
accelerator: "CmdOrCtrl+S",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:save-requested"),
},
{ type: "separator" },
{ label: "Open Recent", submenu: recentItems },
{ type: "separator" },
{

142
src/main/migration.ts Normal file
View file

@ -0,0 +1,142 @@
// Migration from legacy Markdown books to the native Folio document model.
//
// Strategy (safe + explicit, never automatic on open):
// - Only runs when the caller explicitly migrates a chapter/book.
// - Before touching anything, a full recovery backup of the book is made to
// ~/Folio-Recovery (see backup.ts). The original .md files are NEVER
// modified or deleted.
// - The .md content is parsed into a native FolioChapterDoc and written to a
// NEW chapters/<id>.json file. The folio.json entry is repointed to it.
// - Migration is idempotent: if a chapter already has a native .json file it
// is left untouched and reported as already-native.
import fs from "fs";
import path from "path";
import {
loadBook,
saveMeta,
chaptersDir,
type BookMeta,
} from "./project.js";
import { getChapterContent } from "./chapters.js";
import {
getChapterDoc,
setChapterDoc,
nativeChapterFile,
CURRENT_DOC_SCHEMA_VERSION,
type FolioChapterDoc,
} from "./native-doc.js";
import { markdownToNative } from "./markdown-to-native.js";
import { ensureRecoveryBackup } from "./backup.js";
export type MigrateStatus = "migrated" | "already-native" | "error";
export interface MigrateResult {
status: MigrateStatus;
id: string;
file?: string;
error?: string;
}
// Resolve an image src for migration: external/data URIs are kept as-is;
// local relative paths are copied into the book's assets/ directory and the
// reference is rewritten to "assets/<name>".
function resolveImageSrc(
src: string,
bookPath: string,
assetsDir: string,
chapterId: string
): string {
if (/^(https?:|data:|blob:)/i.test(src)) return src;
const candidate = path.resolve(bookPath, src);
let exists = false;
try {
exists = fs.existsSync(candidate) && fs.statSync(candidate).isFile();
} catch {
exists = false;
}
if (!exists) return src; // best-effort: keep the original reference
fs.mkdirSync(assetsDir, { recursive: true });
const base = path.basename(src);
let name = `${chapterId}-${base}`.replace(/[^a-zA-Z0-9._-]/g, "_");
let dest = path.join(assetsDir, name);
let n = 2;
while (fs.existsSync(dest)) {
name = `${chapterId}-${n}-${base}`.replace(/[^a-zA-Z0-9._-]/g, "_");
dest = path.join(assetsDir, name);
n += 1;
}
fs.copyFileSync(candidate, dest);
return `assets/${name}`;
}
export function migrateChapterToNative(
bookPath: string,
id: string
): MigrateResult {
try {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) return { status: "error", id, error: "Chapter not found" };
// Idempotency: already a native chapter -> leave the original .md alone.
if (entry.file.endsWith(".json")) {
meta.docFormat = "folio-doc";
meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION;
saveMeta(bookPath, meta);
return { status: "already-native", id, file: entry.file };
}
ensureRecoveryBackup(bookPath);
const md = getChapterContent(bookPath, id);
const assetsDir = path.join(bookPath, "assets");
const targets = Object.values(meta.chapters).map((c) => ({
id: c.id,
title: c.title,
}));
const doc: FolioChapterDoc = markdownToNative(md, {
resolveImage: (src) => resolveImageSrc(src, bookPath, assetsDir, id),
targets,
});
const newFile = path.posix.join("chapters", nativeChapterFile(id));
fs.mkdirSync(chaptersDir(bookPath), { recursive: true });
setChapterDoc(bookPath, id, doc);
entry.file = newFile;
meta.docFormat = "folio-doc";
meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION;
saveMeta(bookPath, meta);
return { status: "migrated", id, file: newFile };
} catch (err) {
return {
status: "error",
id,
error: err instanceof Error ? err.message : String(err),
};
}
}
export function migrateBookToNative(bookPath: string): MigrateResult[] {
ensureRecoveryBackup(bookPath);
const meta = loadBook(bookPath);
return meta.chapterOrder.map((id) => migrateChapterToNative(bookPath, id));
}
// Convenience: verify a migrated chapter reads back as valid native content.
export function isNativeChapter(
bookPath: string,
id: string
): boolean {
const meta = loadBook(bookPath) as BookMeta;
const entry = meta.chapters[id];
if (!entry || !entry.file.endsWith(".json")) return false;
try {
getChapterDoc(bookPath, id);
return true;
} catch {
return false;
}
}

127
src/main/native-doc.ts Normal file
View file

@ -0,0 +1,127 @@
// Native Folio rich-text document model.
//
// 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.
//
// Persistence layout (per book):
// folio.json -> book metadata (chapterOrder, chapters, docFormat, ...)
// chapters/<id>.json -> { schemaVersion, doc } (native content)
// assets/ -> copied image assets referenced by chapters
//
// Markdown remains only for legacy migration (this module does the parsing)
// and optional export (added later).
import fs from "fs";
import path from "path";
import {
loadBook,
saveMeta,
chaptersDir,
} from "./project.js";
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;
// 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).
export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
const file = path.join(bookPath, entry.file);
const raw = fs.readFileSync(file, "utf-8");
return parseChapter(raw);
}
// Write a native chapter document. Creates chapters/ if needed.
export function setChapterDoc(
bookPath: string,
id: string,
doc: FolioChapterDoc
): void {
fs.mkdirSync(chaptersDir(bookPath), { recursive: true });
const file = path.join(bookPath, "chapters", nativeChapterFile(id));
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 };

View file

@ -0,0 +1,190 @@
// Serialize a native Folio document (ProseMirror/Tiptap JSON) back to Markdown.
//
// This module is the *bridge* that lets Stage 1 keep the existing CodeMirror
// editor, the Full Book view, and the Markdown export working after a chapter
// has been migrated to the native JSON format. It is the inverse of
// markdown-to-native.ts. Stage 2 (Tiptap editor) removes this bridge entirely
// because the editor will speak native JSON directly.
//
// The output is intentionally "good enough" Markdown that round-trips through
// markdown-to-native.ts; it is NOT a pixel-perfect reconstruction of the
// original source (e.g. raw control characters inside ==highlight== are kept
// literal, and folio:chapter/<id> links become [[text]] wikilinks).
import type {
FolioChapterDoc,
FolioMark,
FolioNode,
} from "./native-doc.js";
// Marks applied outermost-first so nested formatting renders as valid Markdown.
const MARK_ORDER = [
"link",
"textColor",
"highlight",
"underline",
"strike",
"bold",
"italic",
"code",
];
function wrap(mark: FolioMark, text: string): string {
const attrs = mark.attrs ?? {};
switch (mark.type) {
case "bold":
return `**${text}**`;
case "italic":
return `*${text}*`;
case "strike":
return `~~${text}~~`;
case "code":
return "`" + text + "`";
case "underline":
return `<u>${text}</u>`;
case "highlight": {
const color = attrs.color;
return color
? `<span style="background-color:${color}">${text}</span>`
: `==${text}==`;
}
case "textColor":
return `<span style="color:${attrs.color}">${text}</span>`;
case "link": {
const href = String(attrs.href ?? "");
if (href.startsWith("folio:chapter/")) return `[[${text}]]`;
const title = attrs.title ? ` "${attrs.title}"` : "";
return `[${text}](${href}${title})`;
}
default:
return text;
}
}
function applyMarks(text: string, marks: FolioMark[] = []): string {
let s = text;
for (const type of MARK_ORDER) {
const mark = marks.find((m) => m.type === type);
if (mark) s = wrap(mark, s);
}
return s;
}
function inlineToMd(nodes: FolioNode[] = []): string {
let out = "";
for (const n of nodes) {
if (n.type === "text") {
out += applyMarks(n.text ?? "", n.marks);
} else if (n.type === "hard_break" || n.type === "hardbreak") {
out += "\n";
} else if (n.type === "paragraph") {
out += inlineToMd(n.content);
} else if (n.type === "image") {
const { src, alt, title } = n.attrs ?? {};
out += title
? `![${alt ?? ""}](${src} "${title}")`
: `![${alt ?? ""}](${src})`;
}
}
return out;
}
function listToMd(node: FolioNode, depth: number): string {
const indent = " ".repeat(depth);
const ordered = node.type === "orderedList";
const isTask = node.type === "taskList";
const items = node.content ?? [];
return items
.map((item, i) => {
let marker: string;
if (isTask) marker = item.attrs?.checked ? "- [x] " : "- [ ] ";
else if (ordered) marker = `${i + 1}. `;
else marker = "- ";
const inner = (item.content ?? [])
.map((b) => blockToMd(b, depth + 1))
.join("\n\n");
const lines = inner.split("\n");
return (
indent +
marker +
lines
.map((l, idx) => (idx === 0 ? l : indent + " " + l))
.join("\n")
);
})
.join("\n");
}
function tableToMd(node: FolioNode): string {
const rows = node.content ?? [];
if (!rows.length) return "";
const cellsOf = (row: FolioNode) =>
(row.content ?? []).map((c) => inlineToMd(c.content));
const header = cellsOf(rows[0]);
const sep = header.map(() => "---");
const body = rows.slice(1).map(cellsOf);
return [...[header, sep], ...body]
.map((r) => "| " + r.join(" | ") + " |")
.join("\n");
}
function blockToMd(node: FolioNode, depth = 0): string {
switch (node.type) {
case "paragraph":
return inlineToMd(node.content);
case "heading": {
const level = Number(node.attrs?.level ?? 1);
return "#".repeat(level) + " " + inlineToMd(node.content);
}
case "blockquote": {
if (node.attrs?.callout) {
const calloutType = String(node.attrs.callout);
const rest: FolioNode[] = [];
let title = "";
for (const c of node.content ?? []) {
if (c.type === "paragraph" && c.attrs?.class === "callout-title") {
title = inlineToMd(c.content);
} else {
rest.push(c);
}
}
const body = rest.map((b) => blockToMd(b, depth)).join("\n\n");
const lines = [`> [!${calloutType}]${title ? " " + title : ""}`];
if (body) {
for (const l of body.split("\n")) lines.push(l ? "> " + l : ">");
}
return lines.join("\n");
}
const inner = (node.content ?? [])
.map((b) => blockToMd(b, depth))
.join("\n\n");
return inner
.split("\n")
.map((l) => (l ? "> " + l : ">"))
.join("\n");
}
case "bulletList":
case "orderedList":
case "taskList":
return listToMd(node, depth);
case "codeBlock": {
const lang = node.attrs?.language ?? "";
const text = (node.content ?? [])
.map((c) => c.text ?? "")
.join("")
.replace(/\n+$/, "");
return "```" + lang + "\n" + text + "\n```";
}
case "horizontalRule":
return "---";
case "table":
return tableToMd(node);
default:
return "";
}
}
export function nativeToMarkdown(doc: FolioChapterDoc): string {
const blocks = doc.doc?.content ?? [];
return blocks.map((b) => blockToMd(b)).join("\n\n");
}

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ contextBridge.exposeInMainWorld("folio", {
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
getRecents: () => ipcRenderer.invoke("folio:getRecents"),
closeBook: () => ipcRenderer.invoke("folio:closeBook"),
showContextMenu: (x: number, y: number) =>
ipcRenderer.send("folio:context-menu", x, y),
// chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
@ -25,6 +27,14 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:reorderChapters", newOrder),
saveChapter: (id: string, content: string) =>
ipcRenderer.invoke("folio:saveChapter", id, content),
migrateChapter: (id: string) =>
ipcRenderer.invoke("folio:migrateChapter", id),
migrateBook: () => ipcRenderer.invoke("folio:migrateBook"),
loadChapterDoc: (id: string) =>
ipcRenderer.invoke("folio:loadChapterDoc", id),
saveChapterDoc: (id: string, doc: unknown) =>
ipcRenderer.invoke("folio:saveChapterDoc", id, doc),
importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"),
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"),
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
@ -33,6 +43,7 @@ contextBridge.exposeInMainWorld("folio", {
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"),
// theme preference
getTheme: () => ipcRenderer.invoke("folio:getTheme"),
@ -69,4 +80,6 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:redo-requested", () => cb()),
onSelectAllRequested: (cb: () => void) =>
ipcRenderer.on("folio:selectAll-requested", () => cb()),
onSaveRequested: (cb: () => void) =>
ipcRenderer.on("folio:save-requested", () => cb()),
});

View file

@ -13,6 +13,10 @@ export interface BookMeta {
title: string;
chapterOrder: string[];
chapters: Record<string, ChapterEntry>;
// Native document format metadata (added when a book is migrated). Legacy
// Markdown books omit these fields entirely.
docFormat?: "markdown" | "folio-doc";
docSchemaVersion?: number;
}
export interface Recent {

View file

@ -16,10 +16,20 @@ export function recentsFilePath(): string {
export function listRecents(): Recent[] {
// Drop entries whose book no longer exists (e.g. e2e temp books).
const recents = _listRecents(recentsFilePath());
return recents.filter(
const recents = _listRecents(recentsFilePath()).filter(
(r) => r && typeof r.path === "string" && hasBook(r.path)
);
// Collapse repeats of the same book (same title) so the home/recent list
// is not cluttered with multiple temp copies of one book.
const seen = new Set<string>();
const deduped: Recent[] = [];
for (const r of recents) {
const key = (r.title || r.path).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(r);
}
return deduped;
}
export function addRecent(recent: Recent): Recent[] {

File diff suppressed because it is too large Load diff

View file

@ -68,15 +68,16 @@ html, body {
}
.brand-icon {
width: 24px;
height: 24px;
width: 64px;
height: 64px;
flex: none;
object-fit: contain;
}
.sidebar-header .icon-button {
font-size: 1.1rem;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.sidebar-title {
@ -290,6 +291,45 @@ html, body {
border-color: var(--color-accent);
}
.icon-button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.icon-button:active {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
transform: translateY(1px);
}
.icon-button:disabled {
opacity: 0.55;
cursor: default;
background: transparent;
color: inherit;
border-color: var(--color-border);
transform: none;
}
.icon-button.active {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
}
/* The chapters "+" must stay clearly visible (white) in dark mode. */
:root[data-theme="dark-mode"] #addChapterBtn {
color: #ffffff;
}
/* The brand logo is a dark glyph; on the near-black dark theme it would be
invisible. Render it white there (light theme keeps the dark logo). */
:root[data-theme="dark-mode"] .brand-icon,
:root[data-theme="dark-mode"] .welcome-brand {
filter: brightness(0) invert(1);
}
/* ---- welcome ---- */
.welcome {
flex: 1;
@ -304,8 +344,8 @@ html, body {
}
.welcome-brand {
width: 96px;
height: 96px;
width: 128px;
height: 128px;
object-fit: contain;
margin-bottom: 12px;
}
@ -377,6 +417,12 @@ html, body {
}
/* ---- chapter list ---- */
body.dragging-active,
body.dragging-active * {
cursor: grabbing !important;
user-select: none;
}
.chapter-row {
display: flex;
align-items: center;
@ -571,20 +617,24 @@ html, body {
the first gets a subtle separator where a printed page will begin. The
separator is a pure CSS border on the chapter's first line, so it never
becomes part of the copied chapter text. */
#fullBookHost .cm-line.chapter-break {
#fullBookHost .chapter-break {
border-top: 1px solid var(--color-border);
margin-top: 2.5rem;
padding-top: 1.5rem;
}
#fullBookHost .chapter-break.first {
border-top: none;
margin-top: 0;
padding-top: 0;
}
/* ---- PDF / print output ----
PDF export prints the whole window via webContents.printToPDF, so the
print stylesheet replaces the entire app with a plain-HTML manuscript
(#printBook) that the renderer keeps populated from the assembled book.
Printing plain HTML (instead of the CodeMirror editor, which only keeps a
virtualized window of lines in the DOM) guarantees the PDF always contains
the complete book in full, no matter how long it is or where the editor is
scrolled. #printBook is hidden on screen and shown only when printing. */
Printing plain HTML guarantees the PDF always contains the complete book in
full, no matter how long it is or where the editor is scrolled. #printBook
is hidden on screen and shown only when printing. */
#printBook {
display: none;
}
@ -812,6 +862,40 @@ html, body {
border-color: var(--color-accent);
}
.toolbar-label {
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: center;
padding: 0 2px;
user-select: none;
}
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translateX(-50%) translateY(8px);
background: var(--color-accent, #2f6df0);
color: #fff;
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
padding: 10px 18px;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 1000;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---- toolbar dropdowns ---- */
.toolbar-dropdown {
position: relative;
@ -976,50 +1060,6 @@ body.focus-mode .editor {
background: var(--color-bg);
}
.cm-editor {
flex: 1;
min-width: 0;
height: 100%;
}
.cm-editor .cm-scroller {
overflow: auto;
}
.cm-editor .cm-gutters {
background: var(--color-bg);
color: var(--color-muted);
border-right: 1px solid var(--color-border);
}
.cm-h1 { font-size: 1.7rem; font-weight: 700; }
.cm-h2 { font-size: 1.45rem; font-weight: 700; }
.cm-h3 { font-size: 1.25rem; font-weight: 600; }
.cm-h4 { font-size: 1.1rem; font-weight: 600; }
.cm-h5 { font-size: 1.05rem; font-weight: 600; }
.cm-h6 { font-size: 1rem; font-weight: 600; color: var(--color-muted); }
.cm-strong { font-weight: 700; }
.cm-em { font-style: italic; }
.cm-strike { text-decoration: line-through; }
.cm-link { color: var(--color-accent); }
.cm-url { color: var(--color-muted); text-decoration: underline; }
.cm-code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
background: var(--color-panel);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.cm-quote { color: var(--color-muted); }
.cm-hr { color: var(--color-muted); text-decoration: line-through; }
.cm-comment { color: var(--color-muted); }
.cm-panels {
background: var(--color-panel);
color: var(--color-text);
}
/* ---- modal dialog ---- */
.modal-backdrop {
position: fixed;
@ -1095,3 +1135,77 @@ body.focus-mode .editor {
cursor: not-allowed;
}
/* ---- Native rich-text (Tiptap / ProseMirror) editor ---- */
.editor-panes .folio-prose.ProseMirror {
height: 100%;
min-height: 100%;
overflow-y: auto;
font-family: var(--font-serif);
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
background: var(--color-bg);
padding: 24px 20px;
max-width: 820px;
margin: 0 auto;
outline: none;
}
.folio-prose :first-child { margin-top: 0; }
.folio-prose h1 { font-size: 2rem; margin: 0.6em 0 0.3em; }
.folio-prose h2 { font-size: 1.6rem; margin: 0.6em 0 0.3em; }
.folio-prose h3 { font-size: 1.3rem; margin: 0.6em 0 0.3em; }
.folio-prose h4 { font-size: 1.12rem; margin: 0.6em 0 0.3em; }
.folio-prose h5 { font-size: 1rem; margin: 0.6em 0 0.3em; }
.folio-prose h6 { font-size: 0.9rem; margin: 0.6em 0 0.3em; }
.folio-prose p { margin: 0 0 0.8em; }
.folio-prose ul, .folio-prose ol { padding-left: 1.4em; margin: 0 0 0.8em; }
.folio-prose blockquote {
border-left: 3px solid var(--color-accent);
margin: 0 0 0.8em;
padding: 0.2em 0 0.2em 1em;
color: var(--color-text-dim);
}
.folio-prose code {
font-family: var(--font-mono);
background: var(--color-panel);
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.92em;
}
.folio-prose pre {
background: var(--color-panel);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 12px 14px;
overflow-x: auto;
}
.folio-prose pre code { background: none; padding: 0; }
.folio-prose img { max-width: 100%; height: auto; border-radius: 4px; }
.folio-prose a { color: var(--color-accent); text-decoration: underline; }
.folio-prose hr { border: none; border-top: 1px solid var(--color-border); margin: 1.2em 0; }
.folio-prose table {
border-collapse: collapse;
margin: 0 0 0.8em;
width: 100%;
}
.folio-prose th, .folio-prose td {
border: 1px solid var(--color-border);
padding: 6px 10px;
vertical-align: top;
}
.folio-prose th { background: var(--color-panel); font-weight: 600; }
.folio-prose ul[data-type="taskList"] { list-style: none; padding-left: 0.4em; }
.folio-prose ul[data-type="taskList"] li { display: flex; gap: 0.5em; align-items: flex-start; }
.folio-prose ul[data-type="taskList"] li > label { margin-top: 0.25em; }
.folio-prose ul[data-type="taskList"] input { margin: 0; }
.folio-prose .selectedCell { background: rgba(127, 127, 127, 0.2); }
.folio-prose .is-empty::before {
content: "Write something…";
color: var(--color-text-dim);
float: left;
height: 0;
pointer-events: none;
}
.folio-prose:focus { outline: none; }

View file

@ -18,6 +18,7 @@
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
</div>
<div class="project-bar hidden" id="projectBar">
<button class="icon-button" id="homeBtn" title="Home" aria-label="Home">🏠</button>
<span class="book-title" id="bookTitle"></span>
<span class="book-path" id="bookPath" title=""></span>
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
@ -31,7 +32,7 @@
<label class="theme-label" for="themeSelect">Theme</label>
<select id="themeSelect" class="theme-select" title="Interface theme">
<option value="light">Light</option>
<option value="neonflux">Neonflux</option>
<option value="dark-mode">Dark Mode</option>
</select>
</div>
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
@ -84,6 +85,39 @@
<option value="5">Heading 5</option>
<option value="6">Heading 6</option>
</select>
<select id="fontFamilySelect" class="toolbar-select" title="Font family">
<option value="">Serif</option>
<option value="DejaVu Serif, Liberation Serif, serif">DejaVu Serif</option>
<option value="C059, Times New Roman, serif">C059</option>
<option value="Bitstream Charter, serif">Bitstream Charter</option>
<option value="IBM Plex Serif, serif">IBM Plex Serif</option>
<option value="URW Bookman, DejaVu Serif, serif">Bookman</option>
<option value="DejaVu Sans, Liberation Sans, sans-serif">DejaVu Sans</option>
<option value="Carlito, DejaVu Sans, sans-serif">Carlito</option>
<option value="IBM Plex Sans, DejaVu Sans, sans-serif">IBM Plex Sans</option>
<option value="DejaVu Sans Mono, Liberation Mono, monospace">DejaVu Mono</option>
<option value="IBM Plex Mono, DejaVu Sans Mono, monospace">IBM Plex Mono</option>
<option value="FreeMono, monospace">FreeMono</option>
</select>
<select id="fontSizeSelect" class="toolbar-select" title="Font size">
<option value="">17px</option>
<option value="12px">12px</option>
<option value="14px">14px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
<option value="32px">32px</option>
</select>
<select id="lineSpacingSelect" class="toolbar-select" title="Line spacing">
<option value="">1.7</option>
<option value="1">1.0</option>
<option value="1.15">1.15</option>
<option value="1.5">1.5</option>
<option value="2">2.0</option>
<option value="2.5">2.5</option>
<option value="3">3.0</option>
</select>
</div>
<div class="toolbar-group">
<button class="toolbar-btn b" data-cmd="bold" title="Bold (Ctrl+B)">B</button>
@ -191,6 +225,8 @@
<button class="dropdown-item" data-export="pdf">PDF</button>
<button class="dropdown-item" data-export="docx">DOCX</button>
<button class="dropdown-item" data-export="odt">ODT</button>
<div class="dropdown-sep"></div>
<button class="dropdown-item" data-export="native">Migrate to native</button>
</div>
</div>
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
@ -222,6 +258,7 @@
</div>
</div>
</div>
<div class="toast hidden" id="toast"></div>
<script src="./renderer.js"></script>
</body>
</html>

View file

@ -1,153 +0,0 @@
import { EditorState, type Extension } from "@codemirror/state";
import {
EditorView,
drawSelection,
dropCursor,
highlightActiveLine,
keymap,
type KeyBinding,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import {
bracketMatching,
HighlightStyle,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { markdown } from "@codemirror/lang-markdown";
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
import { tags } from "@lezer/highlight";
import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough, toggleUnderline } from "./md-format.js";
export interface MdEditorCallbacks {
onChange: (text: string) => void;
onSelection?: (view: EditorView) => void;
readOnly?: boolean;
extensions?: Extension[];
}
const formatBindings: KeyBinding[] = [
{ key: "Mod-b", run: toggleBold, preventDefault: true },
{ key: "Mod-i", run: toggleItalic, preventDefault: true },
{ key: "Mod-u", run: toggleUnderline, preventDefault: true },
{ key: "Mod-Shift-s", run: toggleStrikethrough, preventDefault: true },
{ key: "Mod-k", run: toggleLink, preventDefault: true },
];
const mdHighlight = HighlightStyle.define([
{ tag: tags.heading1, class: "cm-h1" },
{ tag: tags.heading2, class: "cm-h2" },
{ tag: tags.heading3, class: "cm-h3" },
{ tag: tags.heading4, class: "cm-h4" },
{ tag: tags.heading5, class: "cm-h5" },
{ tag: tags.heading6, class: "cm-h6" },
{ tag: tags.strong, class: "cm-strong" },
{ tag: tags.emphasis, class: "cm-em" },
{ tag: tags.strikethrough, class: "cm-strike" },
{ tag: tags.link, class: "cm-link" },
{ tag: tags.url, class: "cm-url" },
{ tag: tags.monospace, class: "cm-code" },
{ tag: tags.quote, class: "cm-quote" },
{ tag: tags.contentSeparator, class: "cm-hr" },
{ tag: tags.comment, class: "cm-comment" },
]);
const folioTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "1.05rem",
backgroundColor: "var(--color-bg)",
color: "var(--color-text)",
},
"&.cm-focused": {
outline: "none",
},
".cm-scroller": {
fontFamily: "var(--font-serif)",
lineHeight: "1.7",
padding: "24px 20px",
},
".cm-content": {
caretColor: "var(--color-accent)",
maxWidth: "820px",
paddingBottom: "48px",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--color-accent)",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "var(--color-accent)",
opacity: "0.25",
},
".cm-activeLine": {
backgroundColor: "var(--color-panel)",
},
"&.cm-focused .cm-matchingBracket": {
outline: "1px solid var(--color-accent)",
},
".cm-searchMatch": {
backgroundColor: "var(--color-selection)",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "var(--color-selection-strong)",
},
".cm-panels": {
backgroundColor: "var(--color-panel)",
color: "var(--color-text)",
fontFamily: "var(--font-sans)",
fontSize: "0.85rem",
},
".cm-panels input, .cm-panels button": {
fontFamily: "var(--font-sans)",
},
});
export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks): EditorView {
const readOnly = !!callbacks.readOnly;
const view = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
EditorView.lineWrapping,
drawSelection(),
dropCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
history(),
indentOnInput(),
bracketMatching(),
markdown(),
search({ top: true }),
syntaxHighlighting(mdHighlight),
folioTheme,
...(callbacks.extensions ?? []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
keymap.of(
readOnly
? [...defaultKeymap, ...searchKeymap]
: [...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]
),
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
if (callbacks.onSelection && (update.docChanged || update.selectionSet)) {
callbacks.onSelection(update.view);
}
}),
],
}),
parent: host,
});
return view;
}
export function setEditorText(view: EditorView, text: string): void {
const current = view.state.doc.toString();
if (current === text) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
});
}
export function editorText(view: EditorView): string {
return view.state.doc.toString();
}

View file

@ -1,818 +0,0 @@
import {
EditorSelection,
type EditorState,
type Transaction,
type TransactionSpec,
} from "@codemirror/state";
/**
* Minimal command target: the functions in this module only need read access
* to an editor state and a way to dispatch a transaction. Both `EditorView`
* and a plain { state, dispatch } object satisfy it, which keeps the module
* testable without a DOM.
*/
export interface FormatTarget {
state: EditorState;
dispatch: (tr: Transaction | TransactionSpec) => void;
}
type FormatCommand = (target: FormatTarget) => boolean;
function slice(state: EditorState, from: number, to: number): string {
return state.doc.sliceString(from, to);
}
function lineRange(state: EditorState, from: number, to: number): { start: number; end: number } {
const first = state.doc.lineAt(from);
const last = state.doc.lineAt(to);
return { start: first.from, end: last.to };
}
/* ------------------------------------------------------------------ */
/* Inline markers: bold, italic, strikethrough, inline code */
/* ------------------------------------------------------------------ */
function toggleInline(marker: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const len = marker.length;
const single = len === 1;
const before = from - len >= 0 ? slice(state, from - len, from) : "";
const after = slice(state, to, to + len);
const isolatedBefore =
!single || from - len - 1 < 0 || slice(state, from - len - 1, from - len) !== marker;
const isolatedAfter =
!single || to + len >= state.doc.length || slice(state, to + len, to + len + 1) !== marker;
if (before === marker && after === marker && isolatedBefore && isolatedAfter) {
dispatch({
changes: [
{ from: from - len, to: from, insert: "" },
{ from: to, to: to + len, insert: "" },
],
selection: EditorSelection.single(from - len, to - len),
});
return true;
}
const text = slice(state, from, to);
const textStartsMarked = text.startsWith(marker);
const textEndsMarked = text.endsWith(marker);
const isolatedInnerBefore =
!single || text.length < len * 2 + 1 || text.charAt(len) !== marker;
const isolatedInnerAfter =
!single || text.length < len * 2 + 1 || text.charAt(text.length - len - 1) !== marker;
if (
text.length >= len * 2 &&
textStartsMarked &&
textEndsMarked &&
isolatedInnerBefore &&
isolatedInnerAfter
) {
dispatch({
changes: [
{ from, to: from + len, insert: "" },
{ from: to - len, to, insert: "" },
],
selection: EditorSelection.single(from, to - len * 2),
});
return true;
}
dispatch({
changes: [
{ from, to: from, insert: marker },
{ from: to, to: to, insert: marker },
],
selection: EditorSelection.single(from + len, to + len),
});
return true;
};
}
export const toggleBold: FormatCommand = toggleInline("**");
export const toggleItalic: FormatCommand = toggleInline("*");
export const toggleStrikethrough: FormatCommand = toggleInline("~~");
export const toggleInlineCode: FormatCommand = toggleInline("`");
/* ------------------------------------------------------------------ */
/* Code: inline for single-line/empty selection, fenced for blocks */
/* ------------------------------------------------------------------ */
const fenceLineRe = /^```[a-zA-Z0-9_-]*[ \t]*$/;
export const toggleCode: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const first = state.doc.lineAt(from);
const last = state.doc.lineAt(to);
if (first.number === last.number) {
return toggleInlineCode(target);
}
let start = first.from;
let end = last.to;
let region = slice(state, start, end);
let lines = region.split("\n");
// Selection starts at an opening fence: treat it as a fenced-block toggle.
if (lines.length > 1 && fenceLineRe.test(lines[0])) {
if (!fenceLineRe.test(lines[lines.length - 1]) && last.number < state.doc.lines) {
const next = state.doc.line(last.number + 1);
if (fenceLineRe.test(next.text)) end = next.to;
}
region = slice(state, start, end);
lines = region.split("\n");
const body = lines.slice(1, lines.length > 1 && fenceLineRe.test(lines[lines.length - 1]) ? -1 : lines.length);
dispatch({ changes: { from: start, to: end, insert: body.join("\n") } });
return true;
}
dispatch({ changes: { from: start, to: end, insert: "```\n" + region + "\n```" } });
return true;
};
/* ------------------------------------------------------------------ */
/* Links */
/* ------------------------------------------------------------------ */
export const toggleLink: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = slice(state, from, to);
// Selection is already a link → select its URL so the user can edit it.
const link = /^\[([^\]\n]*)\]\(([^)\n]*)\)$/.exec(text);
if (link) {
const urlFrom = from + 1 + link[1].length + 2;
dispatch({
selection: EditorSelection.range(urlFrom, urlFrom + link[2].length),
scrollIntoView: true,
});
return true;
}
if (from === to) {
const pos = from;
dispatch({
changes: { from: pos, to: pos, insert: "[](url)" },
selection: EditorSelection.cursor(pos + 1),
});
return true;
}
const urlStart = from + text.length + 3;
dispatch({
changes: { from, to, insert: `[${text}](url)` },
selection: EditorSelection.range(urlStart, urlStart + 3),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Headings */
/* ------------------------------------------------------------------ */
const headingRe = /^(#{1,6})[ \t]/;
export function setHeading(level: number): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
const line = state.doc.lineAt(from);
const match = line.text.match(headingRe);
const existing = match ? match[1].length : 0;
if (level === 0) {
if (!existing) return false;
dispatch({ changes: { from: line.from, to: line.from + existing + 1, insert: "" } });
return true;
}
if (existing === level) return false;
const insert = "#".repeat(level) + " ";
dispatch({
changes: existing
? { from: line.from, to: line.from + existing + 1, insert }
: { from: line.from, to: line.from, insert },
});
return true;
};
}
export function headingLevelAt(state: EditorState, pos: number): number {
const line = state.doc.lineAt(pos);
const match = line.text.match(headingRe);
return match ? match[1].length : 0;
}
/* ------------------------------------------------------------------ */
/* Line prefixes: bullet list, blockquote */
/* ------------------------------------------------------------------ */
function toggleLinePrefix(prefix: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((line) => line.startsWith(prefix));
const insert = lines.map((line) => (allHave ? line.slice(prefix.length) : prefix + line)).join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
}
export const toggleBulletList: FormatCommand = toggleLinePrefix("- ");
export const toggleBlockquote: FormatCommand = toggleLinePrefix("> ");
/* ------------------------------------------------------------------ */
/* Numbered list */
/* ------------------------------------------------------------------ */
const orderedRe = /^\d+\.\s+/;
export const toggleOrderedList: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((line) => orderedRe.test(line));
const insert = lines
.map((line, index) => {
const stripped = line.replace(orderedRe, "");
return allHave ? stripped : `${index + 1}. ${stripped}`;
})
.join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
/* ------------------------------------------------------------------ */
/* Horizontal rule */
/* ------------------------------------------------------------------ */
export const insertHorizontalRule: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
if (from === 0) {
dispatch({
changes: { from: 0, to: 0, insert: "---\n\n" },
selection: EditorSelection.cursor(5),
});
return true;
}
const line = state.doc.lineAt(from);
if (line.from === from && line.text.length === 0) {
const hasNextLine =
line.to < state.doc.length && slice(state, line.to, line.to + 1) === "\n";
const insertAt = hasNextLine ? line.to + 1 : line.from;
dispatch({
changes: { from: insertAt, to: insertAt, insert: "---\n\n" },
selection: EditorSelection.cursor(insertAt + 5),
});
return true;
}
dispatch({
changes: { from, to: from, insert: "\n\n---\n\n" },
selection: EditorSelection.cursor(from + 6),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Clear formatting */
/* ------------------------------------------------------------------ */
function clearLine(line: string): string {
let out = line;
out = out.replace(/^#{1,6}[ \t]+/, "");
out = out.replace(/^>\s*/, "");
out = out.replace(/^[-*+]\s+/, "");
out = out.replace(/^\d+\.\s+/, "");
out = out.replace(/\[([^\]\n]+)\]\([^)\n]*\)/g, "$1");
out = out.replace(/\*\*|\*|~~|`|==/g, "");
out = out.replace(
/<u>|<\/u>|<mark[^>]*>|<\/mark>|<span[^>]*>|<\/span>|<div[^>]*>|<\/div>/g,
""
);
return out;
}
export const clearFormatting: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const cleaned = region.split("\n").map(clearLine).join("\n");
if (cleaned === region) return false;
dispatch({ changes: { from: start, to: end, insert: cleaned } });
return true;
};
/* ------------------------------------------------------------------ */
/* HTML inline wrappers: underline, colors (readable outside Folio) */
/* ------------------------------------------------------------------ */
interface HtmlSpan {
from: number;
textFrom: number;
textTo: number;
to: number;
}
function htmlSpanAt(
state: EditorState,
pos: number,
openPrefix: string,
closeTag: string
): HtmlSpan | null {
const before = state.doc.sliceString(0, pos);
const openIdx = before.lastIndexOf(openPrefix);
if (openIdx < 0) return null;
const gt = state.doc.sliceString(openIdx).indexOf(">");
if (gt < 0) return null;
const textFrom = openIdx + gt + 1;
const after = state.doc.sliceString(textFrom);
const closeIdx = after.indexOf(closeTag);
if (closeIdx < 0) return null;
const textTo = textFrom + closeIdx;
if (pos >= openIdx && pos <= textTo + closeTag.length) {
return { from: openIdx, textFrom, textTo, to: textTo + closeTag.length };
}
return null;
}
function hexFrom(tag: string): string | null {
const m = /color:\s*(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)/.exec(tag);
return m ? m[1].toLowerCase() : null;
}
export const toggleUnderline: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
// Selection bounded by the tags: unwrap.
if (
from - 3 >= 0 &&
slice(state, from - 3, from) === "<u>" &&
slice(state, to, to + 4) === "</u>"
) {
dispatch({
changes: [
{ from: from - 3, to: from, insert: "" },
{ from: to, to: to + 4, insert: "" },
],
selection: EditorSelection.single(from - 3, to - 3),
});
return true;
}
// Selection includes the tags: unwrap.
const text = slice(state, from, to);
if (text.startsWith("<u>") && text.endsWith("</u>")) {
dispatch({
changes: [
{ from, to: from + 3, insert: "" },
{ from: to - 4, to, insert: "" },
],
selection: EditorSelection.single(from, to - 7),
});
return true;
}
// Cursor inside a wrapper: unwrap it.
const wrapper = htmlSpanAt(state, from, "<u>", "</u>");
if (wrapper && from === to) {
dispatch({
changes: [
{ from: wrapper.from, to: wrapper.textFrom, insert: "" },
{ from: wrapper.textTo, to: wrapper.to, insert: "" },
],
selection: EditorSelection.cursor(wrapper.textFrom),
});
return true;
}
dispatch({
changes: [
{ from, to: from, insert: "<u>" },
{ from: to, to: to, insert: "</u>" },
],
selection: EditorSelection.single(from + 3, to + 3),
});
return true;
};
// Obsidian-compatible highlight markers.
export const toggleHighlight: FormatCommand = toggleInline("==");
function wrapColored(kind: "color" | "bg", color: string): FormatCommand {
const attr = kind === "color" ? "color" : "background-color";
const open = `<span style="${attr}:${color}">`;
const close = "</span>";
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = slice(state, from, to);
// Whole colored span selected: remove the color.
const re = new RegExp(`^<span style="${attr}:[^"]*">([\\s\\S]*)<\\/span>$`);
const full = re.exec(text);
if (full) {
const inner = full[1];
dispatch({
changes: { from, to, insert: inner },
selection: EditorSelection.single(from, from + inner.length),
});
return true;
}
// Selection/cursor inside an existing wrapper: remove if same color, else recolor.
const wrap = htmlSpanAt(state, from, `<span style="${attr}:`, close);
if (wrap) {
const inner = slice(state, wrap.textFrom, wrap.textTo);
const openTag = state.doc.sliceString(wrap.from, wrap.textFrom);
const same = openTag.includes(`:${color}"`);
const insert = same ? inner : open + inner + close;
dispatch({
changes: { from: wrap.from, to: wrap.to, insert },
selection: EditorSelection.single(
wrap.from + (same ? 0 : open.length),
wrap.from + (same ? inner.length : open.length + inner.length)
),
});
return true;
}
if (from === to) {
const insert = open + "text" + close;
dispatch({
changes: { from, to: from, insert },
selection: EditorSelection.range(from + open.length, from + open.length + 4),
});
return true;
}
dispatch({
changes: { from, to, insert: open + text + close },
selection: EditorSelection.single(from + open.length, to + open.length),
});
return true;
};
}
export const toggleTextColor = (color: string): FormatCommand => wrapColored("color", color);
export const toggleHighlightColor = (color: string): FormatCommand => wrapColored("bg", color);
function stripSpan(region: string, attr: string): string {
return region.replace(
new RegExp(`<span style="${attr}:[^"]*">([\\s\\S]*?)<\\/span>`, "g"),
"$1"
);
}
export const removeColor: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const cleaned = stripSpan(region, "color");
if (cleaned === region) return false;
dispatch({ changes: { from: start, to: end, insert: cleaned } });
return true;
};
export const removeHighlight: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const cleaned = region
.replace(/<span style="background-color:[^"]*">([\s\S]*?)<\/span>/g, "$1")
.replace(/==([\s\S]*?)==/g, "$1");
if (cleaned === region) return false;
dispatch({ changes: { from: start, to: end, insert: cleaned } });
return true;
};
/* ------------------------------------------------------------------ */
/* Task list */
/* ------------------------------------------------------------------ */
const checklistRe = /^([-*+]\s+)\[([ xX])\]\s+/;
export const toggleChecklist: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((l) => checklistRe.test(l));
const insert = lines
.map((l) => {
if (allHave) {
const m = l.match(checklistRe)!;
const checked = m[2].toLowerCase() === "x";
return `${m[1]}[${checked ? " " : "x"}] ${l.slice(m[0].length)}`;
}
const stripped = l.replace(/^([-*+]\s+|\d+\.\s+)/, "");
return `- [ ] ${stripped}`;
})
.join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
/* ------------------------------------------------------------------ */
/* Insertions: wikilink, image, table, callout, attachment */
/* ------------------------------------------------------------------ */
export function insertWikilink(name: string, label?: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = label && label !== name ? `[[${name}|${label}]]` : `[[${name}]]`;
dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length),
});
return true;
};
}
export function insertImage(alt: string, url: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = `![${alt}](${url})`;
dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.range(from + alt.length + 4, from + alt.length + 4 + url.length),
});
return true;
};
}
export function insertTable(rows: number, cols: number): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
const header = `| ${Array.from({ length: cols }, (_, i) => `Col ${i + 1}`).join(" | ")} |`;
const sep = `| ${Array(cols).fill("---").join(" | ")} |`;
const bodyRows = Math.max(1, rows - 1);
const body = Array.from(
{ length: bodyRows },
() => `| ${Array(cols).fill(" ").join(" | ")} |`
).join("\n");
const table = `${header}\n${sep}\n${body}`;
const line = state.doc.lineAt(from);
const prefix = from > 0 && line.from !== from ? "\n" : "";
const insert = `${prefix}${table}\n`;
const cellFrom = from + prefix.length + header.length + 1 + sep.length + 1 + 2;
dispatch({
changes: { from, to: from, insert },
selection: EditorSelection.cursor(cellFrom),
});
return true;
};
}
export function insertCallout(type = "note", title?: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const head = title ? `> [!${type}] ${title}\n` : `> [!${type}]\n`;
const insert = head + "> ";
dispatch({
changes: { from, to, insert },
selection: EditorSelection.cursor(from + insert.length),
});
return true;
};
}
export function insertAttachment(name: string, relPath: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = `[${name}](${relPath})`;
dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length),
});
return true;
};
}
/* ------------------------------------------------------------------ */
/* Alignment (HTML, compatible with Obsidian) */
/* ------------------------------------------------------------------ */
const alignDivRe = /^(<div align="(left|center|right)">)([\s\S]*)(<\/div>)$/;
export function toggleAlign(align: "left" | "center" | "right"): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const m = alignDivRe.exec(region);
if (m) {
if (align === "left" || m[2] === align) {
dispatch({ changes: { from: start, to: end, insert: m[3] } });
} else {
dispatch({ changes: { from: start, to: end, insert: `<div align="${align}">${m[3]}</div>` } });
}
return true;
}
if (align === "left") return false;
const open = `<div align="${align}">`;
dispatch({
changes: { from: start, to: end, insert: `${open}${region}</div>` },
selection: EditorSelection.single(start + open.length, end + open.length),
});
return true;
};
}
/* ------------------------------------------------------------------ */
/* Explicit code block */
/* ------------------------------------------------------------------ */
function fencedBlockAt(state: EditorState, pos: number): { from: number; to: number } | null {
const doc = state.doc;
const line = doc.lineAt(pos);
let open = fenceLineRe.test(line.text) ? line.number : -1;
if (open < 0) {
for (let n = line.number - 1; n >= 1; n--) {
if (fenceLineRe.test(doc.line(n).text)) {
open = n;
break;
}
}
}
if (open < 1) return null;
for (let n = open + 1; n <= doc.lines; n++) {
if (fenceLineRe.test(doc.line(n).text)) {
return { from: doc.line(open).from, to: doc.line(n).to };
}
}
return null;
}
export const toggleFencedCode: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const block = fencedBlockAt(state, start);
if (block) {
const body = slice(state, block.from, block.to).split("\n").slice(1, -1).join("\n");
dispatch({ changes: { from: block.from, to: block.to, insert: body } });
return true;
}
const region = slice(state, start, end);
dispatch({
changes: { from: start, to: end, insert: "```\n" + region + "\n```" },
selection: EditorSelection.single(start + 4, end + 4),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Active-state detection */
/* ------------------------------------------------------------------ */
export interface FormatState {
bold: boolean;
italic: boolean;
strike: boolean;
underline: boolean;
highlight: boolean;
inlineCode: boolean;
color: string | null;
hColor: string | null;
heading: number;
list: "bullet" | "ordered" | "checklist" | null;
align: "left" | "center" | "right" | null;
}
function countOccurrences(text: string, sub: string): number {
let count = 0;
let i = 0;
while ((i = text.indexOf(sub, i)) !== -1) {
count += 1;
i += sub.length;
}
return count;
}
function oddBefore(line: string, pos: number, marker: string): boolean {
return countOccurrences(line.slice(0, pos), marker) % 2 === 1;
}
export function detectFormatting(state: EditorState, pos: number): FormatState {
const line = state.doc.lineAt(pos);
const inLine = pos - line.from;
const before = line.text.slice(0, inLine);
const italic = (countOccurrences(before, "*") - 2 * countOccurrences(before, "**")) % 2 === 1;
let list: FormatState["list"] = null;
if (checklistRe.test(line.text)) list = "checklist";
else if (/^[-*+]\s+/.test(line.text)) list = "bullet";
else if (orderedRe.test(line.text)) list = "ordered";
let align: FormatState["align"] = null;
const div = htmlSpanAt(state, pos, "<div align=", "</div>");
if (div) {
const tag = state.doc.sliceString(div.from, div.textFrom).toLowerCase();
if (tag.includes('align="center"')) align = "center";
else if (tag.includes('align="right"')) align = "right";
else align = "left";
}
const u = htmlSpanAt(state, pos, "<u>", "</u>");
const mark = htmlSpanAt(state, pos, '<mark style="background-color:', "</mark>");
const span = htmlSpanAt(state, pos, '<span style="color:', "</span>");
return {
bold: oddBefore(line.text, inLine, "**"),
italic,
strike: oddBefore(line.text, inLine, "~~"),
underline: u !== null,
highlight: oddBefore(line.text, inLine, "==") || mark !== null,
inlineCode: oddBefore(line.text, inLine, "`"),
color: span ? hexFrom(state.doc.sliceString(span.from, span.textFrom)) : null,
hColor: mark ? hexFrom(state.doc.sliceString(mark.from, mark.textFrom)) : null,
heading: headingLevelAt(state, pos),
list,
align,
};
}
export const captureFormat: (state: EditorState, pos: number) => FormatState = detectFormatting;
/* ------------------------------------------------------------------ */
/* Format painter */
/* ------------------------------------------------------------------ */
function selectionWrapped(state: EditorState, open: string, close: string): boolean {
const { from, to } = state.selection.main;
const t = slice(state, from, to);
return t.startsWith(open) && t.endsWith(close);
}
function ensureWrap(target: FormatTarget, open: string, close: string): boolean {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
if (from === to) return false;
dispatch({
changes: [
{ from, to: from, insert: open },
{ from: to, to: to, insert: close },
],
selection: EditorSelection.single(from + open.length, to + open.length),
});
return true;
}
export function applyFormatPainter(target: FormatTarget, fmt: FormatState): boolean {
let changed = false;
if (fmt.bold && !selectionWrapped(target.state, "**", "**")) changed = ensureWrap(target, "**", "**") || changed;
if (fmt.italic && !selectionWrapped(target.state, "*", "*")) changed = ensureWrap(target, "*", "*") || changed;
if (fmt.strike && !selectionWrapped(target.state, "~~", "~~")) changed = ensureWrap(target, "~~", "~~") || changed;
if (fmt.underline && !selectionWrapped(target.state, "<u>", "</u>")) changed = ensureWrap(target, "<u>", "</u>") || changed;
if (fmt.highlight && !selectionWrapped(target.state, "==", "==")) changed = ensureWrap(target, "==", "==") || changed;
const hasSelection =
target.state.selection.main.from !== target.state.selection.main.to;
if (fmt.color && hasSelection) {
const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to);
if (!/^<span style="color:[^"]*">/.test(t)) changed = toggleTextColor(fmt.color)(target) || changed;
}
if (fmt.hColor && hasSelection) {
const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to);
if (!/^<span style="background-color:[^"]*">/.test(t)) changed = toggleHighlightColor(fmt.hColor)(target) || changed;
}
const cur = detectFormatting(target.state, target.state.selection.main.from);
if (fmt.heading && cur.heading !== fmt.heading) changed = setHeading(fmt.heading)(target) || changed;
if (fmt.list && cur.list !== fmt.list) {
if (fmt.list === "bullet") changed = toggleBulletList(target) || changed;
else if (fmt.list === "ordered") changed = toggleOrderedList(target) || changed;
else if (fmt.list === "checklist") changed = toggleChecklist(target) || changed;
}
return changed;
}

View file

@ -0,0 +1,82 @@
// Pure assembly logic for the Full Book view.
//
// The Full Book renders every chapter's native rich-text document back-to-back
// in a single read-only ProseMirror surface, separated by ChapterBreak anchors.
// These helpers are kept free of DOM/IPC so they can be unit-tested directly
// and reused by the renderer (book.ts).
import type { FolioNode } from "../main/native-doc.js";
import type { WikilinkTarget } from "./wikilinks.js";
export interface FullBookChapterInput {
id: string;
title: string;
// Native ProseMirror document ({ type: "doc", content: [...] }).
doc: FolioNode;
}
export interface FullBookAssembly {
nativeDoc: FolioNode;
chapterEntries: { id: string; title: string }[];
targets: WikilinkTarget[];
}
// Concatenate the plain text of a native node tree (used for word/char counts).
// Atom leaf nodes (chapterBreak, image, horizontal rule, …) carry no text.
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 the native content already opens with a heading matching its title,
// in which case we must not inject a duplicate title heading.
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;
}
// Build the top-level nodes for one chapter in the Full Book, 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,
];
}
// A ChapterBreak anchor marking where a chapter begins in the assembled view.
export function chapterBreakNode(id: string, title: string, first: boolean): FolioNode {
return { type: "chapterBreak", attrs: { chapterId: id, title, first } };
}
// Assemble the whole book directly from native chapter documents. The first
// chapter's break is unmarked; every following chapter gets a separator break.
export function buildFullBookDoc(chapters: FullBookChapterInput[]): FullBookAssembly {
const nativeContent: FolioNode[] = [];
const chapterEntries: { id: string; title: string }[] = [];
const targets: WikilinkTarget[] = [];
let index = 0;
for (const ch of chapters) {
targets.push({ id: ch.id, title: ch.title });
const chapterDoc =
ch.doc && ch.doc.type === "doc" ? ch.doc : { type: "doc" as const, content: [] };
const content = Array.isArray(chapterDoc.content) ? chapterDoc.content : [];
nativeContent.push(chapterBreakNode(ch.id, ch.title, index === 0));
for (const n of buildChapterNativeContent(content, ch.title)) nativeContent.push(n);
chapterEntries.push({ id: ch.id, title: ch.title });
index++;
}
return {
nativeDoc: { type: "doc", content: nativeContent },
chapterEntries,
targets,
};
}

View file

@ -1,136 +1,170 @@
// Print rendering for the PDF-exported manuscript (#printBook).
//
// The PDF is produced by printing plain HTML, so this renderer has to keep
// every formatting feature Folio's editor can write. Standard markdown is
// handled by markdown-it; the extensions on top of it are:
// - raw HTML passthrough (html: true) so the toolbar's <u>, <span style=...>
// and <div align=...> survive instead of showing as literal text,
// - Obsidian-style ==highlight== rendered as <mark>,
// - Obsidian callouts (> [!note] Title) rendered as styled divs,
// - wikilinks ([[Title]]) resolved to links via the shared wikilink plugin.
import MarkdownIt from "markdown-it";
import type {
MarkdownIt as MarkdownItInstance,
StateCore,
StateInline,
} from "markdown-it";
import { wikilinkPlugin, type WikilinkTarget } from "./wikilinks.js";
// The PDF is produced by printing plain HTML, so this renderer walks the
// native Folio document model directly and emits HTML that keeps every
// formatting feature the editor can write. Chapters are emitted as
// <section class="print-chapter"> blocks, delimited by the chapterBreak nodes
// the Full Book assembly inserts between chapters.
import type { FolioMark, FolioNode } from "../main/native-doc.js";
// ==text== -> <mark>text</mark>
function highlightPlugin(md: MarkdownItInstance) {
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x3d /* = */ || src.charCodeAt(start + 1) !== 0x3d)
return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x0a /* newline */) return false;
if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) {
end = i;
break;
}
}
if (end === -1) return false;
const content = src.slice(start + 2, end);
if (!content.trim()) return false;
if (silent) {
state.pos = end + 2;
return true;
}
state.push("highlight_open", "mark", 1);
state.push("text", "", 0).content = content;
state.push("highlight_close", "mark", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("emphasis", "highlight", parse);
md.renderer.rules.highlight_open = () => "<mark>";
md.renderer.rules.highlight_close = () => "</mark>";
// Native-document print rendering for the PDF-exported manuscript (#printBook).
//
// Like renderPrintChapters but it consumes the native Folio document model
// directly instead of Markdown, so PDF output stays in sync with the editor
// after the migration. Chapters are emitted as <section class="print-chapter">
// blocks, delimited by the chapterBreak nodes that the Full Book assembly
// inserts between chapters.
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/;
const ALIGN = (node?: FolioNode): string => {
const a = node?.attrs;
const parts: string[] = [];
const ta = a?.textAlign ? String(a.textAlign) : "";
if (ta && ta !== "left") parts.push(`text-align:${esc(ta)}`);
if (a?.lineHeight) parts.push(`line-height:${esc(String(a.lineHeight))}`);
return parts.length ? ` style="${parts.join(";")}"` : "";
};
// > [!note] Title followed by "> body" lines becomes a styled callout div.
// Runs after inline parsing so blockquote paragraphs already have children.
function calloutPlugin(md: MarkdownItInstance) {
md.core.ruler.after("inline", "callout", (state: StateCore) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const open = tokens[i];
if (open.type !== "blockquote_open") continue;
let depth = 1;
let closeIdx = -1;
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === "blockquote_open") depth += 1;
else if (t.type === "blockquote_close") {
depth -= 1;
if (depth === 0) {
closeIdx = j;
break;
}
}
}
if (closeIdx === -1) continue;
const paraOpen = tokens[i + 1];
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
const inline = tokens[i + 2];
if (!inline || inline.type !== "inline" || !inline.children) continue;
const first = inline.children[0];
if (!first || first.type !== "text") continue;
const m = CALLOUT_RE.exec(first.content);
if (!m) continue;
const calloutType = m[1].toLowerCase();
const title = m[2].trim();
open.type = "callout_open";
open.tag = "div";
open.attrs = [["class", `callout callout-${calloutType}`]];
const close = tokens[closeIdx];
close.type = "callout_close";
close.tag = "div";
// Strip the "[!type] Title" marker from the first paragraph and drop
// any text tokens that became empty as a result (plus the softbreak the
// marker line left behind).
first.content = "";
inline.children = inline.children.filter(
(c) => !(c.type === "text" && c.content === "")
);
if (inline.children[0]?.type === "softbreak") inline.children.shift();
if (title) {
const titleOpen = new state.Token("paragraph_open", "p", 1);
titleOpen.attrs = [["class", "callout-title"]];
const titleInline = new state.Token("inline", "", 0);
titleInline.content = title;
const titleText = new state.Token("text", "", 0);
titleText.content = title;
titleInline.children = [titleText];
const titleClose = new state.Token("paragraph_close", "p", -1);
tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose);
}
}
});
md.renderer.rules.callout_open = (tokens, idx) =>
`<div class="${tokens[idx].attrGet("class") ?? "callout"}">`;
md.renderer.rules.callout_close = () => "</div>";
// Wrap a string with the formatting marks on a text node. Order matters: link
// is outermost, then font/size, then color/highlight, then underline/strike,
// then bold/italic, with code innermost.
function applyMarksHtml(text: string, marks: FolioMark[] = []): string {
let out = text;
const has = (t: string) => marks.some((m) => m.type === t);
if (has("code")) out = `<code>${out}</code>`;
if (has("italic")) out = `<em>${out}</em>`;
if (has("bold")) out = `<strong>${out}</strong>`;
if (has("strike")) out = `<s>${out}</s>`;
if (has("underline")) out = `<u>${out}</u>`;
const hl = marks.find((m) => m.type === "highlight");
if (hl) {
const color = hl.attrs?.color ? String(hl.attrs.color) : "";
out = color ? `<span style="background-color:${esc(color)}">${out}</span>` : `<mark>${out}</mark>`;
}
const tc = marks.find((m) => m.type === "textColor");
if (tc?.attrs?.color) out = `<span style="color:${esc(String(tc.attrs.color))}">${out}</span>`;
const ff = marks.find((m) => m.type === "fontFamily");
if (ff?.attrs?.fontFamily) out = `<span style="font-family:${esc(String(ff.attrs.fontFamily))}">${out}</span>`;
const fsz = marks.find((m) => m.type === "fontSize");
if (fsz?.attrs?.fontSize) out = `<span style="font-size:${esc(String(fsz.attrs.fontSize))}">${out}</span>`;
const link = marks.find((m) => m.type === "link");
if (link?.attrs?.href) {
const href = String(link.attrs.href);
const internal = href.startsWith("folio:chapter/");
const cls = internal ? ' class="wikilink internal"' : "";
out = `<a href="${esc(href)}"${cls}>${out}</a>`;
}
return out;
}
export function renderPrintChapters(
chapters: string[],
targets: WikilinkTarget[]
): string {
const md = new MarkdownIt({ html: true });
md.use(wikilinkPlugin, { chapters: targets });
md.use(highlightPlugin);
md.use(calloutPlugin);
return chapters
.map((c) => `<section class="print-chapter">${md.render(c)}</section>`)
function inlineToHtml(nodes: FolioNode[] = []): string {
let out = "";
for (const n of nodes) {
if (n.type === "text") out += applyMarksHtml(n.text ?? "", n.marks);
else if (n.type === "hard_break") out += "<br>";
else if (n.type === "image") {
const src = esc(String(n.attrs?.src ?? ""));
const alt = esc(String(n.attrs?.alt ?? ""));
out += `<img src="${src}" alt="${alt}">`;
}
}
return out;
}
function blockToHtml(node: FolioNode): string {
switch (node.type) {
case "heading": {
const level = Number(node.attrs?.level ?? 1);
return `<h${level}${ALIGN(node)}>${inlineToHtml(node.content)}</h${level}>`;
}
case "paragraph": {
const isTitle = String(node.attrs?.class ?? "") === "callout-title";
const cls = isTitle ? ' class="callout-title"' : "";
return `<p${cls}${ALIGN(node)}>${inlineToHtml(node.content)}</p>`;
}
case "bulletList":
return `<ul>${listItemsToHtml(node.content)}</ul>`;
case "orderedList":
return `<ol>${listItemsToHtml(node.content)}</ol>`;
case "taskList":
return `<ul class="task-list">${listItemsToHtml(node.content, true)}</ul>`;
case "blockquote":
return calloutToHtml(node);
case "codeBlock": {
const text = (node.content ?? []).map((c) => c.text ?? "").join("");
return `<pre><code>${esc(text)}</code></pre>`;
}
case "horizontalRule":
return "<hr>";
case "table":
return tableToHtml(node);
case "image":
return `<img src="${esc(String(node.attrs?.src ?? ""))}" alt="${esc(String(node.attrs?.alt ?? ""))}">`;
default:
return "";
}
}
function listItemsToHtml(items: FolioNode[] = [], isTask = false): string {
return items
.map((item) => {
const checked = isTask && !!item.attrs?.checked;
const prefix = isTask ? (checked ? "&#9746; " : "&#9744; ") : "";
const inner = (item.content ?? []).map(blockToHtml).join("");
return `<li>${prefix}${inner}</li>`;
})
.join("");
}
function calloutToHtml(node: FolioNode): string {
const isCallout = !!node.attrs?.callout;
if (!isCallout) {
return `<blockquote>${node.content?.map(blockToHtml).join("") ?? ""}</blockquote>`;
}
const type = String(node.attrs?.callout);
return `<div class="callout callout-${esc(type)}">${
node.content?.map(blockToHtml).join("") ?? ""
}</div>`;
}
function tableToHtml(node: FolioNode): string {
const rows = (node.content ?? [])
.map((row) => {
const cells = (row.content ?? [])
.map((cell) => {
const tag = cell.type === "tableHeader" ? "th" : "td";
return `<${tag}>${inlineToHtml((cell.content?.[0]?.content ?? []) as FolioNode[])}</${tag}>`;
})
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<table class="folio-table"><tbody>${rows}</tbody></table>`;
}
export function renderPrintChaptersNative(doc: FolioNode): string {
const sections: string[] = [];
let current = "";
let started = false;
for (const node of doc.content ?? []) {
if (node.type === "chapterBreak") {
if (started || current) {
sections.push(`<section class="print-chapter">${current}</section>`);
}
current = "";
started = true;
continue;
}
current += blockToHtml(node);
}
if (started || current) {
sections.push(`<section class="print-chapter">${current}</section>`);
}
return sections.join("");
}

View file

@ -1,10 +1,10 @@
/* Folio themes.
Every interface color is driven by the semantic variables below; the UI and
CodeMirror styles only ever reference these variables. A theme is therefore
editor styles only ever reference these variables. A theme is therefore
just a block of variable values — adding one means dropping a
`:root[data-theme="..."]` block here plus an <option> in the theme selector.
Light is Folio's default: a warm, writing-focused light scheme. Neonflux is
Light is Folio's default: a warm, writing-focused light scheme. Dark Mode is
a fixed dark, futuristic theme. */
/* ---- shared (non-color) tokens ---- */
@ -47,11 +47,11 @@
--color-scrollbar-hover: rgba(45, 40, 32, 0.38);
}
/* ---- Neonflux ----
/* ---- Dark Mode ----
Dark, high-contrast, futuristic. A near-black neutral base keeps the
manuscript comfortable for long writing sessions while electric-cyan accent
marks active/selected/focused elements, links and controls. */
:root[data-theme="neonflux"] {
:root[data-theme="dark-mode"] {
--color-bg: #0a0d14;
--color-panel: #121722;
--color-border: #24304a;
@ -64,8 +64,8 @@
--color-on-accent-soft: rgba(6, 19, 28, 0.18);
--color-on-accent-border: rgba(6, 19, 28, 0.38);
--color-selection: rgba(34, 211, 238, 0.16);
--color-selection-strong: rgba(34, 211, 238, 0.34);
--color-selection: rgba(34, 211, 238, 0.55);
--color-selection-strong: rgba(34, 211, 238, 0.85);
--color-shadow: rgba(0, 0, 0, 0.55);
--color-shadow-strong: rgba(0, 0, 0, 0.65);

View file

@ -0,0 +1,573 @@
// Tiptap 2 (ProseMirror) rich-text editor for Folio chapters.
//
// This is the native editor that replaces the Markdown/CodeMirror editing
// experience. The editor state is ProseMirror/Tiptap JSON and is persisted
// directly as the native Folio chapter document (see src/main/native-doc.ts).
// No Markdown is ever held as the editing model.
//
// Formatting features map to ProseMirror nodes/marks that are also produced by
// the Markdown -> native converter, so migrated chapters load and re-save
// losslessly.
import { Editor, Extension, Mark, Node, mergeAttributes } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import TextAlign from "@tiptap/extension-text-align";
import Highlight from "@tiptap/extension-highlight";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import type { FolioChapterDoc, FolioNode } from "../main/native-doc.js";
// --- Custom marks matching the native Folio schema -------------------------
// The native model uses `textColor` (not Tiptap's `textStyle`) so the
// Markdown bridge (native-to-markdown) and the converter agree. Font family
// and size are stored as their own marks.
const TextColor = Mark.create({
name: "textColor",
addAttributes() {
return {
color: {
default: null,
parseHTML: (el) => (el as HTMLElement).style.color || null,
renderHTML: (attrs) =>
attrs.color ? { style: `color: ${attrs.color}` } : {},
},
};
},
parseHTML() {
return [{ style: "color" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
});
const FontFamily = Mark.create({
name: "fontFamily",
addAttributes() {
return {
fontFamily: {
default: null,
parseHTML: (el) => (el as HTMLElement).style.fontFamily || null,
renderHTML: (attrs) =>
attrs.fontFamily
? { style: `font-family: ${attrs.fontFamily}` }
: {},
},
};
},
parseHTML() {
return [{ style: "font-family" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
});
const FontSize = Mark.create({
name: "fontSize",
addAttributes() {
return {
fontSize: {
default: null,
parseHTML: (el) => (el as HTMLElement).style.fontSize || null,
renderHTML: (attrs) =>
attrs.fontSize ? { style: `font-size: ${attrs.fontSize}` } : {},
},
};
},
parseHTML() {
return [{ style: "font-size" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
});
// Line spacing is a block-level property (paragraphs and headings), so it is
// added as a global attribute on those nodes rather than a mark.
const LineHeight = Extension.create({
name: "lineHeight",
addGlobalAttributes() {
return [
{
types: ["paragraph", "heading"],
attributes: {
lineHeight: {
default: null,
parseHTML: (el: HTMLElement) => el.style.lineHeight || null,
renderHTML: (attrs: Record<string, any>) =>
attrs.lineHeight ? { style: `line-height: ${attrs.lineHeight}` } : {},
},
},
},
];
},
});
// Hard break. Registered as `hard_break` (snake_case) to match the rest of the
// pipeline (markdown-to-native, native-to-markdown, print/office export). StarterKit
// ships HardBreak as `hardBreak`, which would reject loaded docs containing a
// `hard_break` node and leave the editor empty, so we override it here.
const HardBreak = Node.create({
name: "hard_break",
inline: true,
group: "inline",
selectable: false,
parseHTML() {
return [{ tag: "br" }];
},
renderHTML() {
return ["br"];
},
addCommands() {
return {
setHardBreak:
() =>
({ chain }: any) =>
chain().insertContent({ type: "hard_break" }).run(),
};
},
addKeyboardShortcuts() {
return {
"Shift-Enter": () => this.editor.commands.setHardBreak(),
"Mod-Enter": () => this.editor.commands.setHardBreak(),
};
},
});
// Block separator rendered between chapters in the Full Book view. It carries
// the chapter id/title as data attributes so navigation can scroll to it via
// the DOM, and renders a subtle on-screen rule (except for the first chapter).
// It contributes no text, so word/character counts ignore it.
export const ChapterBreak = Node.create({
name: "chapterBreak",
group: "block",
atom: true,
selectable: false,
draggable: false,
addAttributes() {
return {
chapterId: { default: "" },
title: { default: "" },
first: { default: false },
};
},
parseHTML() {
return [{ tag: "div[data-chapter-id]" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
class: "chapter-break" + (node.attrs.first ? " first" : ""),
"data-chapter-id": node.attrs.chapterId as string,
"data-title": node.attrs.title as string,
"data-first": node.attrs.first ? "true" : "false",
}),
];
},
});
export interface FormatState {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
code: boolean;
link: boolean;
heading: number; // 0 = paragraph
bullet: boolean;
ordered: boolean;
task: boolean;
blockquote: boolean;
align: string;
color: string;
highlight: string;
fontFamily: string;
fontSize: string;
lineHeight: string;
}
export interface TiptapCallbacks {
onChange?: () => void;
onSelectionUpdate?: () => void;
}
export interface TiptapOptions {
// Extra ProseMirror/Tiptap extensions (e.g. the Full Book ChapterBreak node).
extensions?: any[];
// Defaults to true (editable). Pass false for a read-only surface.
editable?: boolean;
}
// Clean office/HTML clipboard content so it pastes reliably. Office suites
// (OnlyOffice, Word, LibreOffice) put heavily-namespaced, style-laden HTML on
// the clipboard; ProseMirror's default paste often discards it entirely. We
// strip the office cruft but keep the real text and basic formatting.
function sanitizeClipboardHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
doc
.querySelectorAll("script, style, meta, link, title, head, noscript")
.forEach((n) => n.remove());
const all = Array.from(doc.body.querySelectorAll("*"));
for (const el of all) {
// Unwrap namespaced (office) elements, keeping their content.
if (/:/.test(el.tagName)) {
const parent = el.parentNode;
if (parent) {
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
}
continue;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
if (
name === "style" ||
name === "class" ||
name === "id" ||
name.startsWith("data-") ||
name.startsWith("xmlns")
) {
el.removeAttribute(attr.name);
}
}
}
return doc.body.innerHTML;
}
// Pull just the visible text out of HTML (used as a last-resort paste fallback
// when an office clipboard provides HTML that ProseMirror can't parse but no
// usable text/plain).
function htmlToText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
const text = doc.body.textContent || "";
return text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
export function createTiptapEditor(
host: HTMLElement,
callbacks: TiptapCallbacks = {},
options: TiptapOptions = {}
): Editor {
const editor = new Editor({
element: host,
editable: options.editable !== false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
hardBreak: false,
}),
Underline,
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: "noopener noreferrer" },
}),
Image.configure({ inline: false, allowBase64: false }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Highlight.configure({ multicolor: true }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
TextColor,
FontFamily,
FontSize,
LineHeight,
HardBreak,
...(options.extensions ?? []),
],
editorProps: {
attributes: {
class: "folio-prose",
spellcheck: "true",
},
},
onUpdate: () => callbacks.onChange?.(),
onSelectionUpdate: () => callbacks.onSelectionUpdate?.(),
});
// Robust paste. We handle the `paste` event ourselves (capture phase on
// document, before ProseMirror's own handler) and insert sanitized content
// via Tiptap directly. ProseMirror's default clipboard pipeline silently
// dropped Office/OnlyOffice HTML, and a handlePaste that re-invoked
// pasteHTML recursed and blocked all pasting — so we bypass it entirely.
const dom = editor.view.dom as HTMLElement;
const onPaste = (e: ClipboardEvent) => {
const t = e.target as globalThis.Node | null;
const inside = (t && dom.contains(t)) || document.activeElement === dom;
if (!inside) return; // let other fields (inputs, etc.) paste normally
const data = e.clipboardData;
if (!data) return;
const html = data.getData("text/html");
const text = data.getData("text/plain");
if (!html && !text) return;
e.preventDefault();
e.stopPropagation();
editor.commands.focus();
const insert = (content: string) => {
try {
editor.commands.insertContent(content);
} catch {
try {
editor.commands.insertContent(text || htmlToText(html));
} catch {
/* nothing we can do */
}
}
};
if (html) {
const clean = sanitizeClipboardHtml(html);
if (clean.replace(/<[^>]*>/g, "").trim().length > 0) {
insert(clean);
return;
}
}
if (text) {
insert(text);
return;
}
if (html) insert(htmlToText(html));
};
document.addEventListener("paste", onPaste, true);
const originalDestroy = editor.destroy.bind(editor);
editor.destroy = (...args: Parameters<typeof originalDestroy>) => {
document.removeEventListener("paste", onPaste, true);
return originalDestroy(...args);
};
return editor;
}
export function setEditorDoc(editor: Editor, doc: FolioNode): void {
// An empty document (no top-level blocks) leaves ProseMirror with nowhere to
// place the cursor, so brand-new/empty chapters can't be typed into or pasted
// until the user forces a block in. Seed one paragraph so the caret always
// has a home and paste works immediately.
const content = (doc as { content?: unknown[] } | null)?.content;
const safe =
content && Array.isArray(content) && content.length > 0
? doc
: ({ type: "doc", content: [{ type: "paragraph" }] } as unknown as FolioNode);
editor.commands.setContent(safe as unknown as Record<string, unknown>, false);
}
export function getEditorDoc(editor: Editor): FolioChapterDoc {
return {
schemaVersion: 1,
doc: editor.getJSON() as unknown as FolioNode,
};
}
export function getFormatState(editor: Editor): FormatState {
const a = (name: string) => editor.getAttributes(name);
let heading = 0;
for (let l = 1; l <= 6; l++) {
if (editor.isActive("heading", { level: l })) {
heading = l;
break;
}
}
return {
bold: editor.isActive("bold"),
italic: editor.isActive("italic"),
underline: editor.isActive("underline"),
strike: editor.isActive("strike"),
code: editor.isActive("code"),
link: editor.isActive("link"),
heading,
bullet: editor.isActive("bulletList"),
ordered: editor.isActive("orderedList"),
task: editor.isActive("taskList"),
blockquote: editor.isActive("blockquote"),
align: (a("textAlign").textAlign as string) || "",
color: (a("textColor").color as string) || "",
highlight: (a("highlight").color as string) || "",
fontFamily: (a("fontFamily").fontFamily as string) || "",
fontSize: (a("fontSize").fontSize as string) || "",
lineHeight: editor.isActive("heading")
? (a("heading").lineHeight as string) || ""
: (a("paragraph").lineHeight as string) || "",
};
}
// Apply a toolbar command by name. `payload` carries extra data (URLs, colors,
// table dimensions, etc.).
export function runCommand(
editor: Editor,
cmd: string,
payload: Record<string, unknown> = {}
): void {
const chain = () => editor.chain().focus();
switch (cmd) {
case "bold":
chain().toggleBold().run();
break;
case "italic":
chain().toggleItalic().run();
break;
case "underline":
chain().toggleUnderline().run();
break;
case "strike":
chain().toggleStrike().run();
break;
case "code":
chain().toggleCode().run();
break;
case "link": {
const href = String(payload.href ?? "").trim();
if (!href) chain().unsetLink().run();
else chain().extendMarkRange("link").setLink({ href }).run();
break;
}
case "hr":
chain().setHorizontalRule().run();
break;
case "bullet":
chain().toggleBulletList().run();
break;
case "number":
chain().toggleOrderedList().run();
break;
case "checklist":
chain().toggleTaskList().run();
break;
case "quote":
chain().toggleBlockquote().run();
break;
case "codeblock":
chain().toggleCodeBlock().run();
break;
case "paragraph":
chain().setParagraph().run();
break;
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6": {
const level = Number(cmd.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6;
if (editor.isActive("heading", { level })) chain().setParagraph().run();
else chain().toggleHeading({ level }).run();
break;
}
case "align-left":
chain().setTextAlign("left").run();
break;
case "align-center":
chain().setTextAlign("center").run();
break;
case "align-right":
chain().setTextAlign("right").run();
break;
case "align-justify":
chain().setTextAlign("justify").run();
break;
case "indent":
chain().sinkListItem("listItem").run();
break;
case "outdent":
chain().liftListItem("listItem").run();
break;
case "clear":
chain().unsetAllMarks().run();
break;
case "color": {
const c = payload.color ? String(payload.color) : "";
if (c) chain().setMark("textColor", { color: c }).run();
else chain().unsetMark("textColor").run();
break;
}
case "highlight": {
const c = payload.color ? String(payload.color) : "";
if (c) chain().toggleHighlight({ color: c }).run();
else chain().unsetHighlight().run();
break;
}
case "fontfamily": {
const f = payload.fontFamily ? String(payload.fontFamily) : "";
if (f) chain().setMark("fontFamily", { fontFamily: f }).run();
else chain().unsetMark("fontFamily").run();
break;
}
case "fontsize": {
const s = payload.fontSize ? String(payload.fontSize) : "";
if (s) chain().setMark("fontSize", { fontSize: s }).run();
else chain().unsetMark("fontSize").run();
break;
}
case "linespacing": {
const lh = payload.lineHeight ? String(payload.lineHeight) : "";
const block = editor.isActive("heading") ? "heading" : "paragraph";
if (lh) chain().updateAttributes(block, { lineHeight: lh }).run();
else chain().updateAttributes(block, { lineHeight: null }).run();
break;
}
case "table":
chain()
.insertTable({
rows: Number(payload.rows ?? 3),
cols: Number(payload.cols ?? 2),
withHeaderRow: true,
})
.run();
break;
case "image": {
const src = String(payload.src ?? "");
if (src) {
chain()
.setImage({ src, alt: String(payload.alt ?? "") })
.run();
}
break;
}
case "wikilink": {
const href = String(payload.href ?? "");
const text = String(payload.text ?? href);
if (href) {
chain().insertContent({
type: "text",
text,
marks: [{ type: "link", attrs: { href } }],
}).run();
}
break;
}
case "addRowBefore":
chain().addRowBefore().run();
break;
case "addRowAfter":
chain().addRowAfter().run();
break;
case "addColumnBefore":
chain().addColumnBefore().run();
break;
case "addColumnAfter":
chain().addColumnAfter().run();
break;
case "deleteRow":
chain().deleteRow().run();
break;
case "deleteColumn":
chain().deleteColumn().run();
break;
case "deleteTable":
chain().deleteTable().run();
break;
default:
break;
}
}