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

223
tests/run-editor-test.mjs Normal file
View file

@ -0,0 +1,223 @@
// Runner for the Tiptap editor test. Sets up a jsdom DOM, then dynamically
// imports the bundled editor module so Tiptap sees a browser-like environment.
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
const { window } = dom;
const g = globalThis;
g.window = window;
g.document = window.document;
g.HTMLElement = window.HTMLElement;
g.Element = window.Element;
g.Node = window.Node;
g.MutationObserver = window.MutationObserver;
g.getComputedStyle = window.getComputedStyle.bind(window);
g.DOMParser = window.DOMParser;
g.requestAnimationFrame = (cb) => setTimeout(cb, 0);
g.cancelAnimationFrame = (id) => clearTimeout(id);
const out = path.join(os.tmpdir(), `folio-editor-${process.pid}.mjs`);
const entry = path.resolve("tests/.folio-editor-test-entry.ts");
await esbuild.build({
entryPoints: [entry],
bundle: true,
platform: "node",
format: "esm",
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const {
createTiptapEditor,
runCommand,
getEditorDoc,
setEditorDoc,
setChapterDoc,
getChapterDoc,
parseChapter,
createBook,
saveMeta,
markdownToNative,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function findNodes(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content ?? []) findNodes(c, type, acc);
return acc;
}
function findMarks(node, type, acc = []) {
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
for (const c of node.content ?? []) findMarks(c, type, acc);
return acc;
}
function allText(node, acc = "") {
if (node.text) acc += node.text;
for (const c of node.content ?? []) acc = allText(c, acc);
return acc;
}
function makeEditor() {
const host = window.document.createElement("div");
window.document.body.appendChild(host);
return createTiptapEditor(host, {});
}
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-ed-"));
const meta = createBook(book, "Editor Book");
const id = "chap-1";
meta.chapters[id] = { id, title: "Chapter One", file: `chapters/${id}.json` };
meta.chapterOrder = [id];
saveMeta(book, meta);
// --- editor command -> state (each feature on a fresh editor) ---
function editorDocAfter(commands) {
const ed = makeEditor();
for (const c of commands) {
if (typeof c === "function") c(ed);
else runCommand(ed, c.cmd, c.payload);
}
const d = getEditorDoc(ed);
return { ed, doc: d.doc };
}
let r;
r = editorDocAfter([{ cmd: "h2" }, (ed) => ed.commands.insertContent("Title")]);
ok(r.doc.type === "doc", "editor state is a ProseMirror doc");
ok(findNodes(r.doc, "heading").some((h) => h.attrs?.level === 2), "heading level 2 present");
ok(allText(r.doc).includes("Title"), "inserted heading text present");
r = editorDocAfter([{ cmd: "bullet" }, (ed) => ed.commands.insertContent("item one")]);
ok(findNodes(r.doc, "bulletList").length === 1, "bullet list present");
ok(allText(r.doc).includes("item one"), "bullet list text present");
r = editorDocAfter([
{ cmd: "bold" },
(ed) => ed.commands.insertContent("bolded"),
{ cmd: "color", payload: { color: "#ff0000" } },
(ed) => ed.commands.insertContent("red"),
]);
ok(findMarks(r.doc, "bold").length >= 1, "bold mark present");
ok(findMarks(r.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "text color mark present");
r = editorDocAfter([
{ cmd: "table", payload: { rows: 2, cols: 2 } },
{ cmd: "image", payload: { src: "assets/x.png", alt: "x" } },
]);
ok(findNodes(r.doc, "table").length === 1, "table present");
ok(findNodes(r.doc, "image").length === 1, "image present");
ok(findNodes(r.doc, "image")[0]?.attrs?.src === "assets/x.png", "image src preserved");
// A richer document combining several features in one editor. Each block-level
// feature is placed in its own block so toggling a list does not consume the
// heading (which is the correct ProseMirror behavior).
const editor = makeEditor();
runCommand(editor, "h2");
editor.commands.insertContent("Hello world");
editor.commands.insertContent({ type: "paragraph" });
runCommand(editor, "bold");
editor.commands.insertContent("bolded");
runCommand(editor, "color", { color: "#ff0000" });
editor.commands.insertContent(" red");
editor.commands.insertContent({ type: "paragraph" });
runCommand(editor, "bullet");
editor.commands.insertContent("list item");
runCommand(editor, "table", { rows: 2, cols: 2 });
runCommand(editor, "image", { src: "assets/x.png", alt: "x" });
const doc = getEditorDoc(editor);
ok(allText(doc.doc).includes("Hello world"), "combined document has text");
ok(findNodes(doc.doc, "heading").some((h) => h.attrs?.level === 2), "combined: heading present");
ok(findMarks(doc.doc, "bold").length >= 1, "combined: bold present");
ok(findNodes(doc.doc, "bulletList").length >= 1, "combined: bullet list present");
ok(findMarks(doc.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "combined: color present");
ok(findNodes(doc.doc, "table").length === 1, "combined: table present");
ok(findNodes(doc.doc, "image").length === 1, "combined: image present");
// --- state -> native persistence ---
setChapterDoc(book, id, doc);
const reloaded = getChapterDoc(book, id);
ok(reloaded.doc.type === "doc", "persisted chapter reads back as doc");
ok(
JSON.stringify(reloaded.doc) === JSON.stringify(doc.doc),
"reload is byte-identical to editor state"
);
// --- reload -> editor (round trip through a fresh editor) ---
const editor2 = makeEditor();
setEditorDoc(editor2, reloaded.doc);
const doc2 = getEditorDoc(editor2);
ok(
JSON.stringify(doc2.doc) === JSON.stringify(doc.doc),
"second editor reproduces same state"
);
// --- Markdown-migrated document loads into the editor ---
const migrated = markdownToNative("# Title\n\nSome **strong** text and a [link](https://e.com).");
const editor3 = makeEditor();
setEditorDoc(editor3, migrated.doc);
const doc3 = getEditorDoc(editor3);
ok(findNodes(doc3.doc, "heading").some((h) => h.attrs?.level === 1), "migrated heading loads");
ok(findMarks(doc3.doc, "bold").length === 1, "migrated bold loads");
ok(findMarks(doc3.doc, "link").some((m) => m.attrs?.href === "https://e.com"), "migrated link loads");
ok(allText(doc3.doc).includes("Title"), "migrated text loads");
// --- document containing a hard break loads (snake_case hard_break node) ---
// Regression: StarterKit registers HardBreak as `hardBreak`; a doc with a
// `hard_break` node (as produced by markdown-to-native and the export pipeline)
// must load and render its text rather than throwing and leaving the editor empty.
const withBreak = markdownToNative("Line one \nline two\n\nPara two");
const editor4 = makeEditor();
setEditorDoc(editor4, withBreak.doc);
const doc4 = getEditorDoc(editor4);
ok(editor4.getText().includes("Line one") && editor4.getText().includes("line two"),
"hard_break document renders its text (not empty)");
ok(findNodes(doc4.doc, "hard_break").length === 1, "hard_break node preserved on load");
// --- legacy camelCase hardBreak (from older editor saves) normalizes on read ---
const camel = {
schemaVersion: 1,
doc: {
type: "doc",
content: [
{ type: "paragraph", content: [
{ type: "text", text: "alpha" },
{ type: "hardBreak" },
{ type: "text", text: "beta" },
] },
],
},
};
const normalized = parseChapter(JSON.stringify(camel));
ok(findNodes(normalized.doc, "hard_break").length === 1, "parseChapter renames hardBreak -> hard_break");
const editor5 = makeEditor();
setEditorDoc(editor5, normalized.doc);
ok(editor5.getText().includes("alpha") && editor5.getText().includes("beta"),
"legacy camelCase hardBreak chapter loads (not empty)");
fs.rmSync(out, { force: true });
fs.rmSync(book, { recursive: true, force: true });
console.log(
"\nEditor tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
);
process.exit(failures === 0 ? 0 : 1);