From ec512c2c48acefcd78c84f21703c7d616dbe3d6a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 18:39:00 -0700 Subject: [PATCH 1/3] feat(doc-info): live doc.info counts for characters, tracked changes, SDT fields, and lists --- apps/docs/core/superdoc/events.mdx | 13 +- apps/docs/core/supereditor/events.mdx | 44 +++ apps/docs/document-api/common-workflows.mdx | 67 ++++ .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 2 +- apps/docs/document-api/reference/info.mdx | 32 +- apps/docs/document-engine/sdks.mdx | 24 +- .../scripts/check-contract-parity.ts | 14 +- .../src/contract/operation-definitions.ts | 6 +- packages/document-api/src/contract/schemas.ts | 19 +- packages/document-api/src/index.test.ts | 5 + packages/document-api/src/index.ts | 2 +- packages/document-api/src/info/info.test.ts | 14 +- .../document-api/src/invoke/invoke.test.ts | 14 +- .../src/overview-examples.test.ts | 14 +- packages/document-api/src/types/info.types.ts | 16 + .../helpers/live-document-counts.test.ts | 364 ++++++++++++++++++ .../helpers/live-document-counts.ts | 184 +++++++++ .../info-adapter.integration.test.ts | 61 +++ .../info-adapter.test.ts | 145 +++---- .../src/document-api-adapters/info-adapter.ts | 65 +--- 21 files changed, 955 insertions(+), 152 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts create mode 100644 packages/super-editor/src/document-api-adapters/info-adapter.integration.test.ts diff --git a/apps/docs/core/superdoc/events.mdx b/apps/docs/core/superdoc/events.mdx index 76d2c66347..54a1f4e074 100644 --- a/apps/docs/core/superdoc/events.mdx +++ b/apps/docs/core/superdoc/events.mdx @@ -143,7 +143,7 @@ superdoc.on('editorDestroy', () => { ### `editor-update` -When editor content changes. +When editor content changes. Use this to refresh live UI state like word counts or auto-save. ```javascript Usage @@ -167,6 +167,17 @@ superdoc.on('editor-update', ({ editor }) => { ``` +**Live counter example:** Read `editor.doc.info()` inside the handler to build a live document-stats panel without polling. + +```javascript +superdoc.on('editor-update', ({ editor }) => { + const { counts } = editor.doc.info(); + document.getElementById('stats').textContent = + `${counts.words} words, ${counts.characters} characters, ` + + `${counts.trackedChanges} tracked changes, ${counts.lists} lists`; +}); +``` + ### `content-error` When content processing fails. diff --git a/apps/docs/core/supereditor/events.mdx b/apps/docs/core/supereditor/events.mdx index 22da078e2a..4ba0ac7b68 100644 --- a/apps/docs/core/supereditor/events.mdx +++ b/apps/docs/core/supereditor/events.mdx @@ -222,6 +222,50 @@ const editor = await Editor.open(yourFile, { ``` +## Subscribing after initialization + +Use `editor.on(...)` and `editor.off(...)` to subscribe to events at any time after the editor is created. This is useful for adding listeners from external code that does not control the initial configuration. + + +```javascript Usage +editor.on('update', ({ editor }) => { + const { counts } = editor.doc.info(); + updateDocumentStatsUI({ + words: counts.words, + characters: counts.characters, + trackedChanges: counts.trackedChanges, + sdtFields: counts.sdtFields, + lists: counts.lists, + }); +}); +``` + +```javascript Full Example +import { Editor } from 'superdoc/super-editor'; + +const editor = await Editor.open(yourFile, { + element: document.querySelector('#editor'), +}); + +// Subscribe to updates after creation +const handler = ({ editor }) => { + const { counts } = editor.doc.info(); + document.getElementById('stats').textContent = + `${counts.words} words, ${counts.characters} characters, ` + + `${counts.trackedChanges} tracked changes`; +}; + +editor.on('update', handler); + +// Later, unsubscribe +editor.off('update', handler); +``` + + + +Constructor callbacks like `onUpdate` and runtime subscriptions like `editor.on('update', ...)` both fire on the same events. Use constructor callbacks when the listener is known at creation time, and `editor.on(...)` when adding listeners dynamically. + + ## Features ### `onCommentsUpdate` diff --git a/apps/docs/document-api/common-workflows.mdx b/apps/docs/document-api/common-workflows.mdx index 45bd4a9e09..aae4c77972 100644 --- a/apps/docs/document-api/common-workflows.mdx +++ b/apps/docs/document-api/common-workflows.mdx @@ -247,6 +247,73 @@ editor2.destroy(); No ID is guaranteed to survive all Microsoft Word round-trips. Re-extract addresses after major external edits or transformations, since Word (or other tools) may rewrite paragraph IDs and SuperDoc may rewrite duplicate IDs on import. +## Read document counts + +`doc.info()` returns a snapshot of current document statistics including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts. + +```ts +const info = editor.doc.info(); + +console.log(info.counts.words); // whitespace-delimited word count +console.log(info.counts.characters); // full text projection length (with spaces) +console.log(info.counts.paragraphs); // excludes headings and list items +console.log(info.counts.headings); // style-based heading detection +console.log(info.counts.tables); // top-level table containers +console.log(info.counts.images); // block + inline images +console.log(info.counts.comments); // unique anchored comment IDs +console.log(info.counts.trackedChanges); // grouped tracked-change entities +console.log(info.counts.sdtFields); // field-like SDT/content-control nodes +console.log(info.counts.lists); // unique list sequences +``` + +All counts reflect the current editor state, not OOXML metadata. They update naturally as the document changes. + +### Build a live counter in the browser + +`doc.info()` is a snapshot read. To build a live counter, subscribe to document-change events and refresh counts in the handler — do not poll in a render loop. + +**SuperEditor (raw editor):** + +```ts +editor.on('update', ({ editor }) => { + const { counts } = editor.doc.info(); + updateDocumentStatsUI({ + words: counts.words, + characters: counts.characters, + trackedChanges: counts.trackedChanges, + sdtFields: counts.sdtFields, + lists: counts.lists, + }); +}); +``` + +**SuperDoc (wrapper):** + +```ts +superdoc.on('editor-update', ({ editor }) => { + const { counts } = editor.doc.info(); + updateDocumentStatsUI({ + words: counts.words, + characters: counts.characters, + trackedChanges: counts.trackedChanges, + sdtFields: counts.sdtFields, + lists: counts.lists, + }); +}); +``` + +### SDK usage + +The SDKs do not expose browser event subscriptions. Call `doc.info()` at workflow boundaries — after opening a document, after a batch of mutations, or before saving. + +```ts +const doc = await client.open('./contract.docx'); +const info = doc.info(); +console.log( + `${info.counts.words} words, ${info.counts.characters} characters, ${info.counts.trackedChanges} tracked changes`, +); +``` + ## Dry-run preview Pass `dryRun: true` to validate an operation without applying it: diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 0da3964084..8d7cdf6f75 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -962,5 +962,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "bf7e9b493d8ab9e84c2d5875b5aa7fe0e74e8504ec3e258e463af5e529b16e92" + "sourceHash": "9197780d09944c67a656339ee8adc0e2c4473d1dffb09288c9c0b85f68fd34f3" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index df2c5c3a16..9fe319813b 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -68,7 +68,7 @@ The tables below are grouped by namespace. | getMarkdown | editor.doc.getMarkdown(...) | Extract the document content as a Markdown string. | | getHtml | editor.doc.getHtml(...) | Extract the document content as an HTML string. | | markdownToFragment | editor.doc.markdownToFragment(...) | Convert a Markdown string into an SDM/1 structural fragment. | -| info | editor.doc.info(...) | Return document metadata including revision, node count, and capabilities. | +| info | editor.doc.info(...) | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. | | clearContent | editor.doc.clearContent(...) | Clear all document body content, leaving a single empty paragraph. | | insert | editor.doc.insert(...) | Insert content into the document. Two input shapes: legacy string-based (value + type) inserts inline content at a text position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target is omitted, content appends at the end of the document. Legacy mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | replace | editor.doc.replace(...) | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | diff --git a/apps/docs/document-api/reference/info.mdx b/apps/docs/document-api/reference/info.mdx index db417aebd6..8b4c4d0459 100644 --- a/apps/docs/document-api/reference/info.mdx +++ b/apps/docs/document-api/reference/info.mdx @@ -1,7 +1,7 @@ --- title: info sidebarTitle: info -description: Return document metadata including revision, node count, and capabilities. +description: Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Return document metadata including revision, node count, and capabi ## Summary -Return document metadata including revision, node count, and capabilities. +Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. - Operation ID: `info` - API member path: `editor.doc.info(...)` @@ -22,7 +22,7 @@ Return document metadata including revision, node count, and capabilities. ## Expected result -Returns a DocumentInfo object with revision, word/paragraph/heading counts, and capability flags. +Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists), document outline, capability flags, and revision. ## Input fields @@ -44,11 +44,15 @@ _No fields._ | `capabilities.canGetNode` | boolean | yes | | | `capabilities.canReplace` | boolean | yes | | | `counts` | object | yes | | +| `counts.characters` | integer | yes | | | `counts.comments` | integer | yes | | | `counts.headings` | integer | yes | | | `counts.images` | integer | yes | | +| `counts.lists` | integer | yes | | | `counts.paragraphs` | integer | yes | | +| `counts.sdtFields` | integer | yes | | | `counts.tables` | integer | yes | | +| `counts.trackedChanges` | integer | yes | | | `counts.words` | integer | yes | | | `outline` | object[] | yes | | | `revision` | string | yes | | @@ -64,11 +68,15 @@ _No fields._ "canReplace": true }, "counts": { + "characters": 1, "comments": 0, "headings": 3, "images": 2, + "lists": 1, "paragraphs": 12, + "sdtFields": 1, "tables": 1, + "trackedChanges": 1, "words": 250 }, "outline": [ @@ -134,6 +142,9 @@ _No fields._ "counts": { "additionalProperties": false, "properties": { + "characters": { + "type": "integer" + }, "comments": { "type": "integer" }, @@ -143,23 +154,36 @@ _No fields._ "images": { "type": "integer" }, + "lists": { + "type": "integer" + }, "paragraphs": { "type": "integer" }, + "sdtFields": { + "type": "integer" + }, "tables": { "type": "integer" }, + "trackedChanges": { + "type": "integer" + }, "words": { "type": "integer" } }, "required": [ "words", + "characters", "paragraphs", "headings", "tables", "images", - "comments" + "comments", + "trackedChanges", + "sdtFields", + "lists" ], "type": "object" }, diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 17bb3c7d75..4c4f776fcb 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -380,7 +380,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | | `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | | `doc.markdownToFragment` | `markdown-to-fragment` | Convert a Markdown string into an SDM/1 structural fragment. | -| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.info` | `info` | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. | | `doc.clearContent` | `clear-content` | Clear all document body content, leaving a single empty paragraph. | | `doc.insert` | `insert` | Insert content into the document. Two input shapes: legacy string-based (value + type) inserts inline content at a text position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target is omitted, content appends at the end of the document. Legacy mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | `doc.replace` | `replace` | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | @@ -818,7 +818,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | | `doc.get_html` | `get-html` | Extract the document content as an HTML string. | | `doc.markdown_to_fragment` | `markdown-to-fragment` | Convert a Markdown string into an SDM/1 structural fragment. | -| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.info` | `info` | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. | | `doc.clear_content` | `clear-content` | Clear all document body content, leaving a single empty paragraph. | | `doc.insert` | `insert` | Insert content into the document. Two input shapes: legacy string-based (value + type) inserts inline content at a text position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target is omitted, content appends at the end of the document. Legacy mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. | | `doc.replace` | `replace` | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. | @@ -1245,6 +1245,26 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p {/* SDK_OPERATIONS_END */} +## SDK vs browser integration model + +The SDKs are request/response wrappers around the CLI. They do **not** expose browser event subscriptions like `editor.on('update', ...)` or `superdoc.on('editor-update', ...)`. + +- Call `doc.info()` at workflow boundaries — after opening, after edits, or before saving — not in a polling loop. +- If you are building a browser live counter, use the [SuperEditor events](/core/supereditor/events) or [SuperDoc events](/core/superdoc/events) instead. + +```ts +const doc = await client.open('./report.docx'); +const info = doc.info(); +console.log( + `${info.counts.words} words, ` + + `${info.counts.characters} characters, ` + + `${info.counts.trackedChanges} tracked changes, ` + + `${info.counts.sdtFields} SDT fields, ` + + `${info.counts.lists} lists`, +); +await doc.close(); +``` + ## Related - [Document API](/document-api/overview) — the in-browser API that defines the operation set diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts index 75296edb13..3d05de6fc1 100644 --- a/packages/document-api/scripts/check-contract-parity.ts +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -58,9 +58,21 @@ function createNoopAdapters(): DocumentApiAdapters { }, info: { info: () => ({ - counts: { words: 0, paragraphs: 0, headings: 0, tables: 0, images: 0, comments: 0 }, + counts: { + words: 0, + characters: 0, + paragraphs: 0, + headings: 0, + tables: 0, + images: 0, + comments: 0, + trackedChanges: 0, + sdtFields: 0, + lists: 0, + }, outline: [], capabilities: { canFind: true, canGetNode: true, canComment: true, canReplace: true }, + revision: '0', }), }, capabilities: { diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 08d2c52977..4fae888594 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -378,8 +378,10 @@ export const OPERATION_DEFINITIONS = { }, info: { memberPath: 'info', - description: 'Return document metadata including revision, node count, and capabilities.', - expectedResult: 'Returns a DocumentInfo object with revision, word/paragraph/heading counts, and capability flags.', + description: + 'Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities.', + expectedResult: + 'Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists), document outline, capability flags, and revision.', requiresDocumentContext: true, metadata: readOperation(), referenceDocPath: 'info.mdx', diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 0b5598d38f..600ac9cfb3 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -898,13 +898,28 @@ function sdMutationResultSchemaFor(operationId: OperationId): JsonSchema { const documentInfoCountsSchema = objectSchema( { words: { type: 'integer' }, + characters: { type: 'integer' }, paragraphs: { type: 'integer' }, headings: { type: 'integer' }, tables: { type: 'integer' }, images: { type: 'integer' }, comments: { type: 'integer' }, - }, - ['words', 'paragraphs', 'headings', 'tables', 'images', 'comments'], + trackedChanges: { type: 'integer' }, + sdtFields: { type: 'integer' }, + lists: { type: 'integer' }, + }, + [ + 'words', + 'characters', + 'paragraphs', + 'headings', + 'tables', + 'images', + 'comments', + 'trackedChanges', + 'sdtFields', + 'lists', + ], ); const documentInfoOutlineItemSchema = objectSchema( diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 98974d803e..9d3a224456 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -47,11 +47,15 @@ function makeInfoAdapter(result?: Partial) { const defaultResult: DocumentInfo = { counts: { words: 0, + characters: 0, paragraphs: 0, headings: 0, tables: 0, images: 0, comments: 0, + trackedChanges: 0, + sdtFields: 0, + lists: 0, }, outline: [], capabilities: { @@ -60,6 +64,7 @@ function makeInfoAdapter(result?: Partial) { canComment: true, canReplace: true, }, + revision: '0', }; return { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 4e2d156672..6fea2206b3 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -1435,7 +1435,7 @@ export interface DocumentApi { */ markdownToFragment(input: MarkdownToFragmentInput): SDMarkdownToFragmentResult; /** - * Return document summary info used by `doc.info`. + * Return document summary info including document counts and capabilities. */ info(input: InfoInput): DocumentInfo; /** diff --git a/packages/document-api/src/info/info.test.ts b/packages/document-api/src/info/info.test.ts index ed9588d1be..528862e9c1 100644 --- a/packages/document-api/src/info/info.test.ts +++ b/packages/document-api/src/info/info.test.ts @@ -3,9 +3,21 @@ import { executeInfo } from './info.js'; import type { InfoAdapter } from './info.js'; const DEFAULT_INFO: DocumentInfo = { - counts: { words: 42, paragraphs: 3, headings: 1, tables: 0, images: 0, comments: 0 }, + counts: { + words: 42, + characters: 200, + paragraphs: 3, + headings: 1, + tables: 0, + images: 0, + comments: 0, + trackedChanges: 2, + sdtFields: 1, + lists: 1, + }, outline: [{ level: 1, text: 'Heading', nodeId: 'h1' }], capabilities: { canFind: true, canGetNode: true, canComment: true, canReplace: true }, + revision: '0', }; describe('executeInfo', () => { diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 84269a8e87..32e13640bb 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -24,9 +24,21 @@ function makeAdapters() { const getTextAdapter = { getText: vi.fn(() => 'hello') }; const infoAdapter = { info: vi.fn(() => ({ - counts: { words: 1, paragraphs: 1, headings: 0, tables: 0, images: 0, comments: 0 }, + counts: { + words: 1, + characters: 5, + paragraphs: 1, + headings: 0, + tables: 0, + images: 0, + comments: 0, + trackedChanges: 0, + sdtFields: 0, + lists: 1, + }, outline: [], capabilities: { canFind: true, canGetNode: true, canComment: true, canReplace: true }, + revision: '0', })), }; const capabilitiesAdapter: CapabilitiesAdapter = { diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index 5f818273a9..8d2a93e823 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -91,9 +91,21 @@ function makeGetTextAdapter() { function makeInfoAdapter() { return { info: vi.fn(() => ({ - counts: { words: 0, paragraphs: 0, headings: 0, tables: 0, images: 0, comments: 0 }, + counts: { + words: 0, + characters: 0, + paragraphs: 0, + headings: 0, + tables: 0, + images: 0, + comments: 0, + trackedChanges: 0, + sdtFields: 0, + lists: 0, + }, outline: [], capabilities: { canFind: true, canGetNode: true, canComment: true, canReplace: true }, + revision: '0', })), }; } diff --git a/packages/document-api/src/types/info.types.ts b/packages/document-api/src/types/info.types.ts index 539848f6b2..562b505d13 100644 --- a/packages/document-api/src/types/info.types.ts +++ b/packages/document-api/src/types/info.types.ts @@ -1,10 +1,26 @@ export interface DocumentInfoCounts { words: number; + /** + * Length of the Document API plain-text projection. + * + * This is a "characters with spaces" metric derived from + * `doc.textBetween(0, size, '\n', '\n')`. It includes whitespace, + * inter-block newline separators, and one `'\n'` per non-text leaf node + * (images, tabs, breaks). It is neither Word's `ap:Characters` nor + * `ap:CharactersWithSpaces`. + */ + characters: number; paragraphs: number; headings: number; tables: number; images: number; comments: number; + /** Count of grouped tracked-change entities (insertions, deletions, format changes). */ + trackedChanges: number; + /** Count of field-like SDT/content-control nodes (text/date/checkbox/choice controls). */ + sdtFields: number; + /** Count of unique list sequences, not individual list items. */ + lists: number; } export interface DocumentInfoOutlineItem { diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts new file mode 100644 index 0000000000..c2d9449806 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { BlockIndex, BlockCandidate } from './node-address-resolver.js'; +import type { InlineIndex, InlineCandidate } from './inline-address-resolver.js'; +import type { InlineNodeType } from '@superdoc/document-api'; +import { getBlockIndex, getInlineIndex } from './index-cache.js'; +import { getTextAdapter } from '../get-text-adapter.js'; +import { groupTrackedChanges } from './tracked-change-resolver.js'; +import { findAllSdtNodes } from './content-controls/index.js'; +import { + getLiveDocumentCounts, + countWordsFromText, + countBlockNodeTypes, + countInlineImages, + countUniqueCommentIds, + countTrackedChanges, + countSdtFields, + countLists, +} from './live-document-counts.js'; + +vi.mock('./index-cache.js', () => ({ + getBlockIndex: vi.fn(), + getInlineIndex: vi.fn(), +})); + +vi.mock('../get-text-adapter.js', () => ({ + getTextAdapter: vi.fn(), +})); + +vi.mock('./tracked-change-resolver.js', () => ({ + groupTrackedChanges: vi.fn(), +})); + +vi.mock('./content-controls/index.js', () => ({ + findAllSdtNodes: vi.fn(), + resolveControlType: (attrs: Record) => attrs.controlType ?? attrs.type ?? 'unknown', +})); + +const getBlockIndexMock = vi.mocked(getBlockIndex); +const getInlineIndexMock = vi.mocked(getInlineIndex); +const getTextAdapterMock = vi.mocked(getTextAdapter); +const groupTrackedChangesMock = vi.mocked(groupTrackedChanges); +const findAllSdtNodesMock = vi.mocked(findAllSdtNodes); + +function makeBlockCandidate(nodeType: BlockCandidate['nodeType'], attrs?: Record): BlockCandidate { + return { + node: { + attrs, + textContent: typeof attrs?.textContent === 'string' ? attrs.textContent : '', + } as BlockCandidate['node'], + pos: 0, + end: 1, + nodeType, + nodeId: `${nodeType}-1`, + }; +} + +function makeBlockIndex(candidates: BlockCandidate[]): BlockIndex { + return { + candidates, + byId: new Map(), + ambiguous: new Set(), + }; +} + +function makeInlineCandidate(nodeType: InlineNodeType, attrs?: Record): InlineCandidate { + return { + nodeType, + anchor: { start: { blockId: 'p1', offset: 0 }, end: { blockId: 'p1', offset: 1 } }, + blockId: 'p1', + pos: 0, + end: 1, + attrs, + }; +} + +function makeInlineIndex(candidates: InlineCandidate[]): InlineIndex { + const byType = new Map(); + for (const c of candidates) { + const list = byType.get(c.nodeType) ?? []; + list.push(c); + byType.set(c.nodeType, list); + } + return { + candidates, + byType, + byKey: new Map(), + }; +} + +const EMPTY_EDITOR = { + state: { + doc: {}, + }, +} as Editor; + +describe('countWordsFromText', () => { + it('counts whitespace-delimited tokens', () => { + expect(countWordsFromText('hello world foo')).toBe(3); + }); + + it('returns 0 for empty text', () => { + expect(countWordsFromText('')).toBe(0); + }); + + it('returns 0 for whitespace-only text', () => { + expect(countWordsFromText(' \n\t ')).toBe(0); + }); + + it('handles leading/trailing whitespace', () => { + expect(countWordsFromText(' hello world ')).toBe(2); + }); +}); + +describe('countBlockNodeTypes', () => { + it('counts paragraphs, headings, tables, and block images', () => { + const index = makeBlockIndex([ + makeBlockCandidate('paragraph'), + makeBlockCandidate('paragraph'), + makeBlockCandidate('heading'), + makeBlockCandidate('table'), + makeBlockCandidate('image'), + makeBlockCandidate('image'), + ]); + + expect(countBlockNodeTypes(index)).toEqual({ + paragraphs: 2, + headings: 1, + tables: 1, + blockImages: 2, + }); + }); + + it('excludes list items from paragraph count', () => { + const index = makeBlockIndex([ + makeBlockCandidate('paragraph'), + makeBlockCandidate('listItem'), + makeBlockCandidate('listItem'), + ]); + + const result = countBlockNodeTypes(index); + expect(result.paragraphs).toBe(1); + }); + + it('skips tableRow, tableCell, tableOfContents, and sdt', () => { + const index = makeBlockIndex([ + makeBlockCandidate('tableRow'), + makeBlockCandidate('tableCell'), + makeBlockCandidate('tableOfContents'), + makeBlockCandidate('sdt'), + ]); + + expect(countBlockNodeTypes(index)).toEqual({ + paragraphs: 0, + headings: 0, + tables: 0, + blockImages: 0, + }); + }); + + it('returns all zeros for empty index', () => { + expect(countBlockNodeTypes(makeBlockIndex([]))).toEqual({ + paragraphs: 0, + headings: 0, + tables: 0, + blockImages: 0, + }); + }); +}); + +describe('countInlineImages', () => { + it('counts inline image candidates', () => { + const index = makeInlineIndex([ + makeInlineCandidate('image'), + makeInlineCandidate('image'), + makeInlineCandidate('comment', { commentId: 'c1' }), + ]); + expect(countInlineImages(index)).toBe(2); + }); + + it('returns 0 when no inline images exist', () => { + const index = makeInlineIndex([makeInlineCandidate('comment', { commentId: 'c1' })]); + expect(countInlineImages(index)).toBe(0); + }); +}); + +describe('countUniqueCommentIds', () => { + it('deduplicates repeated inline candidates for the same comment ID', () => { + const index = makeInlineIndex([ + makeInlineCandidate('comment', { commentId: 'c-1' }), + makeInlineCandidate('comment', { commentId: 'c-1' }), + makeInlineCandidate('comment', { commentId: 'c-2' }), + ]); + expect(countUniqueCommentIds(index)).toBe(2); + }); + + it('resolves commentId from importedId and w:id fallbacks', () => { + const index = makeInlineIndex([ + makeInlineCandidate('comment', { importedId: 'imported-1' }), + makeInlineCandidate('comment', { 'w:id': 'w-1' }), + ]); + expect(countUniqueCommentIds(index)).toBe(2); + }); + + it('deduplicates mark-vs-range candidates for the same comment ID', () => { + const markCandidate = makeInlineCandidate('comment', { commentId: 'c-1' }); + markCandidate.mark = {} as InlineCandidate['mark']; + + const rangeCandidate = makeInlineCandidate('comment', { commentId: 'c-1' }); + rangeCandidate.node = {} as InlineCandidate['node']; + + const index = makeInlineIndex([markCandidate, rangeCandidate]); + expect(countUniqueCommentIds(index)).toBe(1); + }); + + it('skips candidates with no resolvable comment ID', () => { + const index = makeInlineIndex([ + makeInlineCandidate('comment', {}), + makeInlineCandidate('comment', { commentId: 'c-1' }), + ]); + expect(countUniqueCommentIds(index)).toBe(1); + }); + + it('returns 0 when no comments exist', () => { + const index = makeInlineIndex([]); + expect(countUniqueCommentIds(index)).toBe(0); + }); +}); + +describe('countTrackedChanges', () => { + it('counts grouped tracked changes, not raw marks', () => { + groupTrackedChangesMock.mockReturnValue([{ id: 'tc-1' }, { id: 'tc-2' }, { id: 'tc-3' }] as ReturnType< + typeof groupTrackedChanges + >); + + expect(countTrackedChanges(EMPTY_EDITOR)).toBe(3); + }); +}); + +describe('countSdtFields', () => { + beforeEach(() => { + findAllSdtNodesMock.mockReset(); + }); + + it('counts only field-like SDT control types', () => { + findAllSdtNodesMock.mockReturnValue([ + { kind: 'block', pos: 0, node: { attrs: { controlType: 'text' } } }, + { kind: 'inline', pos: 1, node: { attrs: { controlType: 'checkbox' } } }, + { kind: 'block', pos: 2, node: { attrs: { controlType: 'comboBox' } } }, + { kind: 'block', pos: 3, node: { attrs: { controlType: 'group' } } }, + { kind: 'block', pos: 4, node: { attrs: { controlType: 'repeatingSection' } } }, + { kind: 'block', pos: 5, node: { attrs: { controlType: 'unknown' } } }, + ] as ReturnType); + + expect(countSdtFields(EMPTY_EDITOR)).toBe(3); + }); +}); + +describe('countLists', () => { + it('counts unique list sequences rather than individual list items', () => { + const index = makeBlockIndex([ + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } }, + }), + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } }, + }), + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { numId: 2, ilvl: 0 } }, + }), + makeBlockCandidate('paragraph'), + ]); + + index.candidates[0]!.nodeId = 'li-1'; + index.candidates[1]!.nodeId = 'li-2'; + index.candidates[2]!.nodeId = 'li-3'; + + expect(countLists(EMPTY_EDITOR, index)).toBe(2); + }); + + it('returns 0 when no list items exist', () => { + expect(countLists(EMPTY_EDITOR, makeBlockIndex([makeBlockCandidate('paragraph')]))).toBe(0); + }); +}); + +describe('getLiveDocumentCounts', () => { + beforeEach(() => { + getBlockIndexMock.mockReset(); + getInlineIndexMock.mockReset(); + getTextAdapterMock.mockReset(); + groupTrackedChangesMock.mockReset(); + findAllSdtNodesMock.mockReset(); + }); + + it('assembles all counts from indexes and text projection', () => { + getTextAdapterMock.mockReturnValue('hello world from the document'); + const blockIndex = makeBlockIndex([ + makeBlockCandidate('paragraph'), + makeBlockCandidate('paragraph'), + makeBlockCandidate('paragraph'), + makeBlockCandidate('heading'), + makeBlockCandidate('heading'), + makeBlockCandidate('table'), + makeBlockCandidate('image'), + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } }, + }), + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } }, + }), + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { numId: 2, ilvl: 0 } }, + }), + ]); + blockIndex.candidates[7]!.nodeId = 'li-1'; + blockIndex.candidates[8]!.nodeId = 'li-2'; + blockIndex.candidates[9]!.nodeId = 'li-3'; + getBlockIndexMock.mockReturnValue(blockIndex); + getInlineIndexMock.mockReturnValue( + makeInlineIndex([ + makeInlineCandidate('image'), + makeInlineCandidate('image'), + makeInlineCandidate('comment', { commentId: 'c-1' }), + makeInlineCandidate('comment', { commentId: 'c-1' }), + makeInlineCandidate('comment', { commentId: 'c-2' }), + ]), + ); + groupTrackedChangesMock.mockReturnValue([{ id: 'tc-1' }, { id: 'tc-2' }] as ReturnType); + findAllSdtNodesMock.mockReturnValue([ + { kind: 'block', pos: 0, node: { attrs: { controlType: 'text' } } }, + { kind: 'inline', pos: 1, node: { attrs: { controlType: 'checkbox' } } }, + { kind: 'block', pos: 2, node: { attrs: { controlType: 'group' } } }, + ] as ReturnType); + + const result = getLiveDocumentCounts(EMPTY_EDITOR); + + expect(result).toEqual({ + words: 5, + characters: 29, + paragraphs: 3, + headings: 2, + tables: 1, + images: 3, // 1 block + 2 inline + comments: 2, // 2 unique IDs from 3 candidates + trackedChanges: 2, + sdtFields: 2, + lists: 2, + }); + }); + + it('words and characters derive from the same text projection', () => { + const text = 'one two three'; + getTextAdapterMock.mockReturnValue(text); + getBlockIndexMock.mockReturnValue(makeBlockIndex([])); + getInlineIndexMock.mockReturnValue(makeInlineIndex([])); + groupTrackedChangesMock.mockReturnValue([] as ReturnType); + findAllSdtNodesMock.mockReturnValue([] as ReturnType); + + const result = getLiveDocumentCounts(EMPTY_EDITOR); + + expect(result.words).toBe(3); + expect(result.characters).toBe(text.length); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts new file mode 100644 index 0000000000..d45ce98fd3 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts @@ -0,0 +1,184 @@ +import type { Editor } from '../../core/Editor.js'; +import type { BlockIndex } from './node-address-resolver.js'; +import type { InlineIndex } from './inline-address-resolver.js'; +import { getBlockIndex, getInlineIndex } from './index-cache.js'; +import { getTextAdapter } from '../get-text-adapter.js'; +import { resolveCommentIdFromAttrs } from './value-utils.js'; +import { groupTrackedChanges } from './tracked-change-resolver.js'; +import { findAllSdtNodes, resolveControlType } from './content-controls/index.js'; +import { projectListItemCandidate } from './list-item-resolver.js'; +import { computeSequenceIdMap } from './list-sequence-helpers.js'; + +/** Snapshot of document-level counts derived from the current editor state. */ +export interface LiveDocumentCounts { + words: number; + characters: number; + paragraphs: number; + headings: number; + tables: number; + images: number; + comments: number; + trackedChanges: number; + sdtFields: number; + lists: number; +} + +const FIELD_LIKE_SDT_TYPES = new Set(['text', 'date', 'checkbox', 'comboBox', 'dropDownList']); + +/** + * Computes live document counts from the current editor snapshot. + * + * All counts are derived from already-cached block/inline indexes and the + * Document API text projection. No dedicated counts cache is maintained — + * the underlying indexes are cached by document snapshot in `index-cache.ts`. + * + * Count semantics: + * - `words`: whitespace-delimited tokens from the Document API text projection + * - `characters`: full length of the Document API text projection (includes + * inter-block newlines and one `'\n'` per non-text leaf node — "characters with spaces") + * - `paragraphs`: block-classified paragraphs (excludes headings and list items) + * - `headings`: block-classified headings (style-based detection) + * - `tables`: top-level table containers only (excludes rows and cells) + * - `images`: block images + inline images (dual-kind) + * - `comments`: unique anchored comment IDs from inline candidates + * - `trackedChanges`: grouped tracked-change entities from the current snapshot + * - `sdtFields`: field-like SDT/content-control nodes (text/date/checkbox/choice controls) + * - `lists`: unique list sequences, not individual list items + */ +export function getLiveDocumentCounts(editor: Editor): LiveDocumentCounts { + const text = getTextAdapter(editor, {}); + const blockIndex = getBlockIndex(editor); + const inlineIndex = getInlineIndex(editor); + + const blockCounts = countBlockNodeTypes(blockIndex); + const inlineImages = countInlineImages(inlineIndex); + + return { + words: countWordsFromText(text), + characters: text.length, + paragraphs: blockCounts.paragraphs, + headings: blockCounts.headings, + tables: blockCounts.tables, + images: blockCounts.blockImages + inlineImages, + comments: countUniqueCommentIds(inlineIndex), + trackedChanges: countTrackedChanges(editor), + sdtFields: countSdtFields(editor), + lists: countLists(editor, blockIndex), + }; +} + +/** + * Counts whitespace-delimited words in a text projection. + * Uses `text.trim().match(/\S+/g)` — any non-whitespace run is one word. + */ +export function countWordsFromText(text: string): number { + const matches = text.trim().match(/\S+/g); + return matches ? matches.length : 0; +} + +interface BlockNodeTypeCounts { + paragraphs: number; + headings: number; + tables: number; + blockImages: number; +} + +/** + * Single-pass count of block-level node types from the cached block index. + * + * Only counts the four types relevant to `doc.info()`. Other block types + * (listItem, tableRow, tableCell, tableOfContents, sdt) are intentionally skipped. + */ +export function countBlockNodeTypes(blockIndex: BlockIndex): BlockNodeTypeCounts { + let paragraphs = 0; + let headings = 0; + let tables = 0; + let blockImages = 0; + + for (const candidate of blockIndex.candidates) { + switch (candidate.nodeType) { + case 'paragraph': + paragraphs++; + break; + case 'heading': + headings++; + break; + case 'table': + tables++; + break; + case 'image': + blockImages++; + break; + // listItem, tableRow, tableCell, tableOfContents, sdt — not counted + } + } + + return { paragraphs, headings, tables, blockImages }; +} + +/** + * Counts inline images from the cached inline index. + */ +export function countInlineImages(inlineIndex: InlineIndex): number { + return inlineIndex.byType.get('image')?.length ?? 0; +} + +/** + * Counts unique anchored comment IDs from inline comment candidates. + * + * Preserves current semantics: comments are counted from inline anchors + * (marks and range nodes), deduplicated by resolved comment ID. This does + * NOT count from the entity store (which includes replies and unanchored entries). + */ +export function countUniqueCommentIds(inlineIndex: InlineIndex): number { + const commentCandidates = inlineIndex.byType.get('comment') ?? []; + const uniqueIds = new Set(); + + for (const candidate of commentCandidates) { + const commentId = resolveCommentIdFromAttrs(candidate.attrs ?? {}); + if (commentId) { + uniqueIds.add(commentId); + } + } + + return uniqueIds.size; +} + +/** + * Counts grouped tracked-change entities from the current editor snapshot. + * + * This matches `trackChanges.list().total`, not the raw number of PM marks. + */ +export function countTrackedChanges(editor: Editor): number { + return groupTrackedChanges(editor).length; +} + +/** + * Counts field-like SDT/content-control nodes in the document. + * + * Structural container controls such as groups and repeating sections are + * intentionally excluded so this count tracks user-facing SDT "fields". + */ +export function countSdtFields(editor: Editor): number { + const allSdts = findAllSdtNodes(editor.state.doc); + return allSdts.filter((sdt) => FIELD_LIKE_SDT_TYPES.has(resolveControlType(sdt.node.attrs ?? {}))).length; +} + +/** + * Counts unique list sequences in document order. + * + * Multiple contiguous items in the same list count as one list. This aligns + * with the existing `listId` semantics exposed by the lists adapter. + */ +export function countLists(editor: Editor, blockIndex: BlockIndex): number { + const listItems = blockIndex.candidates + .filter((candidate) => candidate.nodeType === 'listItem') + .map((candidate) => projectListItemCandidate(editor, candidate)); + + const sequenceIds = computeSequenceIdMap(listItems); + const uniqueSequences = new Set(); + for (const id of sequenceIds.values()) { + if (id) uniqueSequences.add(id); + } + return uniqueSequences.size; +} diff --git a/packages/super-editor/src/document-api-adapters/info-adapter.integration.test.ts b/packages/super-editor/src/document-api-adapters/info-adapter.integration.test.ts new file mode 100644 index 0000000000..5ada995949 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/info-adapter.integration.test.ts @@ -0,0 +1,61 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import type { Editor } from '../core/Editor.js'; +import { infoAdapter } from './info-adapter.js'; + +type LoadedDocData = Awaited>; + +let blankDocData: LoadedDocData; +let editor: Editor | undefined; + +beforeAll(async () => { + blankDocData = await loadTestDataForEditorTests('blank-doc.docx'); +}); + +afterEach(() => { + editor?.destroy(); + editor = undefined; +}); + +function createBlankEditor(): Editor { + const result = initTestEditor({ + content: blankDocData.docx, + media: blankDocData.media, + mediaFiles: blankDocData.mediaFiles, + fonts: blankDocData.fonts, + useImmediateSetTimeout: false, + }); + editor = result.editor; + return editor; +} + +describe('infoAdapter integration', () => { + it('returns correct counts for a blank document', () => { + const ed = createBlankEditor(); + const result = infoAdapter(ed, {}); + + expect(result.counts).toEqual({ + words: 0, + characters: 0, + paragraphs: 1, + headings: 0, + tables: 0, + images: 0, + comments: 0, + trackedChanges: 0, + sdtFields: 0, + lists: 0, + }); + }); + + it('characters matches the Document API text projection length', () => { + const ed = createBlankEditor(); + const doc = ed.state.doc; + const textProjection = doc.textBetween(0, doc.content.size, '\n', '\n'); + const result = infoAdapter(ed, {}); + + expect(result.counts.characters).toBe(textProjection.length); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/info-adapter.test.ts b/packages/super-editor/src/document-api-adapters/info-adapter.test.ts index 08fa80e396..ee306dd8ae 100644 --- a/packages/super-editor/src/document-api-adapters/info-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/info-adapter.test.ts @@ -1,21 +1,26 @@ -import type { Query, FindOutput, FindItemDomain } from '@superdoc/document-api'; +import type { FindOutput, FindItemDomain } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../core/Editor.js'; import { findLegacyAdapter } from './find-adapter.js'; -import { getTextAdapter } from './get-text-adapter.js'; +import { getLiveDocumentCounts } from './helpers/live-document-counts.js'; +import type { LiveDocumentCounts } from './helpers/live-document-counts.js'; import { infoAdapter } from './info-adapter.js'; vi.mock('./find-adapter.js', () => ({ findLegacyAdapter: vi.fn(), })); -vi.mock('./get-text-adapter.js', () => ({ - getTextAdapter: vi.fn(), +vi.mock('./helpers/live-document-counts.js', () => ({ + getLiveDocumentCounts: vi.fn(), +})); + +vi.mock('./plan-engine/revision-tracker.js', () => ({ + getRevision: vi.fn(() => '42'), })); const findLegacyAdapterMock = vi.mocked(findLegacyAdapter); -const getTextAdapterMock = vi.mocked(getTextAdapter); +const getLiveDocumentCountsMock = vi.mocked(getLiveDocumentCounts); function makeFindOutput( overrides: { @@ -43,16 +48,40 @@ function makeFindOutput( }; } -function resolveFindResult(query: Query): FindOutput { - if (query.select.type === 'text') { - throw new Error('infoAdapter should only perform node-type queries.'); - } +const DEFAULT_COUNTS: LiveDocumentCounts = { + words: 5, + characters: 29, + paragraphs: 5, + headings: 2, + tables: 1, + images: 3, + comments: 2, + trackedChanges: 1, + sdtFields: 4, + lists: 2, +}; + +describe('infoAdapter', () => { + beforeEach(() => { + findLegacyAdapterMock.mockReset(); + getLiveDocumentCountsMock.mockReset(); + }); + + it('delegates counts to getLiveDocumentCounts', () => { + getLiveDocumentCountsMock.mockReturnValue(DEFAULT_COUNTS); + findLegacyAdapterMock.mockReturnValue(makeFindOutput()); + + const result = infoAdapter({} as Editor, {}); + + expect(result.counts).toBe(DEFAULT_COUNTS); + expect(result.counts.characters).toBe(29); + expect(getLiveDocumentCountsMock).toHaveBeenCalledOnce(); + }); - switch (query.select.nodeType) { - case 'paragraph': - return makeFindOutput({ total: 5 }); - case 'heading': - return makeFindOutput({ + it('builds outline from heading find query', () => { + getLiveDocumentCountsMock.mockReturnValue(DEFAULT_COUNTS); + findLegacyAdapterMock.mockReturnValue( + makeFindOutput({ total: 2, items: [ { @@ -74,90 +103,42 @@ function resolveFindResult(query: Query): FindOutput { }, }, ], - }); - case 'table': - return makeFindOutput({ total: 1 }); - case 'image': - return makeFindOutput({ total: 3 }); - case 'comment': - return makeFindOutput({ - total: 4, - items: [ - { - address: { - kind: 'inline', - nodeType: 'comment', - anchor: { start: { blockId: 'p1', offset: 0 }, end: { blockId: 'p1', offset: 1 } }, - }, - node: { nodeType: 'comment', kind: 'inline', properties: { commentId: 'c-1' } }, - }, - { - address: { - kind: 'inline', - nodeType: 'comment', - anchor: { start: { blockId: 'p1', offset: 2 }, end: { blockId: 'p1', offset: 3 } }, - }, - node: { nodeType: 'comment', kind: 'inline', properties: { commentId: 'c-1' } }, - }, - { - address: { - kind: 'inline', - nodeType: 'comment', - anchor: { start: { blockId: 'p1', offset: 4 }, end: { blockId: 'p1', offset: 5 } }, - }, - node: { nodeType: 'comment', kind: 'inline', properties: { commentId: 'c-2' } }, - }, - ], - }); - default: - return makeFindOutput({}); - } -} - -describe('infoAdapter', () => { - beforeEach(() => { - findLegacyAdapterMock.mockReset(); - getTextAdapterMock.mockReset(); - }); - - it('computes counts and outline from find/get-text adapters', () => { - getTextAdapterMock.mockReturnValue('hello world from info adapter'); - findLegacyAdapterMock.mockImplementation((editor: Editor, query: Query) => resolveFindResult(query)); + }), + ); const result = infoAdapter({} as Editor, {}); - expect(result.counts).toEqual({ - words: 5, - paragraphs: 5, - headings: 2, - tables: 1, - images: 3, - comments: 2, - }); expect(result.outline).toEqual([ { level: 2, text: 'Overview', nodeId: 'H1' }, { level: 6, text: 'Details', nodeId: 'H2' }, ]); + }); + + it('includes capabilities and revision', () => { + getLiveDocumentCountsMock.mockReturnValue(DEFAULT_COUNTS); + findLegacyAdapterMock.mockReturnValue(makeFindOutput()); + + const result = infoAdapter({} as Editor, {}); + expect(result.capabilities).toEqual({ canFind: true, canGetNode: true, canComment: true, canReplace: true, }); + expect(result.revision).toBe('42'); }); - it('falls back to total comment count when includeNodes does not return comment nodes', () => { - getTextAdapterMock.mockReturnValue(''); - findLegacyAdapterMock.mockImplementation((editor: Editor, query: Query) => { - if (query.select.type === 'text') return makeFindOutput({}); - if (query.select.nodeType === 'comment') { - return makeFindOutput({ total: 7 }); - } - return makeFindOutput({}); - }); + it('only calls findLegacyAdapter for heading query (not for counts)', () => { + getLiveDocumentCountsMock.mockReturnValue(DEFAULT_COUNTS); + findLegacyAdapterMock.mockReturnValue(makeFindOutput()); - const result = infoAdapter({} as Editor, {}); + infoAdapter({} as Editor, {}); - expect(result.counts.comments).toBe(7); + expect(findLegacyAdapterMock).toHaveBeenCalledOnce(); + expect(findLegacyAdapterMock).toHaveBeenCalledWith(expect.anything(), { + select: { type: 'node', nodeType: 'heading' }, + includeNodes: true, + }); }); }); diff --git a/packages/super-editor/src/document-api-adapters/info-adapter.ts b/packages/super-editor/src/document-api-adapters/info-adapter.ts index 53ca5d2156..c0b837bf39 100644 --- a/packages/super-editor/src/document-api-adapters/info-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/info-adapter.ts @@ -1,16 +1,10 @@ -import type { DocumentInfo, FindOutput, InfoInput, NodeInfo, NodeType } from '@superdoc/document-api'; +import type { DocumentInfo, FindOutput, InfoInput, NodeInfo } from '@superdoc/document-api'; import type { Editor } from '../core/Editor.js'; import { findLegacyAdapter } from './find-adapter.js'; -import { getTextAdapter } from './get-text-adapter.js'; import { getRevision } from './plan-engine/revision-tracker.js'; +import { getLiveDocumentCounts } from './helpers/live-document-counts.js'; type HeadingNodeInfo = Extract; -type CommentNodeInfo = Extract; - -function countWords(text: string): number { - const matches = text.trim().match(/\S+/g); - return matches ? matches.length : 0; -} function clampHeadingLevel(value: unknown): number { if (typeof value !== 'number' || !Number.isFinite(value)) return 1; @@ -24,10 +18,6 @@ function isHeadingNodeInfo(node: NodeInfo | undefined): node is HeadingNodeInfo return node?.kind === 'block' && node.nodeType === 'heading'; } -function isCommentNodeInfo(node: NodeInfo | undefined): node is CommentNodeInfo { - return node?.kind === 'inline' && node.nodeType === 'comment'; -} - function getHeadingText(node: HeadingNodeInfo | undefined): string { if (!node) return ''; if (typeof node.text === 'string' && node.text.length > 0) return node.text; @@ -52,52 +42,23 @@ function buildOutline(result: FindOutput): DocumentInfo['outline'] { return outline; } -function countDistinctCommentIds(result: FindOutput): number { - const commentIds = new Set(); - for (const item of result.items) { - if (!isCommentNodeInfo(item.node)) continue; - if (typeof item.node.properties.commentId !== 'string' || item.node.properties.commentId.length === 0) continue; - commentIds.add(item.node.properties.commentId); - } - - // When node data is available, deduplicate by commentId. Otherwise fall - // back to the query total (e.g. when includeNodes was not requested). - if (commentIds.size > 0) { - return commentIds.size; - } - return result.total; -} - -function findByNodeType(editor: Editor, nodeType: NodeType, includeNodes = false): FindOutput { - return findLegacyAdapter(editor, { - select: { type: 'node', nodeType }, - includeNodes, - }); -} - /** - * Build `doc.info` payload from engine-backed find/getText adapters. + * Build `doc.info` payload from live document counts and heading outline. * - * This keeps `document-api` engine-agnostic while centralizing composition - * logic in the super-editor adapter layer. + * Counts are derived from the centralized live-document-counts helper. + * Outline generation still uses the heading find query (needs NodeInfo data + * for text and level that the block index does not provide). */ export function infoAdapter(editor: Editor, _input: InfoInput): DocumentInfo { - const text = getTextAdapter(editor, {}); - const paragraphResult = findByNodeType(editor, 'paragraph'); - const headingResult = findByNodeType(editor, 'heading', true); - const tableResult = findByNodeType(editor, 'table'); - const imageResult = findByNodeType(editor, 'image'); - const commentResult = findByNodeType(editor, 'comment', true); + const counts = getLiveDocumentCounts(editor); + + const headingResult = findLegacyAdapter(editor, { + select: { type: 'node', nodeType: 'heading' }, + includeNodes: true, + }); return { - counts: { - words: countWords(text), - paragraphs: paragraphResult.total, - headings: headingResult.total, - tables: tableResult.total, - images: imageResult.total, - comments: countDistinctCommentIds(commentResult), - }, + counts, outline: buildOutline(headingResult), capabilities: { canFind: true, From 8932e0035bf0371584dbc9d58914d85f74202df4 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 19:21:44 -0700 Subject: [PATCH 2/3] fix: live doc.info list counting and cache repeated snapshot reads --- .../helpers/live-document-counts.test.ts | 127 ++++++++++++++++-- .../helpers/live-document-counts.ts | 113 ++++++++++++++-- 2 files changed, 216 insertions(+), 24 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts index c2d9449806..116a8d657a 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts @@ -88,11 +88,13 @@ function makeInlineIndex(candidates: InlineCandidate[]): InlineIndex { }; } -const EMPTY_EDITOR = { - state: { - doc: {}, - }, -} as Editor; +function makeEditor(doc: Record = {}): Editor { + return { + state: { + doc, + }, + } as Editor; +} describe('countWordsFromText', () => { it('counts whitespace-delimited tokens', () => { @@ -233,7 +235,7 @@ describe('countTrackedChanges', () => { typeof groupTrackedChanges >); - expect(countTrackedChanges(EMPTY_EDITOR)).toBe(3); + expect(countTrackedChanges(makeEditor())).toBe(3); }); }); @@ -252,7 +254,7 @@ describe('countSdtFields', () => { { kind: 'block', pos: 5, node: { attrs: { controlType: 'unknown' } } }, ] as ReturnType); - expect(countSdtFields(EMPTY_EDITOR)).toBe(3); + expect(countSdtFields(makeEditor())).toBe(3); }); }); @@ -275,11 +277,67 @@ describe('countLists', () => { index.candidates[1]!.nodeId = 'li-2'; index.candidates[2]!.nodeId = 'li-3'; - expect(countLists(EMPTY_EDITOR, index)).toBe(2); + expect(countLists(makeEditor(), index)).toBe(2); + }); + + it('counts visible list runs when list items have marker/path data but no numId', () => { + const index = makeBlockIndex([ + makeBlockCandidate('listItem', { + listRendering: { markerText: '1.', path: [1], numberingType: 'decimal' }, + }), + makeBlockCandidate('listItem', { + listRendering: { markerText: '2.', path: [2], numberingType: 'decimal' }, + }), + ]); + + index.candidates[0]!.nodeId = 'li-visible-1'; + index.candidates[1]!.nodeId = 'li-visible-2'; + + expect(countLists(makeEditor(), index)).toBe(1); + }); + + it('counts visible list runs when list items only expose ilvl metadata', () => { + const index = makeBlockIndex([ + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { ilvl: 0 } }, + }), + makeBlockCandidate('listItem', { + paragraphProperties: { numberingProperties: { ilvl: 0 } }, + }), + ]); + + index.candidates[0]!.nodeId = 'li-ilvl-1'; + index.candidates[1]!.nodeId = 'li-ilvl-2'; + + expect(countLists(makeEditor(), index)).toBe(1); + }); + + it('starts a new visible list when fallback ordinals restart at the same level', () => { + const index = makeBlockIndex([ + makeBlockCandidate('listItem', { + listRendering: { markerText: '1.', path: [1], numberingType: 'decimal' }, + }), + makeBlockCandidate('listItem', { + listRendering: { markerText: '2.', path: [2], numberingType: 'decimal' }, + }), + makeBlockCandidate('listItem', { + listRendering: { markerText: '1.', path: [1], numberingType: 'decimal' }, + }), + makeBlockCandidate('listItem', { + listRendering: { markerText: '2.', path: [2], numberingType: 'decimal' }, + }), + ]); + + index.candidates[0]!.nodeId = 'li-reset-1'; + index.candidates[1]!.nodeId = 'li-reset-2'; + index.candidates[2]!.nodeId = 'li-reset-3'; + index.candidates[3]!.nodeId = 'li-reset-4'; + + expect(countLists(makeEditor(), index)).toBe(2); }); it('returns 0 when no list items exist', () => { - expect(countLists(EMPTY_EDITOR, makeBlockIndex([makeBlockCandidate('paragraph')]))).toBe(0); + expect(countLists(makeEditor(), makeBlockIndex([makeBlockCandidate('paragraph')]))).toBe(0); }); }); @@ -293,6 +351,7 @@ describe('getLiveDocumentCounts', () => { }); it('assembles all counts from indexes and text projection', () => { + const editor = makeEditor(); getTextAdapterMock.mockReturnValue('hello world from the document'); const blockIndex = makeBlockIndex([ makeBlockCandidate('paragraph'), @@ -332,7 +391,7 @@ describe('getLiveDocumentCounts', () => { { kind: 'block', pos: 2, node: { attrs: { controlType: 'group' } } }, ] as ReturnType); - const result = getLiveDocumentCounts(EMPTY_EDITOR); + const result = getLiveDocumentCounts(editor); expect(result).toEqual({ words: 5, @@ -349,6 +408,7 @@ describe('getLiveDocumentCounts', () => { }); it('words and characters derive from the same text projection', () => { + const editor = makeEditor(); const text = 'one two three'; getTextAdapterMock.mockReturnValue(text); getBlockIndexMock.mockReturnValue(makeBlockIndex([])); @@ -356,9 +416,54 @@ describe('getLiveDocumentCounts', () => { groupTrackedChangesMock.mockReturnValue([] as ReturnType); findAllSdtNodesMock.mockReturnValue([] as ReturnType); - const result = getLiveDocumentCounts(EMPTY_EDITOR); + const result = getLiveDocumentCounts(editor); expect(result.words).toBe(3); expect(result.characters).toBe(text.length); }); + + it('reuses cached counts for repeated reads of the same document snapshot', () => { + const editor = makeEditor({ docId: 'snapshot-1' }); + + getTextAdapterMock.mockReturnValue('one two'); + getBlockIndexMock.mockReturnValue(makeBlockIndex([makeBlockCandidate('paragraph')])); + getInlineIndexMock.mockReturnValue(makeInlineIndex([])); + groupTrackedChangesMock.mockReturnValue([{ id: 'tc-1' }] as ReturnType); + findAllSdtNodesMock.mockReturnValue([ + { kind: 'block', pos: 0, node: { attrs: { controlType: 'text' } } }, + ] as ReturnType); + + const first = getLiveDocumentCounts(editor); + const second = getLiveDocumentCounts(editor); + + expect(first).toEqual(second); + expect(first).not.toBe(second); + expect(getTextAdapterMock).toHaveBeenCalledOnce(); + expect(getBlockIndexMock).toHaveBeenCalledOnce(); + expect(getInlineIndexMock).toHaveBeenCalledOnce(); + expect(groupTrackedChangesMock).toHaveBeenCalledOnce(); + expect(findAllSdtNodesMock).toHaveBeenCalledOnce(); + }); + + it('invalidates the cache when the editor doc snapshot changes', () => { + const editor = makeEditor({ docId: 'snapshot-1' }) as Editor & { state: { doc: Record } }; + + getTextAdapterMock.mockReturnValueOnce('one').mockReturnValueOnce('one two'); + getBlockIndexMock.mockReturnValue(makeBlockIndex([makeBlockCandidate('paragraph')])); + getInlineIndexMock.mockReturnValue(makeInlineIndex([])); + groupTrackedChangesMock.mockReturnValue([] as ReturnType); + findAllSdtNodesMock.mockReturnValue([] as ReturnType); + + const first = getLiveDocumentCounts(editor); + editor.state.doc = { docId: 'snapshot-2' }; + const second = getLiveDocumentCounts(editor); + + expect(first.words).toBe(1); + expect(second.words).toBe(2); + expect(getTextAdapterMock).toHaveBeenCalledTimes(2); + expect(getBlockIndexMock).toHaveBeenCalledTimes(2); + expect(getInlineIndexMock).toHaveBeenCalledTimes(2); + expect(groupTrackedChangesMock).toHaveBeenCalledTimes(2); + expect(findAllSdtNodesMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts index d45ce98fd3..9ba3fed963 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts @@ -6,7 +6,7 @@ import { getTextAdapter } from '../get-text-adapter.js'; import { resolveCommentIdFromAttrs } from './value-utils.js'; import { groupTrackedChanges } from './tracked-change-resolver.js'; import { findAllSdtNodes, resolveControlType } from './content-controls/index.js'; -import { projectListItemCandidate } from './list-item-resolver.js'; +import { projectListItemCandidate, type ListItemProjection } from './list-item-resolver.js'; import { computeSequenceIdMap } from './list-sequence-helpers.js'; /** Snapshot of document-level counts derived from the current editor state. */ @@ -23,14 +23,21 @@ export interface LiveDocumentCounts { lists: number; } +type LiveDocumentCountsCacheEntry = { + doc: Editor['state']['doc']; + counts: LiveDocumentCounts; +}; + const FIELD_LIKE_SDT_TYPES = new Set(['text', 'date', 'checkbox', 'comboBox', 'dropDownList']); +const liveDocumentCountsCache = new WeakMap(); /** * Computes live document counts from the current editor snapshot. * - * All counts are derived from already-cached block/inline indexes and the - * Document API text projection. No dedicated counts cache is maintained — - * the underlying indexes are cached by document snapshot in `index-cache.ts`. + * The helper caches the fully-derived counts by immutable ProseMirror + * document snapshot. Repeated `doc.info()` reads against the same snapshot + * reuse the cached result instead of rescanning text, tracked changes, or + * content controls. * * Count semantics: * - `words`: whitespace-delimited tokens from the Document API text projection @@ -43,9 +50,24 @@ const FIELD_LIKE_SDT_TYPES = new Set(['text', 'date', 'checkbox', 'comboBox', 'd * - `comments`: unique anchored comment IDs from inline candidates * - `trackedChanges`: grouped tracked-change entities from the current snapshot * - `sdtFields`: field-like SDT/content-control nodes (text/date/checkbox/choice controls) - * - `lists`: unique list sequences, not individual list items + * - `lists`: unique list sequences, not individual list items. When list items + * are visible but `numId` is unavailable, counts fall back to visible runs. */ export function getLiveDocumentCounts(editor: Editor): LiveDocumentCounts { + const currentDoc = editor.state.doc; + const cached = liveDocumentCountsCache.get(editor); + + if (cached && cached.doc === currentDoc) { + return cloneLiveDocumentCounts(cached.counts); + } + + const counts = computeLiveDocumentCounts(editor); + liveDocumentCountsCache.set(editor, { doc: currentDoc, counts }); + + return cloneLiveDocumentCounts(counts); +} + +function computeLiveDocumentCounts(editor: Editor): LiveDocumentCounts { const text = getTextAdapter(editor, {}); const blockIndex = getBlockIndex(editor); const inlineIndex = getInlineIndex(editor); @@ -67,6 +89,10 @@ export function getLiveDocumentCounts(editor: Editor): LiveDocumentCounts { }; } +function cloneLiveDocumentCounts(counts: LiveDocumentCounts): LiveDocumentCounts { + return { ...counts }; +} + /** * Counts whitespace-delimited words in a text projection. * Uses `text.trim().match(/\S+/g)` — any non-whitespace run is one word. @@ -167,18 +193,79 @@ export function countSdtFields(editor: Editor): number { /** * Counts unique list sequences in document order. * - * Multiple contiguous items in the same list count as one list. This aligns - * with the existing `listId` semantics exposed by the lists adapter. + * Multiple contiguous items in the same list count as one list. Numbered + * lists preserve the existing `listId`/sequence semantics. When imported + * list items are visibly rendered but do not yet expose a `numId`, the + * counter falls back to visible list runs so those lists still count. */ export function countLists(editor: Editor, blockIndex: BlockIndex): number { - const listItems = blockIndex.candidates + const listItems = getListItemProjections(editor, blockIndex); + if (listItems.length === 0) return 0; + + const sequenceIds = computeSequenceIdMap(listItems); + let listCount = 0; + let previousSequenceId: string | undefined; + let previousFallbackItem: ListItemProjection | undefined; + + for (const item of listItems) { + const sequenceId = sequenceIds.get(item.address.nodeId) ?? ''; + + if (sequenceId) { + if (sequenceId !== previousSequenceId) { + listCount += 1; + } + previousSequenceId = sequenceId; + previousFallbackItem = undefined; + continue; + } + + previousSequenceId = undefined; + if (!previousFallbackItem || startsNewFallbackListSequence(previousFallbackItem, item)) { + listCount += 1; + } + previousFallbackItem = item; + } + + return listCount; +} + +function getListItemProjections(editor: Editor, blockIndex: BlockIndex): ListItemProjection[] { + return blockIndex.candidates .filter((candidate) => candidate.nodeType === 'listItem') .map((candidate) => projectListItemCandidate(editor, candidate)); +} - const sequenceIds = computeSequenceIdMap(listItems); - const uniqueSequences = new Set(); - for (const id of sequenceIds.values()) { - if (id) uniqueSequences.add(id); +function startsNewFallbackListSequence(previous: ListItemProjection, current: ListItemProjection): boolean { + if (hasKnownListKindChange(previous, current)) { + return true; + } + + return hasOrdinalRestartAtSameVisibleLevel(previous, current); +} + +function hasKnownListKindChange(previous: ListItemProjection, current: ListItemProjection): boolean { + return previous.kind != null && current.kind != null && previous.kind !== current.kind; +} + +function hasOrdinalRestartAtSameVisibleLevel(previous: ListItemProjection, current: ListItemProjection): boolean { + const previousLevel = resolveVisibleListLevel(previous); + const currentLevel = resolveVisibleListLevel(current); + + if (previousLevel == null || currentLevel == null || previousLevel !== currentLevel) { + return false; } - return uniqueSequences.size; + + if (previous.ordinal == null || current.ordinal == null) { + return false; + } + + return current.ordinal <= previous.ordinal; +} + +function resolveVisibleListLevel(item: ListItemProjection): number | undefined { + if (item.level != null) { + return item.level; + } + + return item.path && item.path.length > 0 ? item.path.length - 1 : undefined; } From c8bfbccab75041346d2cf4669376764fb1207dcf Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 19:30:14 -0700 Subject: [PATCH 3/3] chore: fix native build --- packages/super-editor/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/super-editor/tsconfig.json b/packages/super-editor/tsconfig.json index 34221ed43c..063063e81e 100644 --- a/packages/super-editor/tsconfig.json +++ b/packages/super-editor/tsconfig.json @@ -14,6 +14,7 @@ "@features/*": ["./src/features/*"], "@components/*": ["./src/components/*"], "@helpers/*": ["./src/core/helpers/*"], + "@utils/*": ["./src/utils/*"], "@converter/*": ["./src/core/super-converter/*"], "@tests/*": ["./src/tests/*"], "@translator": ["./src/core/super-converter/v3/node-translator/"]