From 87743fdd57aba063dbb6aabf04693fa7506745ec Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 23:46:58 +0200 Subject: [PATCH] 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. --- src/App.svelte | 32 +++++++++++++++++++++----------- tests/smoke/run.mjs | 4 ++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index e8092b0..d3422f5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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(() => { - if (tick < 0 || !editor) return { words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 }; + let docStats = $state({ words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 }); + let countedDoc: PmNode | null = null; + let countTimer: ReturnType | 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(() => { 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; }); diff --git a/tests/smoke/run.mjs b/tests/smoke/run.mjs index 3e0a571..aad3168 100644 --- a/tests/smoke/run.mjs +++ b/tests/smoke/run.mjs @@ -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'),