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:
parent
0df26d4ce6
commit
786a65ae2a
48 changed files with 5762 additions and 3999 deletions
223
tests/run-editor-test.mjs
Normal file
223
tests/run-editor-test.mjs
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue