diff --git a/packages/editor/src/extensions/paging/__tests__/paging.test.ts b/packages/editor/src/extensions/paging/__tests__/paging.test.ts index de5e6da13..4f420fa68 100644 --- a/packages/editor/src/extensions/paging/__tests__/paging.test.ts +++ b/packages/editor/src/extensions/paging/__tests__/paging.test.ts @@ -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(/

{ + const editor = createEditor(savedNoteHTML(20), false); + await created(); + + expect(serializeDocumentHTML(editor.state.doc, editor.schema)).toBe( + editor.getHTML() + ); + editor.destroy(); + }); +}); diff --git a/packages/editor/src/extensions/paging/index.ts b/packages/editor/src/extensions/paging/index.ts index c55846ca5..a0841cdc8 100644 --- a/packages/editor/src/extensions/paging/index.ts +++ b/packages/editor/src/extensions/paging/index.ts @@ -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"; diff --git a/packages/editor/src/extensions/paging/serialize.ts b/packages/editor/src/extensions/paging/serialize.ts new file mode 100644 index 000000000..4b09191f8 --- /dev/null +++ b/packages/editor/src/extensions/paging/serialize.ts @@ -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 . +*/ + +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(); + +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; +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index caf5a2438..256475449 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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,