editor: serialize only the pages that changed

This commit is contained in:
Ammar Ahmed
2026-08-25 22:22:54 +05:00
parent 63b42550a7
commit 1af46dff79
4 changed files with 135 additions and 1 deletions

View File

@@ -27,10 +27,12 @@ import {
countPages,
flattenBlocks,
fromFlatPosition,
serializeDocumentHTML,
toFlatPosition
} from "../index.js";
import { BlockId } from "../../block-id/block-id.js";
import { getTableOfContents } from "../../../utils/toc.js";
import { profiler } from "../../../utils/profiler.js";
const PagedDocument = Node.create({
name: "doc",
@@ -266,3 +268,58 @@ describe("paging", () => {
editor.destroy();
});
});
describe("serialization cache", () => {
test("matches the plain serializer exactly", async () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
await created();
expect(serializeDocumentHTML(editor.state.doc, editor.schema)).toBe(
editor.getHTML()
);
editor.destroy();
});
test("reuses unedited pages and re-serializes only the edited one", async () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
await created();
serializeDocumentHTML(editor.state.doc, editor.schema);
profiler.enable();
editor.commands.setTextSelection(3);
editor.commands.insertContent("edited ");
serializeDocumentHTML(editor.state.doc, editor.schema);
const counters = profiler.report().counters;
expect(counters["serialize.cacheMiss"]).toBe(1);
expect(counters["serialize.cacheHit"]).toBe(2);
profiler.disable();
profiler.reset();
editor.destroy();
});
test("still reflects the edit", async () => {
const editor = createEditor(savedNoteHTML(BLOCKS));
await created();
serializeDocumentHTML(editor.state.doc, editor.schema);
editor.commands.setTextSelection(3);
editor.commands.insertContent("edited ");
const html = serializeDocumentHTML(editor.state.doc, editor.schema);
expect(html).toContain("edited");
expect(html).toBe(editor.getHTML());
expect(html.match(/<p/g)).toHaveLength(BLOCKS);
editor.destroy();
});
test("works without pages too", async () => {
const editor = createEditor(savedNoteHTML(20), false);
await created();
expect(serializeDocumentHTML(editor.state.doc, editor.schema)).toBe(
editor.getHTML()
);
editor.destroy();
});
});

View File

@@ -87,3 +87,4 @@ export {
export { installFlatteningSerializer } from "./serializer.js";
export { installPagingParser, uninstallPagingParser } from "./parser.js";
export { fromFlatPosition, toFlatPosition } from "./positions.js";
export { serializeDocumentHTML } from "./serialize.js";

View File

@@ -0,0 +1,72 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
DOMSerializer,
Fragment,
Node as ProsemirrorNode,
Schema
} from "@tiptap/pm/model";
import { profiler } from "../../utils/profiler.js";
import { isPage } from "./split.js";
const cache = new WeakMap<ProsemirrorNode, string>();
function fragmentToHTML(fragment: Fragment, serializer: DOMSerializer): string {
const container = document.createElement("div");
container.appendChild(serializer.serializeFragment(fragment));
return container.innerHTML;
}
/**
* Serializes the document a top-level node at a time and remembers the result
* for each one. ProseMirror nodes are immutable and shared between document
* versions, so an unedited node is the same object as before and its HTML can
* be reused: a keystroke only re-serializes the node it landed in.
*
* Pages are serialized as their contents, keeping stored HTML page-free.
*/
export function serializeDocumentHTML(
doc: ProsemirrorNode,
schema: Schema
): string {
const end = profiler.start("serialize.document");
const serializer = DOMSerializer.fromSchema(schema);
const parts: string[] = [];
doc.forEach((node) => {
const cached = cache.get(node);
if (cached !== undefined) {
profiler.count("serialize.cacheHit");
parts.push(cached);
return;
}
profiler.count("serialize.cacheMiss");
const html = isPage(node)
? fragmentToHTML(node.content, serializer)
: fragmentToHTML(Fragment.from(node), serializer);
cache.set(node, html);
parts.push(html);
});
const html = parts.join("");
end();
return html;
}

View File

@@ -494,7 +494,11 @@ export * from "./utils/word-counter.js";
export * from "./utils/font.js";
export * from "./utils/toc.js";
export * from "./utils/profiler.js";
export { fromFlatPosition, toFlatPosition } from "./extensions/paging/index.js";
export {
fromFlatPosition,
serializeDocumentHTML,
toFlatPosition
} from "./extensions/paging/index.js";
export * from "./utils/downloader.js";
export {
useTiptap,