perf: the word count and the name placeholder no longer walk the document per transaction

A caret move recounted every word; a blank title serialized the whole
document to JSON on every transaction. The count now runs once per changed
document, deferred off the keystroke, and the placeholder reads the first
heading straight off the document.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:12 +02:00
1 parent 066737463d
commit 87743fdd57
2 files changed
+25 -11

No files matched your search

+21 -11
View File
@@ -23,6 +23,7 @@
import { getColorDebug } from './lib/utils/colorDebug';
import { resetHistoryLog } from './lib/utils/historyLog.svelte';
import { countText, type TextStats } from './lib/utils/wordCount';
import type { Node as PmNode } from 'prosemirror-model';
import { clampZoom, wheelZoomFactor, MIN_ZOOM, MAX_ZOOM } from './lib/utils/zoom';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadChromeMode, saveChromeMode, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, loadSplitView, saveSplitView, loadPageColumns, savePageColumns, type ThemeMode, type ChromeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
@@ -37,7 +38,7 @@
import { DEFAULT_NOTE_SETTINGS } from './lib/storage/noteSettings';
import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } from './lib/storage/headerFooter';
import { loadDocName, saveDocName, loadDocFormat, saveDocFormat, stripOdtExtension, sanitizeNameForFile, deriveFilename, type DocumentFormat } from './lib/storage/documentName';
import { loadDocName, saveDocName, loadDocFormat, saveDocFormat, stripOdtExtension, sanitizeNameForFile, deriveFilename, filenameFor, type DocumentFormat } from './lib/storage/documentName';
import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties';
import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation';
import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering';
@@ -136,14 +137,20 @@
editor?.commands.clearSearch();
}
// Word/character counts for the status-bar counter. Reading `tick` makes these
// recompute on every body transaction (incl. selection changes), so they stay
// live without subscribing to ProseMirror directly.
// Word/character counts for the status-bar counter. A selection-only transaction
// keeps the doc object, so nothing recounts; a changed document is counted a beat
// later, off the keystroke — a long document's walk is not free.
let wordCountOpen = $state(false);
let docStats = $derived.by<TextStats>(() => {
if (tick < 0 || !editor) return { words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 };
let docStats = $state<TextStats>({ words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 });
let countedDoc: PmNode | null = null;
let countTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
if (tick < 0 || !editor) return;
const { doc } = editor.state;
return countText(doc, 0, doc.content.size);
if (doc === countedDoc) return;
countedDoc = doc;
clearTimeout(countTimer);
countTimer = setTimeout(() => { docStats = countText(doc, 0, doc.content.size); }, 300);
});
let selStats = $derived.by<TextStats | null>(() => {
if (tick < 0 || !editor) return null;
@@ -200,12 +207,15 @@
// with its text instead of sitting in a fixed-width box.
let docNameSizerWidth = $state(0);
// Shown in the empty title field: what an actual save would name the file.
// Only computed while the field is blank (otherwise the placeholder is hidden,
// so we skip the per-transaction getJSON).
// Shown in the empty title field: what an actual save would name the file — read
// off the document's first heading, not a JSON of the whole document per transaction.
let namePlaceholder = $derived.by(() => {
if (documentName.trim() || tick < 0 || !editor) return t().app.untitled;
const base = stripOdtExtension(deriveFilename(editor.getJSON() as TiptapNode));
let title: string | undefined;
editor.state.doc.forEach((n) => {
if (title === undefined && n.type.name === 'heading' && n.childCount) title = n.firstChild?.text ?? '';
});
const base = stripOdtExtension(filenameFor(title));
return base === 'document' ? t().app.untitled : base;
});
+4
View File
@@ -57,6 +57,10 @@ try {
await page.keyboard.type('bold');
const strong = await page.evaluate(() => document.querySelector('.tiptap strong')?.textContent);
check(strong === 'bold', 'typing + bold shortcut render');
// The status-bar count follows a changed document a beat later, off the keystroke.
await page.waitForFunction(() => /\b3 Words\b/.test(document.querySelector('.statusbar')?.textContent ?? ''),
null, { timeout: 5_000 });
check(true, 'word count follows the typing');
// Autosave (1s debounce) persists the document across a reload.
await page.waitForFunction(() => (localStorage.getItem('edentext-doc') ?? '').includes('Hello smoke'),