diff --git a/apps/web/src/components/editor/profiling.ts b/apps/web/src/components/editor/profiling.ts
new file mode 100644
index 000000000..c4fc0d88d
--- /dev/null
+++ b/apps/web/src/components/editor/profiling.ts
@@ -0,0 +1,206 @@
+/*
+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 { Editor, getHTMLFromFragment, profiler } from "@notesnook/editor";
+
+let activeEditor: Editor | undefined;
+
+export function setProfiledEditor(editor: Editor | undefined) {
+ activeEditor = editor;
+}
+
+function requireEditor(): Editor | undefined {
+ if (!activeEditor || activeEditor.isDestroyed) {
+ console.warn("[profiler] no active editor. open a note first.");
+ return undefined;
+ }
+ return activeEditor;
+}
+
+function nextFrame(): Promise {
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
+}
+
+function afterPaint(): Promise {
+ return new Promise((resolve) =>
+ requestAnimationFrame(() => setTimeout(() => resolve(), 0))
+ );
+}
+
+function findScrollParent(node: HTMLElement): HTMLElement | undefined {
+ let current: HTMLElement | null = node.parentElement;
+ while (current) {
+ const overflowY = getComputedStyle(current).overflowY;
+ if (
+ (overflowY === "auto" || overflowY === "scroll") &&
+ current.scrollHeight > current.clientHeight
+ )
+ return current;
+ current = current.parentElement;
+ }
+ return undefined;
+}
+
+function documentStats(editor: Editor) {
+ const dom = editor.view.dom;
+ return {
+ topLevelBlocks: editor.state.doc.childCount,
+ docNodeSize: editor.state.doc.nodeSize,
+ characters: editor.state.doc.textContent.length,
+ domTopLevelElements: dom.children.length,
+ domTotalElements: dom.getElementsByTagName("*").length,
+ placeholders: dom.querySelectorAll("[data-virtual-placeholder]").length,
+ virtualization: !!(
+ editor.storage.virtualization as { enabled?: boolean } | undefined
+ )?.enabled
+ };
+}
+
+async function typeTest(count = 200, delayMs = 0) {
+ const editor = requireEditor();
+ if (!editor) return;
+
+ const dom = editor.view.dom as HTMLElement;
+ editor.commands.focus();
+ profiler.event("bench.type.start", { count });
+
+ const start = performance.now();
+ for (let i = 0; i < count; i++) {
+ dom.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "a",
+ bubbles: true,
+ cancelable: true
+ })
+ );
+ editor.view.dispatch(editor.state.tr.insertText("a"));
+ await afterPaint();
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
+ }
+ const elapsed = performance.now() - start;
+
+ profiler.event("bench.type.end", { count, elapsed });
+ console.log(
+ `[profiler] typed ${count} characters in ${Math.round(
+ elapsed
+ )}ms (${Math.round(elapsed / count)}ms/char incl. paint)`
+ );
+ return profiler.print(`type:${count}`);
+}
+
+async function scrollTest(steps = 40) {
+ const editor = requireEditor();
+ if (!editor) return;
+
+ const container = findScrollParent(editor.view.dom);
+ if (!container) {
+ console.warn("[profiler] no scroll container found.");
+ return;
+ }
+
+ const max = container.scrollHeight - container.clientHeight;
+ const step = max / steps;
+ profiler.event("bench.scroll.start", { steps, max });
+
+ const frames: number[] = [];
+ const start = performance.now();
+ for (let i = 0; i <= steps; i++) {
+ const stepStart = performance.now();
+ container.scrollTop = step * i;
+ await afterPaint();
+ const duration = performance.now() - stepStart;
+ frames.push(duration);
+ profiler.record("bench.scrollStep", duration);
+ }
+ const elapsed = performance.now() - start;
+
+ const slowest = Math.max(...frames);
+ profiler.event("bench.scroll.end", { elapsed, slowest });
+ console.log(
+ `[profiler] scrolled ${steps} steps in ${Math.round(
+ elapsed
+ )}ms (slowest step ${Math.round(slowest)}ms)`
+ );
+ container.scrollTop = 0;
+ await nextFrame();
+ return profiler.print(`scroll:${steps}`);
+}
+
+function serializeTest(runs = 5) {
+ const editor = requireEditor();
+ if (!editor) return;
+
+ for (let i = 0; i < runs; i++)
+ profiler.time("bench.getHTML", () =>
+ getHTMLFromFragment(editor.state.doc.content, editor.schema)
+ );
+ return profiler.print(`serialize:${runs}`);
+}
+
+export function installProfilerGlobals() {
+ const api = {
+ profiler,
+ enable(options?: { timeline?: boolean }) {
+ profiler.enable(options);
+ console.log(
+ "[profiler] enabled and persisted. reload to profile editor startup."
+ );
+ return api;
+ },
+ disable() {
+ profiler.disable();
+ console.log("[profiler] disabled.");
+ return api;
+ },
+ reset() {
+ profiler.reset();
+ return api;
+ },
+ report: (label?: string) => profiler.report(label),
+ print: (label?: string) => profiler.print(label),
+ snapshot: (label: string) => profiler.snapshot(label),
+ compare: (baseline: string, candidate: string) =>
+ profiler.compare(baseline, candidate),
+ snapshots: () => profiler.listSnapshots(),
+ stats() {
+ const editor = requireEditor();
+ if (!editor) return;
+ const stats = documentStats(editor);
+ console.table(stats);
+ return stats;
+ },
+ copy(label?: string) {
+ const json = JSON.stringify(profiler.report(label), null, 2);
+ navigator.clipboard?.writeText(json);
+ console.log("[profiler] report copied to clipboard.");
+ return json;
+ },
+ typeTest,
+ scrollTest,
+ serializeTest
+ };
+
+ (globalThis as unknown as Record).editorProfiler = api;
+ console.log(
+ profiler.enabled
+ ? "[profiler] on by default. window.editorProfiler.print() for a report, .disable() to turn off."
+ : "[profiler] off. window.editorProfiler.enable() to turn it back on."
+ );
+ return api;
+}
diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx
index 670e1ea9c..35b67788e 100644
--- a/apps/web/src/components/editor/tiptap.tsx
+++ b/apps/web/src/components/editor/tiptap.tsx
@@ -38,8 +38,10 @@ import {
getTableOfContents,
getChangedNodes,
LinkAttributes,
+ profiler,
type Selection
} from "@notesnook/editor";
+import { installProfilerGlobals, setProfiledEditor } from "./profiling";
import { Box, Flex } from "@theme-ui/components";
import {
PropsWithChildren,
@@ -138,6 +140,7 @@ function countSpaces(text: string) {
}
function updateNoteStatistics(id: string, content: () => Fragment) {
+ const end = profiler.start("statistics.update");
const fragment = content();
const documentText = fragment.textBetween(0, fragment.size, "\n", " ");
useEditorManager.getState().updateEditor(id, {
@@ -160,10 +163,14 @@ function updateNoteStatistics(id: string, content: () => Fragment) {
}
}
});
+ end();
+ profiler.count("statistics.updates");
}
const deferredUpdateNoteStatistics = debounce(updateNoteStatistics, 1000);
+installProfilerGlobals();
+
function TipTap(props: TipTapProps) {
const {
id,
@@ -247,7 +254,9 @@ function TipTap(props: TipTapProps) {
event.preventDefault();
onChange?.(
() =>
- getHTMLFromFragment(editor.state.doc.content, editor.schema),
+ profiler.time("serialize.getHTML", () =>
+ getHTMLFromFragment(editor.state.doc.content, editor.schema)
+ ),
false
);
}
@@ -282,6 +291,13 @@ function TipTap(props: TipTapProps) {
autofocus: "start",
onFocus,
onCreate: async ({ editor }) => {
+ setProfiledEditor(editor as Editor);
+ profiler.setContext("virtualization", !!virtualization);
+ profiler.setContext("noteId", id);
+ profiler.setContext("topLevelBlocks", editor.state.doc.childCount);
+ profiler.setContext("characters", editor.state.doc.textContent.length);
+ profiler.event("editor.created");
+
if (oldNonce.current !== nonce)
editor.commands.focus("start", { scrollIntoView: true });
oldNonce.current = nonce;
@@ -289,7 +305,9 @@ function TipTap(props: TipTapProps) {
const instance = toIEditor(editor as Editor);
if (onLoad) onLoad(instance);
- const totalWords = getTotalWords(editor as Editor);
+ const totalWords = profiler.time("statistics.totalWords", () =>
+ getTotalWords(editor as Editor)
+ );
useEditorManager.getState().setEditor(id, {
editor: instance,
canRedo: editor.can().redo(),
@@ -341,11 +359,15 @@ function TipTap(props: TipTapProps) {
if (!autoSave.current) return;
onChange(
- () => getHTMLFromFragment(editor.state.doc.content, editor.schema),
+ () =>
+ profiler.time("serialize.getHTML", () =>
+ getHTMLFromFragment(editor.state.doc.content, editor.schema)
+ ),
ignoreEdit
);
},
onDestroy: () => {
+ setProfiledEditor(undefined);
useEditorManager.getState().setEditor(id);
},
onTransaction: ({ editor, transaction }) => {
@@ -819,7 +841,9 @@ function toIEditor(editor: Editor): IEditor {
),
startSearch: () => editor.commands.startSearch(),
getContent: () =>
- getHTMLFromFragment(editor.state.doc.content, editor.schema),
+ profiler.time("serialize.getHTML", () =>
+ getHTMLFromFragment(editor.state.doc.content, editor.schema)
+ ),
getSelection: () => {
const { from, to } = editor.state.selection;
return { from, to };