Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/document-api/src/contract/operation-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,9 @@ export const OPERATION_DEFINITIONS = {
info: {
memberPath: 'info',
description:
'Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities.',
'Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page 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.',
'Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists, and optionally pages when pagination is active), document outline, capability flags, and revision.',
requiresDocumentContext: true,
metadata: readOperation(),
referenceDocPath: 'info.mdx',
Expand Down
1 change: 1 addition & 0 deletions packages/document-api/src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@
const trackedChangeAddressSchema = ref('TrackedChangeAddress');
const entityAddressSchema = ref('EntityAddress');
const selectionTargetSchema = ref('SelectionTarget');
const targetLocatorSchema = ref('TargetLocator');

Check warning on line 465 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'targetLocatorSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
const deleteBehaviorSchema = ref('DeleteBehavior');
const resolvedHandleSchema = ref('ResolvedHandle');
const pageInfoSchema = ref('PageInfo');
Expand Down Expand Up @@ -726,7 +726,7 @@
text: { type: 'string' },
});

const nodeInfoSchema: JsonSchema = {

Check warning on line 729 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'nodeInfoSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
type: 'object',
required: ['nodeType', 'kind'],
properties: {
Expand All @@ -742,7 +742,7 @@
additionalProperties: false,
};

const matchContextSchema = objectSchema(

Check warning on line 745 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'matchContextSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
address: nodeAddressSchema,
snippet: { type: 'string' },
Expand All @@ -753,7 +753,7 @@
['address', 'snippet', 'highlightRange'],
);

const unknownNodeDiagnosticSchema = objectSchema(

Check warning on line 756 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'unknownNodeDiagnosticSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
message: { type: 'string' },
address: nodeAddressSchema,
Expand Down Expand Up @@ -907,6 +907,7 @@
trackedChanges: { type: 'integer' },
sdtFields: { type: 'integer' },
lists: { type: 'integer' },
pages: { type: 'integer' },
},
[
'words',
Expand Down
2 changes: 2 additions & 0 deletions packages/document-api/src/types/info.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface DocumentInfoCounts {
sdtFields: number;
/** Count of unique list sequences, not individual list items. */
lists: number;
/** Number of layout pages. Absent when pagination is inactive or layout hasn't completed. */
pages?: number;
}

export interface DocumentInfoOutlineItem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
countTrackedChanges,
countSdtFields,
countLists,
countPages,
} from './live-document-counts.js';

vi.mock('./index-cache.js', () => ({
Expand Down Expand Up @@ -96,6 +97,8 @@ function makeEditor(doc: Record<string, unknown> = {}): Editor {
} as Editor;
}

const EMPTY_EDITOR = makeEditor();

describe('countWordsFromText', () => {
it('counts whitespace-delimited tokens', () => {
expect(countWordsFromText('hello world foo')).toBe(3);
Expand Down Expand Up @@ -341,6 +344,20 @@ describe('countLists', () => {
});
});

describe('countPages', () => {
it('returns page count when currentTotalPages is available', () => {
const editorWithPages = {
...EMPTY_EDITOR,
currentTotalPages: 5,
} as unknown as Editor;
expect(countPages(editorWithPages)).toBe(5);
});

it('returns undefined when no presentationEditor', () => {
expect(countPages(EMPTY_EDITOR)).toBeUndefined();
});
});

describe('getLiveDocumentCounts', () => {
beforeEach(() => {
getBlockIndexMock.mockReset();
Expand Down Expand Up @@ -407,6 +424,31 @@ describe('getLiveDocumentCounts', () => {
});
});

it('includes pages when currentTotalPages is available', () => {
getTextAdapterMock.mockReturnValue('hello');
getBlockIndexMock.mockReturnValue(makeBlockIndex([]));
getInlineIndexMock.mockReturnValue(makeInlineIndex([]));
groupTrackedChangesMock.mockReturnValue([] as ReturnType<typeof groupTrackedChanges>);
findAllSdtNodesMock.mockReturnValue([] as ReturnType<typeof findAllSdtNodes>);

const editorWithPages = { ...EMPTY_EDITOR, currentTotalPages: 7 } as unknown as Editor;
const result = getLiveDocumentCounts(editorWithPages);

expect(result.pages).toBe(7);
});

it('omits pages key when pagination is inactive', () => {
getTextAdapterMock.mockReturnValue('hello');
getBlockIndexMock.mockReturnValue(makeBlockIndex([]));
getInlineIndexMock.mockReturnValue(makeInlineIndex([]));
groupTrackedChangesMock.mockReturnValue([] as ReturnType<typeof groupTrackedChanges>);
findAllSdtNodesMock.mockReturnValue([] as ReturnType<typeof findAllSdtNodes>);

const result = getLiveDocumentCounts(EMPTY_EDITOR);

expect('pages' in result).toBe(false);
});

it('words and characters derive from the same text projection', () => {
const editor = makeEditor();
const text = 'one two three';
Expand Down Expand Up @@ -445,6 +487,36 @@ describe('getLiveDocumentCounts', () => {
expect(findAllSdtNodesMock).toHaveBeenCalledOnce();
});

it('re-reads pages on every call even when the document snapshot cache is reused', () => {
const editor = {
...makeEditor({ docId: 'snapshot-1' }),
currentTotalPages: undefined,
} as Editor & { currentTotalPages?: number };

getTextAdapterMock.mockReturnValue('one two');
getBlockIndexMock.mockReturnValue(makeBlockIndex([makeBlockCandidate('paragraph')]));
getInlineIndexMock.mockReturnValue(makeInlineIndex([]));
groupTrackedChangesMock.mockReturnValue([{ id: 'tc-1' }] as ReturnType<typeof groupTrackedChanges>);
findAllSdtNodesMock.mockReturnValue([
{ kind: 'block', pos: 0, node: { attrs: { controlType: 'text' } } },
] as ReturnType<typeof findAllSdtNodes>);

const beforeLayout = getLiveDocumentCounts(editor);
editor.currentTotalPages = 4;
const afterInitialLayout = getLiveDocumentCounts(editor);
editor.currentTotalPages = 6;
const afterRepagination = getLiveDocumentCounts(editor);

expect('pages' in beforeLayout).toBe(false);
expect(afterInitialLayout.pages).toBe(4);
expect(afterRepagination.pages).toBe(6);
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<string, unknown> } };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ export interface LiveDocumentCounts {
trackedChanges: number;
sdtFields: number;
lists: number;
/** Page count from the layout engine, if pagination is active. */
pages?: number;
}

type CachedLiveDocumentCounts = Omit<LiveDocumentCounts, 'pages'>;

type LiveDocumentCountsCacheEntry = {
doc: Editor['state']['doc'];
counts: LiveDocumentCounts;
counts: CachedLiveDocumentCounts;
};

const FIELD_LIKE_SDT_TYPES = new Set(['text', 'date', 'checkbox', 'comboBox', 'dropDownList']);
Expand All @@ -34,10 +38,11 @@ const liveDocumentCountsCache = new WeakMap<Editor, LiveDocumentCountsCacheEntry
/**
* Computes live document counts from the current editor snapshot.
*
* The helper caches the fully-derived counts by immutable ProseMirror
* The helper caches document-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.
* content controls. Page count is merged in fresh on every call because
* layout can change without a ProseMirror doc mutation.
*
* Count semantics:
* - `words`: whitespace-delimited tokens from the Document API text projection
Expand All @@ -52,22 +57,24 @@ const liveDocumentCountsCache = new WeakMap<Editor, LiveDocumentCountsCacheEntry
* - `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.
* - `pages`: layout page count (omitted when pagination is inactive)
*/
export function getLiveDocumentCounts(editor: Editor): LiveDocumentCounts {
const currentDoc = editor.state.doc;
const cached = liveDocumentCountsCache.get(editor);
const pages = countPages(editor);

if (cached && cached.doc === currentDoc) {
return cloneLiveDocumentCounts(cached.counts);
return cloneLiveDocumentCounts(cached.counts, pages);
}

const counts = computeLiveDocumentCounts(editor);
liveDocumentCountsCache.set(editor, { doc: currentDoc, counts });

return cloneLiveDocumentCounts(counts);
return cloneLiveDocumentCounts(counts, pages);
}

function computeLiveDocumentCounts(editor: Editor): LiveDocumentCounts {
function computeLiveDocumentCounts(editor: Editor): CachedLiveDocumentCounts {
const text = getTextAdapter(editor, {});
const blockIndex = getBlockIndex(editor);
const inlineIndex = getInlineIndex(editor);
Expand All @@ -89,8 +96,8 @@ function computeLiveDocumentCounts(editor: Editor): LiveDocumentCounts {
};
}

function cloneLiveDocumentCounts(counts: LiveDocumentCounts): LiveDocumentCounts {
return { ...counts };
function cloneLiveDocumentCounts(counts: CachedLiveDocumentCounts, pages: number | undefined): LiveDocumentCounts {
return pages != null ? { ...counts, pages } : { ...counts };
}

/**
Expand Down Expand Up @@ -269,3 +276,12 @@ function resolveVisibleListLevel(item: ListItemProjection): number | undefined {

return item.path && item.path.length > 0 ? item.path.length - 1 : undefined;
}

/**
* Returns the current page count when pagination is active.
* Delegates to `editor.currentTotalPages`, which returns `undefined`
* when no PresentationEditor exists or layout hasn't completed.
*/
export function countPages(editor: Editor): number | undefined {
return editor.currentTotalPages;
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ describe('infoAdapter', () => {
expect(result.revision).toBe('42');
});

it('passes through pages count when present', () => {
const countsWithPages = { ...DEFAULT_COUNTS, pages: 12 };
getLiveDocumentCountsMock.mockReturnValue(countsWithPages);
findLegacyAdapterMock.mockReturnValue(makeFindOutput());

const result = infoAdapter({} as Editor, {});

expect(result.counts.pages).toBe(12);
});

it('only calls findLegacyAdapter for heading query (not for counts)', () => {
getLiveDocumentCountsMock.mockReturnValue(DEFAULT_COUNTS);
findLegacyAdapterMock.mockReturnValue(makeFindOutput());
Expand Down
Loading
Loading