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..116a8d657a
--- /dev/null
+++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts
@@ -0,0 +1,469 @@
+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(),
+ };
+}
+
+function makeEditor(doc: Record = {}): Editor {
+ return {
+ 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(makeEditor())).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(makeEditor())).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(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(makeEditor(), 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', () => {
+ const editor = makeEditor();
+ 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(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 editor = makeEditor();
+ 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(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
new file mode 100644
index 0000000000..9ba3fed963
--- /dev/null
+++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts
@@ -0,0 +1,271 @@
+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, 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. */
+export interface LiveDocumentCounts {
+ words: number;
+ characters: number;
+ paragraphs: number;
+ headings: number;
+ tables: number;
+ images: number;
+ comments: number;
+ trackedChanges: number;
+ sdtFields: number;
+ 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.
+ *
+ * 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
+ * - `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. 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);
+
+ 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),
+ };
+}
+
+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.
+ */
+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. 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 = 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));
+}
+
+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;
+ }
+
+ 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;
+}
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,
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/"]