Fix typing stutter in large notes by debouncing per-keystroke document scans

This commit is contained in:
Yuri Karamian
2026-06-25 12:09:06 +02:00
parent 313d86df23
commit b10a7b1bfc
+43 -68
View File
@@ -180,6 +180,8 @@
outlineHeadings = headings; outlineHeadings = headings;
} }
const scheduleOutline = debounce(updateOutline, 250);
function handleOutlineResize(delta: number) { function handleOutlineResize(delta: number) {
$outlineWidth = Math.max(160, Math.min(500, $outlineWidth - delta)); $outlineWidth = Math.max(160, Math.min(500, $outlineWidth - delta));
} }
@@ -1038,6 +1040,38 @@
}, },
}); });
// Remap decorations while typing; full rebuild 300ms after it settles, so large notes don't rescan the whole doc per keystroke.
function lazyDecorationPlugin(key: PluginKey, build: (doc: any) => DecorationSet) {
return new Plugin({
key,
state: {
init: (_config: any, state: any) => build(state.doc),
apply: (tr: any, old: DecorationSet, _oldState: any, newState: any) => {
if (tr.getMeta(key) === 'rebuildDecos') return build(newState.doc);
if (!tr.docChanged) return old;
return old.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state: any) { return key.getState(state); },
},
view() {
let timer: ReturnType<typeof setTimeout> | null = null;
return {
update(view: any, prev: any) {
if (view.state.doc === prev.doc) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!view.isDestroyed) view.dispatch(view.state.tr.setMeta(key, 'rebuildDecos'));
}, 300);
},
destroy() { if (timer) clearTimeout(timer); },
};
},
});
}
const MermaidRenderer = Extension.create({ const MermaidRenderer = Extension.create({
name: 'mermaidRendererOptIn', name: 'mermaidRendererOptIn',
addProseMirrorPlugins() { addProseMirrorPlugins() {
@@ -1297,21 +1331,7 @@
} }
const pluginKey = new PluginKey('mermaidRendererOptIn'); const pluginKey = new PluginKey('mermaidRendererOptIn');
return [ return [lazyDecorationPlugin(pluginKey, buildDecorations)];
new Plugin({
key: pluginKey,
state: {
init: (_, state) => buildDecorations(state.doc),
apply: (tr, old, _oldState, newState) => {
if (!tr.docChanged) return old;
return buildDecorations(newState.doc);
},
},
props: {
decorations(state) { return pluginKey.getState(state); },
},
}),
];
}, },
}); });
@@ -1347,18 +1367,7 @@
return DecorationSet.create(doc, decos); return DecorationSet.create(doc, decos);
} }
const pluginKey = new PluginKey('codeBlockCopyButton'); const pluginKey = new PluginKey('codeBlockCopyButton');
return [ return [lazyDecorationPlugin(pluginKey, buildDecorations)];
new Plugin({
key: pluginKey,
state: {
init: (_, state) => buildDecorations(state.doc),
apply: (tr, old) => { if (!tr.docChanged) return old; return buildDecorations(tr.doc); },
},
props: {
decorations(state) { return pluginKey.getState(state); },
},
}),
];
}, },
}); });
@@ -1456,21 +1465,7 @@
const ColorSwatch = Extension.create({ const ColorSwatch = Extension.create({
name: 'colorSwatch', name: 'colorSwatch',
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [lazyDecorationPlugin(colorSwatchPluginKey, buildColorSwatchDecorations)];
new Plugin({
key: colorSwatchPluginKey,
state: {
init: (_, state) => buildColorSwatchDecorations(state.doc),
apply: (tr, old, _oldState, newState) => {
if (!tr.docChanged) return old;
return buildColorSwatchDecorations(newState.doc);
},
},
props: {
decorations(state) { return colorSwatchPluginKey.getState(state); },
},
}),
];
}, },
}); });
@@ -1510,21 +1505,7 @@
const TaskMetaDim = Extension.create({ const TaskMetaDim = Extension.create({
name: 'taskMetaDim', name: 'taskMetaDim',
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [lazyDecorationPlugin(taskMetaPluginKey, buildTaskMetaDecorations)];
new Plugin({
key: taskMetaPluginKey,
state: {
init: (_, state) => buildTaskMetaDecorations(state.doc),
apply: (tr, old, _oldState, newState) => {
if (!tr.docChanged) return old;
return buildTaskMetaDecorations(newState.doc);
},
},
props: {
decorations(state) { return taskMetaPluginKey.getState(state); },
},
}),
];
}, },
}); });
@@ -2638,6 +2619,8 @@
charCount = text.replace(/\s/g, '').length; charCount = text.replace(/\s/g, '').length;
} }
const scheduleCounts = debounce(updateCounts, 250);
async function toggleInfo() { async function toggleInfo() {
if (!showInfo && $activeNote) { if (!showInfo && $activeNote) {
historyLoading = true; historyLoading = true;
@@ -4148,12 +4131,8 @@
} }
$editorDirty = true; $editorDirty = true;
autoSave(); autoSave();
if (!isMobile && showOutline) updateOutline(); if (!isMobile && showOutline) scheduleOutline();
if (editor) { if (showInfo) scheduleCounts();
const text = editor.state.doc.textContent;
wordCount = countWords(text);
charCount = text.replace(/\s/g, '').length;
}
}, },
}); });
editorReady = true; editorReady = true;
@@ -8984,11 +8963,7 @@
list-style-type: square; list-style-type: square;
} }
/* Ordered lists render their number via a counter in normal content flow /* Counter-based numbering: WebKitGTK clips the native ol marker (overflow:hidden), so 11 rendered as 1. list-item respects <ol start> + nesting. */
instead of the native outside marker. WebKitGTK (Linux) clips the native
marker against the editor's overflow:hidden box, so two-digit numbers
showed only the last digit (11 -> 1). The built-in `list-item` counter
still respects the <ol start> attribute and per-level nesting. */
:global(.tiptap-wrapper .tiptap ol) { :global(.tiptap-wrapper .tiptap ol) {
list-style: none; list-style: none;
padding-left: 0; padding-left: 0;