diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index d2c2f2541d..ced815bcea 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -111,11 +111,20 @@ const INTENT_NAMES = { 'doc.lists.list': 'list_lists', 'doc.lists.get': 'get_list', 'doc.lists.insert': 'insert_list', - 'doc.lists.setType': 'set_list_type', 'doc.lists.indent': 'indent_list', 'doc.lists.outdent': 'outdent_list', - 'doc.lists.restart': 'restart_list_numbering', - 'doc.lists.exit': 'exit_list', + 'doc.lists.create': 'create_list', + 'doc.lists.attach': 'attach_to_list', + 'doc.lists.detach': 'detach_from_list', + 'doc.lists.join': 'join_lists', + 'doc.lists.canJoin': 'can_join_lists', + 'doc.lists.separate': 'separate_list', + 'doc.lists.setLevel': 'set_list_level', + 'doc.lists.setValue': 'set_list_value', + 'doc.lists.continuePrevious': 'continue_previous_list', + 'doc.lists.canContinuePrevious': 'can_continue_previous_list', + 'doc.lists.setLevelRestart': 'set_list_level_restart', + 'doc.lists.convertToText': 'convert_list_to_text', 'doc.comments.create': 'create_comment', 'doc.comments.patch': 'patch_comment', 'doc.comments.delete': 'delete_comment', diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts index e420844ead..3f827843a1 100644 --- a/apps/cli/src/__tests__/cli.test.ts +++ b/apps/cli/src/__tests__/cli.test.ts @@ -51,17 +51,21 @@ async function runCli(args: string[], stdinBytes?: Uint8Array): Promise { describe('superdoc CLI', () => { beforeAll(async () => { - process.env.SUPERDOC_CLI_STATE_DIR = STATE_DIR; await mkdir(TEST_DIR, { recursive: true }); await copyFile(await resolveSourceDocFixture(), SAMPLE_DOC); await copyFile(await resolveListDocFixture(), LIST_SAMPLE_DOC); @@ -157,7 +160,6 @@ describe('superdoc CLI', () => { afterAll(async () => { await rm(TEST_DIR, { recursive: true, force: true }); - delete process.env.SUPERDOC_CLI_STATE_DIR; }); test('status returns inactive when no document is open', async () => { @@ -1172,28 +1174,26 @@ describe('superdoc CLI', () => { expect(closeResult.code).toBe(0); }); - test('lists set-type tracked mode maps to TRACK_CHANGE_COMMAND_UNAVAILABLE', async () => { - const source = join(TEST_DIR, 'lists-set-type-source.docx'); - const out = join(TEST_DIR, 'lists-set-type-out.docx'); + test('lists detach tracked mode maps to TRACK_CHANGE_COMMAND_UNAVAILABLE', async () => { + const source = join(TEST_DIR, 'lists-detach-source.docx'); + const out = join(TEST_DIR, 'lists-detach-out.docx'); await copyFile(LIST_SAMPLE_DOC, source); const target = await firstListItemAddress(['lists', 'list', source, '--limit', '1']); - const setTypeResult = await runCli([ + const detachResult = await runCli([ 'lists', - 'set-type', + 'detach', source, '--target-json', JSON.stringify(target), - '--kind', - 'bullet', '--change-mode', 'tracked', '--out', out, ]); - expect(setTypeResult.code).toBe(1); - const envelope = parseJsonOutput(setTypeResult); + expect(detachResult.code).toBe(1); + const envelope = parseJsonOutput(detachResult); expect(envelope.error.code).toBe('TRACK_CHANGE_COMMAND_UNAVAILABLE'); }); diff --git a/apps/cli/src/__tests__/conformance/harness.ts b/apps/cli/src/__tests__/conformance/harness.ts index af4b92aa8d..840bfe63be 100644 --- a/apps/cli/src/__tests__/conformance/harness.ts +++ b/apps/cli/src/__tests__/conformance/harness.ts @@ -2,7 +2,12 @@ import { copyFile, mkdtemp, mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { run } from '../../index'; -import { resolveListDocFixture, resolveSourceDocFixture, resolveTocDocFixture } from '../fixtures'; +import { + resolveListDocFixture, + resolvePreSeparatedListFixture, + resolveSourceDocFixture, + resolveTocDocFixture, +} from '../fixtures'; type RunResult = { code: number; @@ -125,6 +130,12 @@ export class ConformanceHarness { return filePath; } + async copyPreSeparatedListDoc(label: string): Promise { + const filePath = path.join(this.docsDir, `${this.nextId()}-${label}.docx`); + await copyFile(await resolvePreSeparatedListFixture(), filePath); + return filePath; + } + async copyTocFixtureDoc(label: string, stateDir: string): Promise { const filePath = path.join(this.docsDir, `${this.nextId()}-${label}.docx`); @@ -164,13 +175,11 @@ export class ConformanceHarness { stateDir: string, stdinBytes?: Uint8Array, ): Promise<{ result: RunResult; envelope: CommandEnvelope }> { - const previousStateDir = process.env.SUPERDOC_CLI_STATE_DIR; - process.env.SUPERDOC_CLI_STATE_DIR = stateDir; - let stdout = ''; let stderr = ''; - try { - const code = await run(args, { + const code = await run( + args, + { stdout(message: string) { stdout += message; }, @@ -180,17 +189,12 @@ export class ConformanceHarness { async readStdinBytes() { return stdinBytes ?? new Uint8Array(); }, - }); - - const result: RunResult = { code, stdout, stderr }; - return { result, envelope: parseEnvelope(result) }; - } finally { - if (previousStateDir == null) { - delete process.env.SUPERDOC_CLI_STATE_DIR; - } else { - process.env.SUPERDOC_CLI_STATE_DIR = previousStateDir; - } - } + }, + { stateDir }, + ); + + const result: RunResult = { code, stdout, stderr }; + return { result, envelope: parseEnvelope(result) }; } async firstTextRange(docPath: string, stateDir: string, pattern = 'Wilde'): Promise { diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 70dd70da2a..bce69c75f4 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -116,6 +116,76 @@ async function createDocWithSecondSection( return { docPath: withBreakDoc, first, second }; } +type ListDiscoveryItem = { + address?: Record; +}; + +async function listDiscoveryItems( + harness: ConformanceHarness, + stateDir: string, + docPath: string, + limit: number, +): Promise { + const listed = await harness.runCli(['lists', 'list', docPath, '--limit', String(limit)], stateDir); + if (listed.result.code !== 0 || listed.envelope.ok !== true) { + throw new Error(`Failed to list list items for ${docPath}.`); + } + + const items = ((listed.envelope.data as { result?: { items?: ListDiscoveryItem[] } }).result?.items ?? []).filter( + (item) => !!item, + ); + return items; +} + +async function nthListAddress( + harness: ConformanceHarness, + stateDir: string, + docPath: string, + index: number, +): Promise> { + const items = await listDiscoveryItems(harness, stateDir, docPath, Math.max(index + 1, 2)); + const address = items[index]?.address; + if (!address || typeof address !== 'object') { + throw new Error(`Missing list address at index ${index} for ${docPath}.`); + } + return address; +} + +type ListTargetPreparation = { + docPath: string; + target: Record; +}; + +/** + * Load a pre-separated list fixture (two adjacent lists that share the same + * abstractNumId) and resolve the second list item as the target. + * + * This avoids a runtime `lists separate` → DOCX export → re-import round-trip + * which can lose numbering definition compatibility on some platforms. + */ +async function prepareSeparatedSecondListTarget( + harness: ConformanceHarness, + stateDir: string, + label: string, +): Promise { + const docPath = await harness.copyPreSeparatedListDoc(label); + const items = await listDiscoveryItems(harness, stateDir, docPath, 10); + + if (items.length < 2) { + throw new Error( + `[${label}] Pre-separated fixture has fewer than 2 list items (found ${items.length}). ` + + `Items: ${JSON.stringify(items)}`, + ); + } + + const target = items[1]?.address; + if (!target || typeof target !== 'object') { + throw new Error(`[${label}] Second list item has no address. Items: ${JSON.stringify(items)}`); + } + + return { docPath, target }; +} + function sectionMutationScenario( operationId: CliOperationId, label: string, @@ -1068,32 +1138,75 @@ export const SUCCESS_SCENARIOS = { ], }; }, - 'doc.lists.setType': async (harness: ConformanceHarness): Promise => { - const stateDir = await harness.createStateDir('doc-lists-set-type-success'); - const docPath = await harness.copyListFixtureDoc('doc-lists-set-type'); + 'doc.lists.create': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-create-success'); + const docPath = await harness.copyFixtureDoc('doc-lists-create'); + const at = await harness.firstBlockMatch(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'create', + docPath, + '--input-json', + JSON.stringify({ + mode: 'empty', + at: { kind: 'block', nodeType: at.nodeType, nodeId: at.nodeId }, + kind: 'ordered', + }), + '--out', + harness.createOutputPath('doc-lists-create-output'), + ], + }; + }, + 'doc.lists.detach': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-detach-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-detach'); const target = await harness.firstListItemAddress(docPath, stateDir); - const getResult = await harness.runCli( - ['lists', 'get', docPath, '--address-json', JSON.stringify(target)], + return { stateDir, - ); - if (getResult.result.code !== 0 || getResult.envelope.ok !== true) { - throw new Error('Failed to resolve list item kind for set-type conformance scenario.'); - } - const currentKind = (getResult.envelope.data as { item?: { kind?: string } }).item?.kind; - const requestedKind = currentKind === 'ordered' ? 'bullet' : 'ordered'; - + args: [ + 'lists', + 'detach', + docPath, + '--target-json', + JSON.stringify(target), + '--out', + harness.createOutputPath('doc-lists-detach-output'), + ], + }; + }, + 'doc.lists.setLevel': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level', + docPath, + '--input-json', + JSON.stringify({ target, level: 1 }), + '--out', + harness.createOutputPath('doc-lists-set-level-output'), + ], + }; + }, + 'doc.lists.convertToText': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-convert-to-text-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-convert-to-text'); + const target = await harness.firstListItemAddress(docPath, stateDir); return { stateDir, args: [ 'lists', - 'set-type', + 'convert-to-text', docPath, '--target-json', JSON.stringify(target), - '--kind', - requestedKind, '--out', - harness.createOutputPath('doc-lists-set-type-output'), + harness.createOutputPath('doc-lists-convert-to-text-output'), ], }; }, @@ -1140,51 +1253,150 @@ export const SUCCESS_SCENARIOS = { ], }; }, - 'doc.lists.restart': async (harness: ConformanceHarness): Promise => { - const stateDir = await harness.createStateDir('doc-lists-restart-success'); - const docPath = await harness.copyListFixtureDoc('doc-lists-restart'); - const listed = await harness.runCli(['lists', 'list', docPath, '--limit', '50'], stateDir); - if (listed.result.code !== 0 || listed.envelope.ok !== true) { - throw new Error('Failed to list list items for restart conformance scenario.'); - } - const restartTarget = ( - ( - listed.envelope.data as { - result?: { items?: Array<{ ordinal?: number; address?: Record }> }; - } - ).result?.items ?? [] - ).find((item) => typeof item.ordinal === 'number' && item.ordinal > 1)?.address; - if (!restartTarget) { - throw new Error('Restart conformance scenario requires a list item with ordinal > 1.'); - } - + 'doc.lists.setValue': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-value-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-value'); + const target = await harness.firstListItemAddress(docPath, stateDir); return { stateDir, args: [ 'lists', - 'restart', + 'set-value', docPath, + '--input-json', + JSON.stringify({ target, value: 5 }), + '--out', + harness.createOutputPath('doc-lists-set-value-output'), + ], + }; + }, + 'doc.lists.continuePrevious': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-continue-previous-success'); + const prepared = await prepareSeparatedSecondListTarget(harness, stateDir, 'doc-lists-continue-previous'); + + return { + stateDir, + args: [ + 'lists', + 'continue-previous', + prepared.docPath, '--target-json', - JSON.stringify(restartTarget), + JSON.stringify(prepared.target), '--out', - harness.createOutputPath('doc-lists-restart-output'), + harness.createOutputPath('doc-lists-continue-previous-output'), ], }; }, - 'doc.lists.exit': async (harness: ConformanceHarness): Promise => { - const stateDir = await harness.createStateDir('doc-lists-exit-success'); - const docPath = await harness.copyListFixtureDoc('doc-lists-exit'); + 'doc.lists.canJoin': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-can-join-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-can-join'); const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: ['lists', 'can-join', docPath, '--input-json', JSON.stringify({ target, direction: 'withNext' })], + }; + }, + 'doc.lists.canContinuePrevious': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-can-continue-previous-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-can-continue-previous'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: ['lists', 'can-continue-previous', docPath, '--target-json', JSON.stringify(target)], + }; + }, + 'doc.lists.attach': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-attach-success'); + const docPath = await harness.copyFixtureDoc('doc-lists-attach'); + const listSeedTarget = await harness.firstBlockMatch(docPath, stateDir); + const seededDoc = harness.createOutputPath('doc-lists-attach-seeded'); + const create = await harness.runCli( + [ + 'lists', + 'create', + docPath, + '--input-json', + JSON.stringify({ + mode: 'empty', + at: { kind: 'block', nodeType: listSeedTarget.nodeType, nodeId: listSeedTarget.nodeId }, + kind: 'ordered', + }), + '--out', + seededDoc, + ], + stateDir, + ); + if (create.result.code !== 0) { + throw new Error('Failed to prepare attach conformance fixture via lists create.'); + } + + const attachTo = await harness.firstListItemAddress(seededDoc, stateDir); + const target = await harness.firstBlockMatch(seededDoc, stateDir); + return { stateDir, args: [ 'lists', - 'exit', + 'attach', + seededDoc, + '--input-json', + JSON.stringify({ + target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId }, + attachTo, + }), + '--out', + harness.createOutputPath('doc-lists-attach-output'), + ], + }; + }, + 'doc.lists.join': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-join-success'); + const prepared = await prepareSeparatedSecondListTarget(harness, stateDir, 'doc-lists-join'); + + return { + stateDir, + args: [ + 'lists', + 'join', + prepared.docPath, + '--input-json', + JSON.stringify({ target: prepared.target, direction: 'withPrevious' }), + '--out', + harness.createOutputPath('doc-lists-join-output'), + ], + }; + }, + 'doc.lists.separate': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-separate-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-separate'); + const target = await nthListAddress(harness, stateDir, docPath, 1); + return { + stateDir, + args: [ + 'lists', + 'separate', docPath, '--target-json', JSON.stringify(target), '--out', - harness.createOutputPath('doc-lists-exit-output'), + harness.createOutputPath('doc-lists-separate-output'), + ], + }; + }, + 'doc.lists.setLevelRestart': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-lists-set-level-restart-success'); + const docPath = await harness.copyListFixtureDoc('doc-lists-set-level-restart'); + const target = await harness.firstListItemAddress(docPath, stateDir); + return { + stateDir, + args: [ + 'lists', + 'set-level-restart', + docPath, + '--input-json', + JSON.stringify({ target, level: 1, restartAfterLevel: 0 }), + '--out', + harness.createOutputPath('doc-lists-set-level-restart-output'), ], }; }, @@ -1719,6 +1931,7 @@ export const SUCCESS_SCENARIOS = { } as const satisfies Record Promise>; const RUNTIME_CONFORMANCE_SKIP = new Set([ + 'doc.toc.markEntry', 'doc.toc.unmarkEntry', 'doc.toc.getEntry', 'doc.toc.editEntry', diff --git a/apps/cli/src/__tests__/contract-response-conformance.test.ts b/apps/cli/src/__tests__/contract-response-conformance.test.ts index 8e80e0ad07..65b3220984 100644 --- a/apps/cli/src/__tests__/contract-response-conformance.test.ts +++ b/apps/cli/src/__tests__/contract-response-conformance.test.ts @@ -31,6 +31,19 @@ describe('contract response conformance', () => { const invocation = await scenario.success(harness); const { result, envelope } = await harness.runCli(invocation.args, invocation.stateDir, invocation.stdinBytes); + if (result.code !== 0 || envelope.ok !== true) { + const details = JSON.stringify(envelope, null, 2); + throw new Error( + [ + `Expected success envelope for ${scenario.operationId}.`, + `Exit code: ${result.code}`, + `Envelope: ${details}`, + `STDOUT: ${result.stdout.trim() || ''}`, + `STDERR: ${result.stderr.trim() || ''}`, + ].join('\n'), + ); + } + expect(result.code).toBe(0); expect(envelope.ok).toBe(true); diff --git a/apps/cli/src/__tests__/fixtures.ts b/apps/cli/src/__tests__/fixtures.ts index b807e17e03..a10fb5e40e 100644 --- a/apps/cli/src/__tests__/fixtures.ts +++ b/apps/cli/src/__tests__/fixtures.ts @@ -15,6 +15,10 @@ const LIST_SOURCE_DOC_CANDIDATES = [ path.join(REPO_ROOT, 'e2e-tests/test-data/basic-documents/lists-complex-items.docx'), ]; +const PRE_SEPARATED_LIST_CANDIDATES = [ + path.join(REPO_ROOT, 'packages/super-editor/src/tests/data/pre-separated-list.docx'), +]; + const TOC_SOURCE_DOC_CANDIDATES = [ path.join(REPO_ROOT, 'test-corpus/basic/table-of-contents.docx'), path.join(REPO_ROOT, 'test-corpus/basic/table-of-contents-sdt.docx'), @@ -23,6 +27,7 @@ const TOC_SOURCE_DOC_CANDIDATES = [ let resolvedSourceDoc: string | null = null; let resolvedListSourceDoc: string | null = null; +let resolvedPreSeparatedListDoc: string | null = null; let resolvedTocSourceDoc: string | null = null; async function resolveFixture(candidates: string[], fixtureLabel: string): Promise { @@ -50,6 +55,12 @@ export async function resolveListDocFixture(): Promise { return resolvedListSourceDoc; } +export async function resolvePreSeparatedListFixture(): Promise { + if (resolvedPreSeparatedListDoc != null) return resolvedPreSeparatedListDoc; + resolvedPreSeparatedListDoc = await resolveFixture(PRE_SEPARATED_LIST_CANDIDATES, 'pre-separated list'); + return resolvedPreSeparatedListDoc; +} + export async function resolveTocDocFixture(): Promise { if (resolvedTocSourceDoc != null) return resolvedTocSourceDoc; resolvedTocSourceDoc = await resolveFixture(TOC_SOURCE_DOC_CANDIDATES, 'table-of-contents'); diff --git a/apps/cli/src/cli/helper-commands.ts b/apps/cli/src/cli/helper-commands.ts index a3b21ac399..e089a31c00 100644 --- a/apps/cli/src/cli/helper-commands.ts +++ b/apps/cli/src/cli/helper-commands.ts @@ -47,6 +47,16 @@ function mapIdToTarget(input: Record): Record * Helper commands for compatibility/ergonomics where no direct canonical key exists. */ export const CLI_HELPER_COMMANDS: readonly CliHelperCommand[] = [ + // --- Legacy list alias --- + { + tokens: ['lists', 'exit'], + canonicalOperationId: 'lists.detach', + defaultInput: {}, + description: 'Alias for lists detach.', + category: 'lists', + mutates: true, + examples: ['superdoc lists exit --target-json \'{"kind":"block","nodeType":"listItem","nodeId":"p1"}\''], + }, // --- Format helper --- { tokens: ['format', 'strikethrough'], diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index f023b73cc1..af4160e54a 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -90,11 +90,20 @@ export const SUCCESS_VERB: Record = { 'lists.list': 'listed items', 'lists.get': 'resolved list item', 'lists.insert': 'inserted list item', - 'lists.setType': 'set list type', 'lists.indent': 'indented list item', 'lists.outdent': 'outdented list item', - 'lists.restart': 'restarted list numbering', - 'lists.exit': 'exited list item', + 'lists.create': 'created list', + 'lists.attach': 'attached to list', + 'lists.detach': 'detached from list', + 'lists.join': 'joined lists', + 'lists.canJoin': 'checked join feasibility', + 'lists.separate': 'separated list', + 'lists.setLevel': 'set list level', + 'lists.setValue': 'set list value', + 'lists.continuePrevious': 'continued previous list', + 'lists.canContinuePrevious': 'checked continue feasibility', + 'lists.setLevelRestart': 'set level restart', + 'lists.convertToText': 'converted list to text', 'comments.create': 'created comment', 'comments.patch': 'patched comment', 'comments.delete': 'deleted comment', @@ -211,11 +220,20 @@ export const OUTPUT_FORMAT: Record = { 'lists.list': 'listResult', 'lists.get': 'listItemInfo', 'lists.insert': 'listsMutationResult', - 'lists.setType': 'listsMutationResult', 'lists.indent': 'listsMutationResult', 'lists.outdent': 'listsMutationResult', - 'lists.restart': 'listsMutationResult', - 'lists.exit': 'listsMutationResult', + 'lists.create': 'listsMutationResult', + 'lists.attach': 'listsMutationResult', + 'lists.detach': 'listsMutationResult', + 'lists.join': 'listsMutationResult', + 'lists.canJoin': 'plain', + 'lists.separate': 'listsMutationResult', + 'lists.setLevel': 'listsMutationResult', + 'lists.setValue': 'listsMutationResult', + 'lists.continuePrevious': 'listsMutationResult', + 'lists.canContinuePrevious': 'plain', + 'lists.setLevelRestart': 'listsMutationResult', + 'lists.convertToText': 'listsMutationResult', 'comments.create': 'commentReceipt', 'comments.patch': 'commentReceipt', 'comments.delete': 'commentReceipt', @@ -316,11 +334,20 @@ export const RESPONSE_ENVELOPE_KEY: Record 'lists.list': 'result', 'lists.get': 'item', 'lists.insert': 'result', - 'lists.setType': 'result', 'lists.indent': 'result', 'lists.outdent': 'result', - 'lists.restart': 'result', - 'lists.exit': 'result', + 'lists.create': 'result', + 'lists.attach': 'result', + 'lists.detach': 'result', + 'lists.join': 'result', + 'lists.canJoin': 'result', + 'lists.separate': 'result', + 'lists.setLevel': 'result', + 'lists.setValue': 'result', + 'lists.continuePrevious': 'result', + 'lists.canContinuePrevious': 'result', + 'lists.setLevelRestart': 'result', + 'lists.convertToText': 'result', 'comments.create': 'receipt', 'comments.patch': 'receipt', 'comments.delete': 'receipt', @@ -449,11 +476,20 @@ export const OPERATION_FAMILY: Record = 'lists.list': 'lists', 'lists.get': 'lists', 'lists.insert': 'lists', - 'lists.setType': 'lists', 'lists.indent': 'lists', 'lists.outdent': 'lists', - 'lists.restart': 'lists', - 'lists.exit': 'lists', + 'lists.create': 'lists', + 'lists.attach': 'lists', + 'lists.detach': 'lists', + 'lists.join': 'lists', + 'lists.canJoin': 'lists', + 'lists.separate': 'lists', + 'lists.setLevel': 'lists', + 'lists.setValue': 'lists', + 'lists.continuePrevious': 'lists', + 'lists.canContinuePrevious': 'lists', + 'lists.setLevelRestart': 'lists', + 'lists.convertToText': 'lists', 'comments.create': 'comments', 'comments.patch': 'comments', 'comments.delete': 'comments', diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index cdd7327fe4..023f3797dc 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -373,23 +373,47 @@ const EXTRA_CLI_PARAMS: Partial> = { { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS, ], - 'doc.lists.setType': [ + 'doc.lists.indent': [ { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS, ], - 'doc.lists.indent': [ + 'doc.lists.outdent': [ { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS, ], - 'doc.lists.outdent': [ + 'doc.lists.create': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.attach': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.detach': [ + { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, + ...LIST_TARGET_FLAT_PARAMS, + ], + 'doc.lists.join': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.canJoin': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.separate': [ + { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, + ...LIST_TARGET_FLAT_PARAMS, + ], + 'doc.lists.setLevel': [ + { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, + ...LIST_TARGET_FLAT_PARAMS, + ], + 'doc.lists.setValue': [ + { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, + ...LIST_TARGET_FLAT_PARAMS, + ], + 'doc.lists.continuePrevious': [ + { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, + ...LIST_TARGET_FLAT_PARAMS, + ], + 'doc.lists.canContinuePrevious': [ { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS, ], - 'doc.lists.restart': [ + 'doc.lists.setLevelRestart': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], + 'doc.lists.convertToText': [ { name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS, ], - 'doc.lists.exit': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS], 'doc.blocks.delete': [ { name: 'nodeType', kind: 'flag', flag: 'node-type', type: 'string' }, { name: 'nodeId', kind: 'flag', flag: 'node-id', type: 'string' }, diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 17062f800d..4a071a711d 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -19,6 +19,7 @@ import { MANUAL_COMMAND_ALLOWLIST, type ManualCommandKey } from './lib/manual-co import { validateOperationResponseData } from './lib/operation-args'; import { runInstall } from './commands/install'; import { runUninstall } from './commands/uninstall'; +import { withStateDirOverride } from './lib/context'; import { CLI_COMMAND_SPECS, CLI_COMMAND_KEYS, @@ -60,6 +61,7 @@ export type InvokeCommandOptions = { ioOverrides?: Partial; executionMode?: ExecutionMode; collabSessionPool?: CommandContext['collabSessionPool']; + stateDir?: string; }; const MANUAL_COMMANDS = { @@ -262,13 +264,16 @@ async function executeParsedInvocation( export async function invokeCommand(argv: string[], options: InvokeCommandOptions = {}): Promise { const io = mergeIo(options.ioOverrides); const startedAt = io.now(); - const parsed = parseInvocation(argv); - const output = await executeParsedInvocation( - parsed, - io, - options.executionMode ?? 'oneshot', - options.collabSessionPool, - ); + const { parsed, output } = await withStateDirOverride(options.stateDir, async () => { + const parsedInvocation = parseInvocation(argv); + const commandOutput = await executeParsedInvocation( + parsedInvocation, + io, + options.executionMode ?? 'oneshot', + options.collabSessionPool, + ); + return { parsed: parsedInvocation, output: commandOutput }; + }); return { globals: parsed.globals, @@ -289,60 +294,67 @@ async function runHostCommand(tokens: string[], io: CliIO): Promise { * * @param argv - Raw process arguments (after stripping the binary path) * @param ioOverrides - Optional overrides for stdout, stderr, stdin, and clock + * @param options - Optional runtime overrides such as test-scoped state directory * @returns Process exit code (0 on success, non-zero on error) */ -export async function run(argv: string[], ioOverrides?: Partial): Promise { +export async function run( + argv: string[], + ioOverrides?: Partial, + options: Pick = {}, +): Promise { const io = mergeIo(ioOverrides); const startedAt = io.now(); let outputMode: OutputMode = 'json'; - try { - const parsed = parseInvocation(argv); - outputMode = parsed.globals.output; + return withStateDirOverride(options.stateDir, async () => { + try { + const parsed = parseInvocation(argv); + outputMode = parsed.globals.output; - if (parsed.rest[0] === 'host') { - const hostTokens = parsed.rest.slice(1); - if (parsed.globals.help) hostTokens.push('--help'); - return await runHostCommand(hostTokens, io); - } + if (parsed.rest[0] === 'host') { + const hostTokens = parsed.rest.slice(1); + if (parsed.globals.help) hostTokens.push('--help'); + return await runHostCommand(hostTokens, io); + } - if (parsed.rest[0] === 'install' && !parsed.globals.help) { - return await runInstall(parsed.rest.slice(1), io); - } + if (parsed.rest[0] === 'install' && !parsed.globals.help) { + return await runInstall(parsed.rest.slice(1), io); + } - if (parsed.rest[0] === 'uninstall' && !parsed.globals.help) { - return await runUninstall(parsed.rest.slice(1), io); - } + if (parsed.rest[0] === 'uninstall' && !parsed.globals.help) { + return await runUninstall(parsed.rest.slice(1), io); + } - if (parsed.rest[0] === 'call' && outputMode !== 'json') { - throw new CliError('INVALID_ARGUMENT', 'call: only --output json is supported.'); - } + if (parsed.rest[0] === 'call' && outputMode !== 'json') { + throw new CliError('INVALID_ARGUMENT', 'call: only --output json is supported.'); + } - if (!parsed.globals.help) { - const legacyCompat = await tryRunLegacyCompatCommand(argv, parsed.rest, io); - if (legacyCompat.handled) { - return legacyCompat.exitCode; + if (!parsed.globals.help) { + const legacyCompat = await tryRunLegacyCompatCommand(argv, parsed.rest, io); + if (legacyCompat.handled) { + return legacyCompat.exitCode; + } + } + + const output = await executeParsedInvocation(parsed, io, 'oneshot'); + if (output.helpText) { + io.stdout(output.helpText); + return 0; + } + if (!output.execution) { + throw new CliError('COMMAND_FAILED', 'Command produced no execution result and no help text.'); } - } - const output = await executeParsedInvocation(parsed, io, 'oneshot'); - if (output.helpText) { - io.stdout(output.helpText); + const elapsedMs = io.now() - startedAt; + writeSuccess(io, outputMode, output.execution, elapsedMs); return 0; + } catch (error) { + const cliError = toCliError(error); + const elapsedMs = io.now() - startedAt; + writeFailure(io, outputMode, cliError, elapsedMs); + return cliError.exitCode; } - if (!output.execution) { - throw new CliError('COMMAND_FAILED', 'Command produced no execution result and no help text.'); - } - - const elapsedMs = io.now() - startedAt; - writeSuccess(io, outputMode, output.execution, elapsedMs); - return 0; - } catch (error) { - const cliError = toCliError(error); - const elapsedMs = io.now() - startedAt; - writeFailure(io, outputMode, cliError, elapsedMs); - return cliError.exitCode; - } + }); } if (import.meta.main) { diff --git a/apps/cli/src/lib/context.ts b/apps/cli/src/lib/context.ts index 26aec64011..c0b916bd7d 100644 --- a/apps/cli/src/lib/context.ts +++ b/apps/cli/src/lib/context.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import type { Dirent } from 'node:fs'; import { copyFile, mkdir, open, readdir, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; @@ -13,6 +14,7 @@ const CONTEXT_VERSION = 'v1'; const ACTIVE_SESSION_FILENAME = 'active-session'; const DEFAULT_LOCK_TIMEOUT_MS = 5_000; const LOCK_RETRY_INTERVAL_MS = 50; +const STATE_DIR_OVERRIDE_STORAGE = new AsyncLocalStorage(); export type SourceSnapshot = { mtimeMs: number; @@ -75,6 +77,11 @@ type LockMetadata = { }; function getStateRoot(): string { + const scopedOverride = STATE_DIR_OVERRIDE_STORAGE.getStore(); + if (scopedOverride && scopedOverride.length > 0) { + return scopedOverride; + } + const override = process.env.SUPERDOC_CLI_STATE_DIR; if (override && override.length > 0) { return resolve(override); @@ -83,6 +90,14 @@ function getStateRoot(): string { return join(homedir(), '.superdoc-cli', 'state', CONTEXT_VERSION); } +export async function withStateDirOverride(stateDir: string | undefined, operation: () => Promise): Promise { + if (stateDir == null || stateDir.length === 0) { + return operation(); + } + + return STATE_DIR_OVERRIDE_STORAGE.run(resolve(stateDir), operation); +} + export function getContextPaths(contextId: string): ContextPaths { const normalizedContextId = validateSessionId(contextId, 'session id'); const stateRoot = getStateRoot(); diff --git a/apps/cli/src/lib/invoke-input.ts b/apps/cli/src/lib/invoke-input.ts index d984df8c9e..c919a5ad18 100644 --- a/apps/cli/src/lib/invoke-input.ts +++ b/apps/cli/src/lib/invoke-input.ts @@ -26,11 +26,20 @@ const WRAPPED_INPUT_KEY: Partial> = { getNode: 'address', 'lists.list': 'query', 'lists.insert': 'input', - 'lists.setType': 'input', 'lists.indent': 'input', 'lists.outdent': 'input', - 'lists.restart': 'input', - 'lists.exit': 'input', + 'lists.create': 'input', + 'lists.attach': 'input', + 'lists.detach': 'input', + 'lists.join': 'input', + 'lists.canJoin': 'input', + 'lists.separate': 'input', + 'lists.setLevel': 'input', + 'lists.setValue': 'input', + 'lists.continuePrevious': 'input', + 'lists.canContinuePrevious': 'input', + 'lists.setLevelRestart': 'input', + 'lists.convertToText': 'input', 'create.paragraph': 'input', 'create.heading': 'input', }; @@ -94,11 +103,15 @@ const INSERT_OPERATION: CliExposedOperationId = 'insert'; */ const LIST_TARGET_OPERATIONS = new Set([ 'lists.insert', - 'lists.setType', 'lists.indent', 'lists.outdent', - 'lists.restart', - 'lists.exit', + 'lists.detach', + 'lists.separate', + 'lists.setLevel', + 'lists.setValue', + 'lists.continuePrevious', + 'lists.canContinuePrevious', + 'lists.convertToText', ]); function isRecord(value: unknown): value is Record { diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index c90a8a8a9d..2340c9180f 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -21,7 +21,7 @@ Use the tables below to see what operations are available and where each one is | Create | 5 | 0 | 5 | [Reference](/document-api/reference/create/index) | | Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | -| Lists | 8 | 0 | 8 | [Reference](/document-api/reference/lists/index) | +| Lists | 17 | 0 | 17 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | | Paragraph Formatting | 17 | 0 | 17 | [Reference](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Reference](/document-api/reference/styles/paragraph/index) | @@ -105,11 +105,20 @@ Use the tables below to see what operations are available and where each one is | editor.doc.lists.list(...) | [`lists.list`](/document-api/reference/lists/list) | | editor.doc.lists.get(...) | [`lists.get`](/document-api/reference/lists/get) | | editor.doc.lists.insert(...) | [`lists.insert`](/document-api/reference/lists/insert) | -| editor.doc.lists.setType(...) | [`lists.setType`](/document-api/reference/lists/set-type) | +| editor.doc.lists.create(...) | [`lists.create`](/document-api/reference/lists/create) | +| editor.doc.lists.attach(...) | [`lists.attach`](/document-api/reference/lists/attach) | +| editor.doc.lists.detach(...) | [`lists.detach`](/document-api/reference/lists/detach) | | editor.doc.lists.indent(...) | [`lists.indent`](/document-api/reference/lists/indent) | | editor.doc.lists.outdent(...) | [`lists.outdent`](/document-api/reference/lists/outdent) | -| editor.doc.lists.restart(...) | [`lists.restart`](/document-api/reference/lists/restart) | -| editor.doc.lists.exit(...) | [`lists.exit`](/document-api/reference/lists/exit) | +| editor.doc.lists.join(...) | [`lists.join`](/document-api/reference/lists/join) | +| editor.doc.lists.canJoin(...) | [`lists.canJoin`](/document-api/reference/lists/can-join) | +| editor.doc.lists.separate(...) | [`lists.separate`](/document-api/reference/lists/separate) | +| editor.doc.lists.setLevel(...) | [`lists.setLevel`](/document-api/reference/lists/set-level) | +| editor.doc.lists.setValue(...) | [`lists.setValue`](/document-api/reference/lists/set-value) | +| editor.doc.lists.continuePrevious(...) | [`lists.continuePrevious`](/document-api/reference/lists/continue-previous) | +| editor.doc.lists.canContinuePrevious(...) | [`lists.canContinuePrevious`](/document-api/reference/lists/can-continue-previous) | +| editor.doc.lists.setLevelRestart(...) | [`lists.setLevelRestart`](/document-api/reference/lists/set-level-restart) | +| editor.doc.lists.convertToText(...) | [`lists.convertToText`](/document-api/reference/lists/convert-to-text) | | editor.doc.mutations.preview(...) | [`mutations.preview`](/document-api/reference/mutations/preview) | | editor.doc.mutations.apply(...) | [`mutations.apply`](/document-api/reference/mutations/apply) | | editor.doc.format.paragraph.resetDirectFormatting(...) | [`format.paragraph.resetDirectFormatting`](/document-api/reference/format/paragraph/reset-direct-formatting) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index df8dea2555..be3a7835ee 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -93,15 +93,24 @@ "apps/docs/document-api/reference/index.mdx", "apps/docs/document-api/reference/info.mdx", "apps/docs/document-api/reference/insert.mdx", - "apps/docs/document-api/reference/lists/exit.mdx", + "apps/docs/document-api/reference/lists/attach.mdx", + "apps/docs/document-api/reference/lists/can-continue-previous.mdx", + "apps/docs/document-api/reference/lists/can-join.mdx", + "apps/docs/document-api/reference/lists/continue-previous.mdx", + "apps/docs/document-api/reference/lists/convert-to-text.mdx", + "apps/docs/document-api/reference/lists/create.mdx", + "apps/docs/document-api/reference/lists/detach.mdx", "apps/docs/document-api/reference/lists/get.mdx", "apps/docs/document-api/reference/lists/indent.mdx", "apps/docs/document-api/reference/lists/index.mdx", "apps/docs/document-api/reference/lists/insert.mdx", + "apps/docs/document-api/reference/lists/join.mdx", "apps/docs/document-api/reference/lists/list.mdx", "apps/docs/document-api/reference/lists/outdent.mdx", - "apps/docs/document-api/reference/lists/restart.mdx", - "apps/docs/document-api/reference/lists/set-type.mdx", + "apps/docs/document-api/reference/lists/separate.mdx", + "apps/docs/document-api/reference/lists/set-level-restart.mdx", + "apps/docs/document-api/reference/lists/set-level.mdx", + "apps/docs/document-api/reference/lists/set-value.mdx", "apps/docs/document-api/reference/mutations/apply.mdx", "apps/docs/document-api/reference/mutations/index.mdx", "apps/docs/document-api/reference/mutations/preview.mdx", @@ -316,11 +325,20 @@ "lists.list", "lists.get", "lists.insert", - "lists.setType", + "lists.create", + "lists.attach", + "lists.detach", "lists.indent", "lists.outdent", - "lists.restart", - "lists.exit" + "lists.join", + "lists.canJoin", + "lists.separate", + "lists.setLevel", + "lists.setValue", + "lists.continuePrevious", + "lists.canContinuePrevious", + "lists.setLevelRestart", + "lists.convertToText" ], "pagePath": "apps/docs/document-api/reference/lists/index.mdx", "title": "Lists" @@ -459,5 +477,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "7f83c34ee8c4c0f0cf87345069ee739a8c35aedf4769728c8354f14cb465e4ca" + "sourceHash": "f7276958e365ad87346f051a9dc2d6b1a6313353302531b6f669409cf97b3dcc" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 101b4636f3..f2b1e39f2d 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -687,11 +687,41 @@ _No fields._ | `operations.insert.dryRun` | boolean | yes | | | `operations.insert.reasons` | enum[] | no | | | `operations.insert.tracked` | boolean | yes | | -| `operations.lists.exit` | object | yes | | -| `operations.lists.exit.available` | boolean | yes | | -| `operations.lists.exit.dryRun` | boolean | yes | | -| `operations.lists.exit.reasons` | enum[] | no | | -| `operations.lists.exit.tracked` | boolean | yes | | +| `operations.lists.attach` | object | yes | | +| `operations.lists.attach.available` | boolean | yes | | +| `operations.lists.attach.dryRun` | boolean | yes | | +| `operations.lists.attach.reasons` | enum[] | no | | +| `operations.lists.attach.tracked` | boolean | yes | | +| `operations.lists.canContinuePrevious` | object | yes | | +| `operations.lists.canContinuePrevious.available` | boolean | yes | | +| `operations.lists.canContinuePrevious.dryRun` | boolean | yes | | +| `operations.lists.canContinuePrevious.reasons` | enum[] | no | | +| `operations.lists.canContinuePrevious.tracked` | boolean | yes | | +| `operations.lists.canJoin` | object | yes | | +| `operations.lists.canJoin.available` | boolean | yes | | +| `operations.lists.canJoin.dryRun` | boolean | yes | | +| `operations.lists.canJoin.reasons` | enum[] | no | | +| `operations.lists.canJoin.tracked` | boolean | yes | | +| `operations.lists.continuePrevious` | object | yes | | +| `operations.lists.continuePrevious.available` | boolean | yes | | +| `operations.lists.continuePrevious.dryRun` | boolean | yes | | +| `operations.lists.continuePrevious.reasons` | enum[] | no | | +| `operations.lists.continuePrevious.tracked` | boolean | yes | | +| `operations.lists.convertToText` | object | yes | | +| `operations.lists.convertToText.available` | boolean | yes | | +| `operations.lists.convertToText.dryRun` | boolean | yes | | +| `operations.lists.convertToText.reasons` | enum[] | no | | +| `operations.lists.convertToText.tracked` | boolean | yes | | +| `operations.lists.create` | object | yes | | +| `operations.lists.create.available` | boolean | yes | | +| `operations.lists.create.dryRun` | boolean | yes | | +| `operations.lists.create.reasons` | enum[] | no | | +| `operations.lists.create.tracked` | boolean | yes | | +| `operations.lists.detach` | object | yes | | +| `operations.lists.detach.available` | boolean | yes | | +| `operations.lists.detach.dryRun` | boolean | yes | | +| `operations.lists.detach.reasons` | enum[] | no | | +| `operations.lists.detach.tracked` | boolean | yes | | | `operations.lists.get` | object | yes | | | `operations.lists.get.available` | boolean | yes | | | `operations.lists.get.dryRun` | boolean | yes | | @@ -707,6 +737,11 @@ _No fields._ | `operations.lists.insert.dryRun` | boolean | yes | | | `operations.lists.insert.reasons` | enum[] | no | | | `operations.lists.insert.tracked` | boolean | yes | | +| `operations.lists.join` | object | yes | | +| `operations.lists.join.available` | boolean | yes | | +| `operations.lists.join.dryRun` | boolean | yes | | +| `operations.lists.join.reasons` | enum[] | no | | +| `operations.lists.join.tracked` | boolean | yes | | | `operations.lists.list` | object | yes | | | `operations.lists.list.available` | boolean | yes | | | `operations.lists.list.dryRun` | boolean | yes | | @@ -717,16 +752,26 @@ _No fields._ | `operations.lists.outdent.dryRun` | boolean | yes | | | `operations.lists.outdent.reasons` | enum[] | no | | | `operations.lists.outdent.tracked` | boolean | yes | | -| `operations.lists.restart` | object | yes | | -| `operations.lists.restart.available` | boolean | yes | | -| `operations.lists.restart.dryRun` | boolean | yes | | -| `operations.lists.restart.reasons` | enum[] | no | | -| `operations.lists.restart.tracked` | boolean | yes | | -| `operations.lists.setType` | object | yes | | -| `operations.lists.setType.available` | boolean | yes | | -| `operations.lists.setType.dryRun` | boolean | yes | | -| `operations.lists.setType.reasons` | enum[] | no | | -| `operations.lists.setType.tracked` | boolean | yes | | +| `operations.lists.separate` | object | yes | | +| `operations.lists.separate.available` | boolean | yes | | +| `operations.lists.separate.dryRun` | boolean | yes | | +| `operations.lists.separate.reasons` | enum[] | no | | +| `operations.lists.separate.tracked` | boolean | yes | | +| `operations.lists.setLevel` | object | yes | | +| `operations.lists.setLevel.available` | boolean | yes | | +| `operations.lists.setLevel.dryRun` | boolean | yes | | +| `operations.lists.setLevel.reasons` | enum[] | no | | +| `operations.lists.setLevel.tracked` | boolean | yes | | +| `operations.lists.setLevelRestart` | object | yes | | +| `operations.lists.setLevelRestart.available` | boolean | yes | | +| `operations.lists.setLevelRestart.dryRun` | boolean | yes | | +| `operations.lists.setLevelRestart.reasons` | enum[] | no | | +| `operations.lists.setLevelRestart.tracked` | boolean | yes | | +| `operations.lists.setValue` | object | yes | | +| `operations.lists.setValue.available` | boolean | yes | | +| `operations.lists.setValue.dryRun` | boolean | yes | | +| `operations.lists.setValue.reasons` | enum[] | no | | +| `operations.lists.setValue.tracked` | boolean | yes | | | `operations.mutations.apply` | object | yes | | | `operations.mutations.apply.available` | boolean | yes | | | `operations.mutations.apply.dryRun` | boolean | yes | | @@ -2082,7 +2127,55 @@ _No fields._ ], "tracked": true }, - "lists.exit": { + "lists.attach": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.canContinuePrevious": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.canJoin": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.continuePrevious": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.convertToText": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.create": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.detach": { "available": true, "dryRun": true, "reasons": [ @@ -2114,6 +2207,14 @@ _No fields._ ], "tracked": true }, + "lists.join": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "lists.list": { "available": true, "dryRun": true, @@ -2130,7 +2231,15 @@ _No fields._ ], "tracked": true }, - "lists.restart": { + "lists.separate": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.setLevel": { "available": true, "dryRun": true, "reasons": [ @@ -2138,7 +2247,15 @@ _No fields._ ], "tracked": true }, - "lists.setType": { + "lists.setLevelRestart": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "lists.setValue": { "available": true, "dryRun": true, "reasons": [ @@ -7340,7 +7457,217 @@ _No fields._ ], "type": "object" }, - "lists.exit": { + "lists.attach": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.canContinuePrevious": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.canJoin": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.continuePrevious": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.convertToText": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.create": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.detach": { "additionalProperties": false, "properties": { "available": { @@ -7480,6 +7807,41 @@ _No fields._ ], "type": "object" }, + "lists.join": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.list": { "additionalProperties": false, "properties": { @@ -7550,7 +7912,77 @@ _No fields._ ], "type": "object" }, - "lists.restart": { + "lists.separate": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.setLevel": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.setLevelRestart": { "additionalProperties": false, "properties": { "available": { @@ -7585,7 +8017,7 @@ _No fields._ ], "type": "object" }, - "lists.setType": { + "lists.setValue": { "additionalProperties": false, "properties": { "available": { @@ -10414,11 +10846,20 @@ _No fields._ "lists.list", "lists.get", "lists.insert", - "lists.setType", + "lists.create", + "lists.attach", + "lists.detach", "lists.indent", "lists.outdent", - "lists.restart", - "lists.exit", + "lists.join", + "lists.canJoin", + "lists.separate", + "lists.setLevel", + "lists.setValue", + "lists.continuePrevious", + "lists.canContinuePrevious", + "lists.setLevelRestart", + "lists.convertToText", "comments.create", "comments.patch", "comments.delete", diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index fe73dc5a39..04da8bee4a 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -27,7 +27,7 @@ Document API is currently alpha and subject to breaking changes. | Sections | 18 | 0 | 18 | [Open](/document-api/reference/sections/index) | | Format | 44 | 1 | 45 | [Open](/document-api/reference/format/index) | | Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | -| Lists | 8 | 0 | 8 | [Open](/document-api/reference/lists/index) | +| Lists | 17 | 0 | 17 | [Open](/document-api/reference/lists/index) | | Comments | 5 | 0 | 5 | [Open](/document-api/reference/comments/index) | | Track Changes | 3 | 0 | 3 | [Open](/document-api/reference/track-changes/index) | | Query | 1 | 0 | 1 | [Open](/document-api/reference/query/index) | @@ -163,11 +163,20 @@ The tables below are grouped by namespace. | lists.list | editor.doc.lists.list(...) | List all list nodes in the document, optionally filtered by scope. | | lists.get | editor.doc.lists.get(...) | Retrieve a specific list node by target. | | lists.insert | editor.doc.lists.insert(...) | Insert a new list at the target position. | -| lists.setType | editor.doc.lists.setType(...) | Change the list type (ordered, unordered) of a target list. | +| lists.create | editor.doc.lists.create(...) | Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. | +| lists.attach | editor.doc.lists.attach(...) | Convert non-list paragraphs to list items under an existing list sequence. | +| lists.detach | editor.doc.lists.detach(...) | Remove numbering properties from list items, converting them to plain paragraphs. | | lists.indent | editor.doc.lists.indent(...) | Increase the indentation level of a list item. | | lists.outdent | editor.doc.lists.outdent(...) | Decrease the indentation level of a list item. | -| lists.restart | editor.doc.lists.restart(...) | Restart numbering of an ordered list at the target item. | -| lists.exit | editor.doc.lists.exit(...) | Exit a list context, converting the target item to a paragraph. | +| lists.join | editor.doc.lists.join(...) | Merge two adjacent list sequences into one. | +| lists.canJoin | editor.doc.lists.canJoin(...) | Check whether two adjacent list sequences can be joined. | +| lists.separate | editor.doc.lists.separate(...) | Split a list sequence at the target item, creating a new sequence from that point forward. | +| lists.setLevel | editor.doc.lists.setLevel(...) | Set the absolute nesting level (0..8) of a list item. | +| lists.setValue | editor.doc.lists.setValue(...) | Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first. | +| lists.continuePrevious | editor.doc.lists.continuePrevious(...) | Continue numbering from the nearest compatible previous list sequence. | +| lists.canContinuePrevious | editor.doc.lists.canContinuePrevious(...) | Check whether the target sequence can continue numbering from a previous compatible sequence. | +| lists.setLevelRestart | editor.doc.lists.setLevelRestart(...) | Set the restart behavior for a specific list level. | +| lists.convertToText | editor.doc.lists.convertToText(...) | Convert list items to plain paragraphs, optionally prepending the rendered marker text. | #### Comments diff --git a/apps/docs/document-api/reference/lists/attach.mdx b/apps/docs/document-api/reference/lists/attach.mdx new file mode 100644 index 0000000000..f19db21702 --- /dev/null +++ b/apps/docs/document-api/reference/lists/attach.mdx @@ -0,0 +1,245 @@ +--- +title: lists.attach +sidebarTitle: lists.attach +description: Convert non-list paragraphs to list items under an existing list sequence. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Convert non-list paragraphs to list items under an existing list sequence. + +- Operation ID: `lists.attach` +- API member path: `editor.doc.lists.attach(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult confirming attachment. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `attachTo` | ListItemAddress | yes | ListItemAddress | +| `attachTo.kind` | `"block"` | yes | Constant: `"block"` | +| `attachTo.nodeId` | string | yes | | +| `attachTo.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `level` | integer | no | | +| `target` | BlockAddressOrRange | yes | BlockAddressOrRange | + +### Example request + +```json +{ + "attachTo": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "attachTo": { + "$ref": "#/$defs/ListItemAddress" + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/BlockAddressOrRange" + } + }, + "required": [ + "target", + "attachTo" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/can-continue-previous.mdx b/apps/docs/document-api/reference/lists/can-continue-previous.mdx new file mode 100644 index 0000000000..8c4bf1036a --- /dev/null +++ b/apps/docs/document-api/reference/lists/can-continue-previous.mdx @@ -0,0 +1,120 @@ +--- +title: lists.canContinuePrevious +sidebarTitle: lists.canContinuePrevious +description: Check whether the target sequence can continue numbering from a previous compatible sequence. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Check whether the target sequence can continue numbering from a previous compatible sequence. + +- Operation ID: `lists.canContinuePrevious` +- API member path: `editor.doc.lists.canContinuePrevious(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsCanContinuePreviousResult indicating feasibility. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `canContinue` | boolean | yes | | +| `previousListId` | string | no | | +| `reason` | enum | no | `"NO_PREVIOUS_LIST"`, `"INCOMPATIBLE_DEFINITIONS"`, `"ALREADY_CONTINUOUS"` | + +### Example response + +```json +{ + "canContinue": true, + "previousListId": "example", + "reason": "NO_PREVIOUS_LIST" +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "canContinue": { + "type": "boolean" + }, + "previousListId": { + "type": "string" + }, + "reason": { + "enum": [ + "NO_PREVIOUS_LIST", + "INCOMPATIBLE_DEFINITIONS", + "ALREADY_CONTINUOUS" + ] + } + }, + "required": [ + "canContinue" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/can-join.mdx b/apps/docs/document-api/reference/lists/can-join.mdx new file mode 100644 index 0000000000..f00b1f4c80 --- /dev/null +++ b/apps/docs/document-api/reference/lists/can-join.mdx @@ -0,0 +1,129 @@ +--- +title: lists.canJoin +sidebarTitle: lists.canJoin +description: Check whether two adjacent list sequences can be joined. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Check whether two adjacent list sequences can be joined. + +- Operation ID: `lists.canJoin` +- API member path: `editor.doc.lists.canJoin(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsCanJoinResult indicating feasibility and reason if not possible. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `direction` | enum | yes | `"withPrevious"`, `"withNext"` | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "direction": "withPrevious", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `adjacentListId` | string | no | | +| `canJoin` | boolean | yes | | +| `reason` | enum | no | `"NO_ADJACENT_SEQUENCE"`, `"INCOMPATIBLE_DEFINITIONS"`, `"ALREADY_SAME_SEQUENCE"` | + +### Example response + +```json +{ + "adjacentListId": "example", + "canJoin": true, + "reason": "NO_ADJACENT_SEQUENCE" +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "direction": { + "enum": [ + "withPrevious", + "withNext" + ] + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "direction" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "adjacentListId": { + "type": "string" + }, + "canJoin": { + "type": "boolean" + }, + "reason": { + "enum": [ + "NO_ADJACENT_SEQUENCE", + "INCOMPATIBLE_DEFINITIONS", + "ALREADY_SAME_SEQUENCE" + ] + } + }, + "required": [ + "canJoin" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/restart.mdx b/apps/docs/document-api/reference/lists/continue-previous.mdx similarity index 83% rename from apps/docs/document-api/reference/lists/restart.mdx rename to apps/docs/document-api/reference/lists/continue-previous.mdx index f2a59692c3..34eec4b625 100644 --- a/apps/docs/document-api/reference/lists/restart.mdx +++ b/apps/docs/document-api/reference/lists/continue-previous.mdx @@ -1,7 +1,7 @@ --- -title: lists.restart -sidebarTitle: lists.restart -description: Restart numbering of an ordered list at the target item. +title: lists.continuePrevious +sidebarTitle: lists.continuePrevious +description: Continue numbering from the nearest compatible previous list sequence. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,10 +10,10 @@ description: Restart numbering of an ordered list at the target item. ## Summary -Restart numbering of an ordered list at the target item. +Continue numbering from the nearest compatible previous list sequence. -- Operation ID: `lists.restart` -- API member path: `editor.doc.lists.restart(...)` +- Operation ID: `lists.continuePrevious` +- API member path: `editor.doc.lists.continuePrevious(...)` - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` @@ -22,7 +22,7 @@ Restart numbering of an ordered list at the target item. ## Expected result -Returns a ListsMutateItemResult receipt; reports NO_OP if numbering already restarts at the target item. +Returns a ListsMutateItemResult receipt. ## Input fields @@ -62,7 +62,7 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if numbering already rest | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"` | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"NO_COMPATIBLE_PREVIOUS"`, `"ALREADY_CONTINUOUS"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -88,8 +88,9 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if numbering already rest ## Non-applied failure codes -- `NO_OP` - `INVALID_TARGET` +- `NO_COMPATIBLE_PREVIOUS` +- `ALREADY_CONTINUOUS` ## Raw schemas @@ -138,8 +139,9 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if numbering already rest "properties": { "code": { "enum": [ - "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "NO_COMPATIBLE_PREVIOUS", + "ALREADY_CONTINUOUS" ] }, "details": {}, @@ -199,8 +201,9 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if numbering already rest "properties": { "code": { "enum": [ - "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "NO_COMPATIBLE_PREVIOUS", + "ALREADY_CONTINUOUS" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/lists/convert-to-text.mdx b/apps/docs/document-api/reference/lists/convert-to-text.mdx new file mode 100644 index 0000000000..636742d36a --- /dev/null +++ b/apps/docs/document-api/reference/lists/convert-to-text.mdx @@ -0,0 +1,230 @@ +--- +title: lists.convertToText +sidebarTitle: lists.convertToText +description: Convert list items to plain paragraphs, optionally prepending the rendered marker text. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Convert list items to plain paragraphs, optionally prepending the rendered marker text. + +- Operation ID: `lists.convertToText` +- API member path: `editor.doc.lists.convertToText(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsConvertToTextResult confirming the conversion. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `includeMarker` | boolean | no | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "includeMarker": true, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (paragraph.kind="block") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `paragraph` | ParagraphAddress | yes | ParagraphAddress | +| `paragraph.kind` | `"block"` | yes | Constant: `"block"` | +| `paragraph.nodeId` | string | yes | | +| `paragraph.nodeType` | `"paragraph"` | yes | Constant: `"paragraph"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "paragraph": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "includeMarker": { + "type": "boolean" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "paragraph": { + "$ref": "#/$defs/ParagraphAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "paragraph" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "paragraph": { + "$ref": "#/$defs/ParagraphAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "paragraph" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/create.mdx b/apps/docs/document-api/reference/lists/create.mdx new file mode 100644 index 0000000000..09d07e012b --- /dev/null +++ b/apps/docs/document-api/reference/lists/create.mdx @@ -0,0 +1,291 @@ +--- +title: lists.create +sidebarTitle: lists.create +description: Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. + +- Operation ID: `lists.create` +- API member path: `editor.doc.lists.create(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsCreateResult with the new listId and the first item address. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `at` | BlockAddress | no | BlockAddress | +| `at.kind` | `"block"` | no | Constant: `"block"` | +| `at.nodeId` | string | no | | +| `at.nodeType` | `"paragraph"` | no | Constant: `"paragraph"` | +| `kind` | enum | yes | `"ordered"`, `"bullet"` | +| `level` | integer | no | | +| `mode` | enum | yes | `"empty"`, `"fromParagraphs"` | +| `target` | BlockAddressOrRange | no | BlockAddressOrRange | + +### Example request + +```json +{ + "at": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + }, + "kind": "ordered", + "mode": "empty", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `listId` | string | yes | | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"LEVEL_OUT_OF_RANGE"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "listId": "example", + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `LEVEL_OUT_OF_RANGE` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "else": { + "required": [ + "mode", + "kind", + "target" + ] + }, + "if": { + "properties": { + "mode": { + "const": "empty" + } + } + }, + "properties": { + "at": { + "$ref": "#/$defs/BlockAddress" + }, + "kind": { + "enum": [ + "ordered", + "bullet" + ] + }, + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "mode": { + "enum": [ + "empty", + "fromParagraphs" + ] + }, + "target": { + "$ref": "#/$defs/BlockAddressOrRange" + } + }, + "required": [ + "mode", + "kind" + ], + "then": { + "required": [ + "mode", + "kind", + "at" + ] + }, + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "listId": { + "type": "string" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "listId": { + "type": "string" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/exit.mdx b/apps/docs/document-api/reference/lists/detach.mdx similarity index 91% rename from apps/docs/document-api/reference/lists/exit.mdx rename to apps/docs/document-api/reference/lists/detach.mdx index 6b4792e209..d3d7d89fb1 100644 --- a/apps/docs/document-api/reference/lists/exit.mdx +++ b/apps/docs/document-api/reference/lists/detach.mdx @@ -1,7 +1,7 @@ --- -title: lists.exit -sidebarTitle: lists.exit -description: Exit a list context, converting the target item to a paragraph. +title: lists.detach +sidebarTitle: lists.detach +description: Remove numbering properties from list items, converting them to plain paragraphs. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,10 +10,10 @@ description: Exit a list context, converting the target item to a paragraph. ## Summary -Exit a list context, converting the target item to a paragraph. +Remove numbering properties from list items, converting them to plain paragraphs. -- Operation ID: `lists.exit` -- API member path: `editor.doc.lists.exit(...)` +- Operation ID: `lists.detach` +- API member path: `editor.doc.lists.detach(...)` - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` @@ -22,7 +22,7 @@ Exit a list context, converting the target item to a paragraph. ## Expected result -Returns a ListsExitResult confirming the item was converted to a plain paragraph. +Returns a ListsDetachResult confirming the item was converted to a plain paragraph. ## Input fields diff --git a/apps/docs/document-api/reference/lists/get.mdx b/apps/docs/document-api/reference/lists/get.mdx index 1e2772f87d..255b0ffc1d 100644 --- a/apps/docs/document-api/reference/lists/get.mdx +++ b/apps/docs/document-api/reference/lists/get.mdx @@ -55,6 +55,7 @@ Returns a ListItemInfo object with the item kind, level, marker, and address. | `address.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | | `kind` | enum | no | `"ordered"`, `"bullet"` | | `level` | integer | no | | +| `listId` | string | yes | | | `marker` | string | no | | | `ordinal` | integer | no | | | `path` | integer[] | no | | @@ -69,6 +70,7 @@ Returns a ListItemInfo object with the item kind, level, marker, and address. "nodeId": "node-def456", "nodeType": "listItem" }, + "listId": "example", "marker": "1.", "ordinal": 1 } @@ -118,6 +120,9 @@ Returns a ListItemInfo object with the item kind, level, marker, and address. "level": { "type": "integer" }, + "listId": { + "type": "string" + }, "marker": { "type": "string" }, @@ -135,7 +140,8 @@ Returns a ListItemInfo object with the item kind, level, marker, and address. } }, "required": [ - "address" + "address", + "listId" ], "type": "object" } diff --git a/apps/docs/document-api/reference/lists/indent.mdx b/apps/docs/document-api/reference/lists/indent.mdx index ac3b736832..76ce8058de 100644 --- a/apps/docs/document-api/reference/lists/indent.mdx +++ b/apps/docs/document-api/reference/lists/indent.mdx @@ -62,7 +62,7 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"` | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"LEVEL_OUT_OF_RANGE"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -90,6 +90,7 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at - `NO_OP` - `INVALID_TARGET` +- `LEVEL_OUT_OF_RANGE` ## Raw schemas @@ -139,7 +140,8 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at "code": { "enum": [ "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" ] }, "details": {}, @@ -200,7 +202,8 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at "code": { "enum": [ "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/lists/index.mdx b/apps/docs/document-api/reference/lists/index.mdx index cf5124d0dc..1a49fa2f54 100644 --- a/apps/docs/document-api/reference/lists/index.mdx +++ b/apps/docs/document-api/reference/lists/index.mdx @@ -17,9 +17,18 @@ List inspection and list mutations. | lists.list | `lists.list` | No | `idempotent` | No | No | | lists.get | `lists.get` | No | `idempotent` | No | No | | lists.insert | `lists.insert` | Yes | `non-idempotent` | Yes | Yes | -| lists.setType | `lists.setType` | Yes | `conditional` | No | Yes | +| lists.create | `lists.create` | Yes | `non-idempotent` | No | Yes | +| lists.attach | `lists.attach` | Yes | `conditional` | No | Yes | +| lists.detach | `lists.detach` | Yes | `conditional` | No | Yes | | lists.indent | `lists.indent` | Yes | `conditional` | No | Yes | | lists.outdent | `lists.outdent` | Yes | `conditional` | No | Yes | -| lists.restart | `lists.restart` | Yes | `conditional` | No | Yes | -| lists.exit | `lists.exit` | Yes | `conditional` | No | Yes | +| lists.join | `lists.join` | Yes | `conditional` | No | Yes | +| lists.canJoin | `lists.canJoin` | No | `idempotent` | No | No | +| lists.separate | `lists.separate` | Yes | `conditional` | No | Yes | +| lists.setLevel | `lists.setLevel` | Yes | `conditional` | No | Yes | +| lists.setValue | `lists.setValue` | Yes | `conditional` | No | Yes | +| lists.continuePrevious | `lists.continuePrevious` | Yes | `conditional` | No | Yes | +| lists.canContinuePrevious | `lists.canContinuePrevious` | No | `idempotent` | No | No | +| lists.setLevelRestart | `lists.setLevelRestart` | Yes | `conditional` | No | Yes | +| lists.convertToText | `lists.convertToText` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/lists/join.mdx b/apps/docs/document-api/reference/lists/join.mdx new file mode 100644 index 0000000000..df01390278 --- /dev/null +++ b/apps/docs/document-api/reference/lists/join.mdx @@ -0,0 +1,236 @@ +--- +title: lists.join +sidebarTitle: lists.join +description: Merge two adjacent list sequences into one. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Merge two adjacent list sequences into one. + +- Operation ID: `lists.join` +- API member path: `editor.doc.lists.join(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsJoinResult with the resulting listId of the merged sequence. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `direction` | enum | yes | `"withPrevious"`, `"withNext"` | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "direction": "withPrevious", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `listId` | string | yes | | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"NO_ADJACENT_SEQUENCE"`, `"INCOMPATIBLE_DEFINITIONS"`, `"ALREADY_SAME_SEQUENCE"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "listId": "example", + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_ADJACENT_SEQUENCE` +- `INCOMPATIBLE_DEFINITIONS` +- `ALREADY_SAME_SEQUENCE` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "direction": { + "enum": [ + "withPrevious", + "withNext" + ] + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "direction" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "listId": { + "type": "string" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_ADJACENT_SEQUENCE", + "INCOMPATIBLE_DEFINITIONS", + "ALREADY_SAME_SEQUENCE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "listId": { + "type": "string" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_ADJACENT_SEQUENCE", + "INCOMPATIBLE_DEFINITIONS", + "ALREADY_SAME_SEQUENCE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/list.mdx b/apps/docs/document-api/reference/lists/list.mdx index 6fa2e2f318..966b84389a 100644 --- a/apps/docs/document-api/reference/lists/list.mdx +++ b/apps/docs/document-api/reference/lists/list.mdx @@ -81,6 +81,7 @@ Returns a ListsListResult with an array of list item summaries and total count. "targetKind": "text" }, "id": "id-001", + "listId": "example", "marker": "1.", "ordinal": 1 } @@ -168,6 +169,9 @@ Returns a ListsListResult with an array of list item summaries and total count. "level": { "type": "integer" }, + "listId": { + "type": "string" + }, "marker": { "type": "string" }, @@ -187,7 +191,8 @@ Returns a ListsListResult with an array of list item summaries and total count. "required": [ "id", "handle", - "address" + "address", + "listId" ], "type": "object" }, diff --git a/apps/docs/document-api/reference/lists/outdent.mdx b/apps/docs/document-api/reference/lists/outdent.mdx index e4eef44f7f..027bd8767d 100644 --- a/apps/docs/document-api/reference/lists/outdent.mdx +++ b/apps/docs/document-api/reference/lists/outdent.mdx @@ -62,7 +62,7 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"` | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"LEVEL_OUT_OF_RANGE"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -90,6 +90,7 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at - `NO_OP` - `INVALID_TARGET` +- `LEVEL_OUT_OF_RANGE` ## Raw schemas @@ -139,7 +140,8 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at "code": { "enum": [ "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" ] }, "details": {}, @@ -200,7 +202,8 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the item is already at "code": { "enum": [ "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/lists/separate.mdx b/apps/docs/document-api/reference/lists/separate.mdx new file mode 100644 index 0000000000..29935d951d --- /dev/null +++ b/apps/docs/document-api/reference/lists/separate.mdx @@ -0,0 +1,236 @@ +--- +title: lists.separate +sidebarTitle: lists.separate +description: Split a list sequence at the target item, creating a new sequence from that point forward. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Split a list sequence at the target item, creating a new sequence from that point forward. + +- Operation ID: `lists.separate` +- API member path: `editor.doc.lists.separate(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsSeparateResult with the new listId and numId. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `copyOverrides` | boolean | no | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "copyOverrides": true, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `listId` | string | yes | | +| `numId` | integer | yes | | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "listId": "example", + "numId": 1, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "copyOverrides": { + "type": "boolean" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "listId": { + "type": "string" + }, + "numId": { + "type": "integer" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "numId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "listId": { + "type": "string" + }, + "numId": { + "type": "integer" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "numId" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/set-level-restart.mdx b/apps/docs/document-api/reference/lists/set-level-restart.mdx new file mode 100644 index 0000000000..24c291665b --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level-restart.mdx @@ -0,0 +1,253 @@ +--- +title: lists.setLevelRestart +sidebarTitle: lists.setLevelRestart +description: Set the restart behavior for a specific list level. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the restart behavior for a specific list level. + +- Operation ID: `lists.setLevelRestart` +- API member path: `editor.doc.lists.setLevelRestart(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `restartAfterLevel` | any | yes | | +| `scope` | enum | no | `"definition"`, `"instance"` | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "level": 1, + "restartAfterLevel": {}, + "scope": "definition", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"LEVEL_OUT_OF_RANGE"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `LEVEL_OUT_OF_RANGE` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "restartAfterLevel": { + "type": [ + "integer", + "null" + ] + }, + "scope": { + "enum": [ + "definition", + "instance" + ] + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level", + "restartAfterLevel" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/set-level.mdx b/apps/docs/document-api/reference/lists/set-level.mdx new file mode 100644 index 0000000000..8df0c15a2a --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-level.mdx @@ -0,0 +1,239 @@ +--- +title: lists.setLevel +sidebarTitle: lists.setLevel +description: Set the absolute nesting level (0..8) of a list item. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the absolute nesting level (0..8) of a list item. + +- Operation ID: `lists.setLevel` +- API member path: `editor.doc.lists.setLevel(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMutateItemResult receipt; reports NO_OP if already at the target level. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | yes | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "level": 1, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (item.kind="block") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `item` | ListItemAddress | yes | ListItemAddress | +| `item.kind` | `"block"` | yes | Constant: `"block"` | +| `item.nodeId` | string | yes | | +| `item.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"LEVEL_OUT_OF_RANGE"`, `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "item": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `LEVEL_OUT_OF_RANGE` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "level" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/$defs/ListItemAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "LEVEL_OUT_OF_RANGE", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/set-type.mdx b/apps/docs/document-api/reference/lists/set-value.mdx similarity index 84% rename from apps/docs/document-api/reference/lists/set-type.mdx rename to apps/docs/document-api/reference/lists/set-value.mdx index b3d5303563..8550ffcdaf 100644 --- a/apps/docs/document-api/reference/lists/set-type.mdx +++ b/apps/docs/document-api/reference/lists/set-value.mdx @@ -1,7 +1,7 @@ --- -title: lists.setType -sidebarTitle: lists.setType -description: Change the list type (ordered, unordered) of a target list. +title: lists.setValue +sidebarTitle: lists.setValue +description: Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,10 +10,10 @@ description: Change the list type (ordered, unordered) of a target list. ## Summary -Change the list type (ordered, unordered) of a target list. +Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first. -- Operation ID: `lists.setType` -- API member path: `editor.doc.lists.setType(...)` +- Operation ID: `lists.setValue` +- API member path: `editor.doc.lists.setValue(...)` - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` @@ -22,28 +22,28 @@ Change the list type (ordered, unordered) of a target list. ## Expected result -Returns a ListsMutateItemResult receipt; reports NO_OP if the list already has the requested type. +Returns a ListsMutateItemResult receipt. ## Input fields | Field | Type | Required | Description | | --- | --- | --- | --- | -| `kind` | enum | yes | `"ordered"`, `"bullet"` | | `target` | ListItemAddress | yes | ListItemAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | | `target.nodeId` | string | yes | | | `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | +| `value` | any | yes | | ### Example request ```json { - "kind": "ordered", "target": { "kind": "block", "nodeId": "node-def456", "nodeType": "listItem" - } + }, + "value": {} } ``` @@ -64,7 +64,7 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the list already has t | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"` | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"NO_OP"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -90,8 +90,8 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the list already has t ## Non-applied failure codes -- `NO_OP` - `INVALID_TARGET` +- `NO_OP` ## Raw schemas @@ -100,19 +100,19 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the list already has t { "additionalProperties": false, "properties": { - "kind": { - "enum": [ - "ordered", - "bullet" - ] - }, "target": { "$ref": "#/$defs/ListItemAddress" + }, + "value": { + "type": [ + "integer", + "null" + ] } }, "required": [ "target", - "kind" + "value" ], "type": "object" } @@ -147,8 +147,8 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the list already has t "properties": { "code": { "enum": [ - "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "NO_OP" ] }, "details": {}, @@ -208,8 +208,8 @@ Returns a ListsMutateItemResult receipt; reports NO_OP if the list already has t "properties": { "code": { "enum": [ - "NO_OP", - "INVALID_TARGET" + "INVALID_TARGET", + "NO_OP" ] }, "details": {}, diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 139dfea4c6..60f2a363da 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -361,11 +361,20 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.list` | `lists list` | List all list nodes in the document, optionally filtered by scope. | | `doc.lists.get` | `lists get` | Retrieve a specific list node by target. | | `doc.lists.insert` | `lists insert` | Insert a new list at the target position. | -| `doc.lists.setType` | `lists set-type` | Change the list type (ordered, unordered) of a target list. | +| `doc.lists.create` | `lists create` | Create a new list from one or more paragraphs, or convert existing paragraphs into a new list. | +| `doc.lists.attach` | `lists attach` | Convert non-list paragraphs to list items under an existing list sequence. | +| `doc.lists.detach` | `lists detach` | Remove numbering properties from list items, converting them to plain paragraphs. | | `doc.lists.indent` | `lists indent` | Increase the indentation level of a list item. | | `doc.lists.outdent` | `lists outdent` | Decrease the indentation level of a list item. | -| `doc.lists.restart` | `lists restart` | Restart numbering of an ordered list at the target item. | -| `doc.lists.exit` | `lists exit` | Exit a list context, converting the target item to a paragraph. | +| `doc.lists.join` | `lists join` | Merge two adjacent list sequences into one. | +| `doc.lists.canJoin` | `lists can-join` | Check whether two adjacent list sequences can be joined. | +| `doc.lists.separate` | `lists separate` | Split a list sequence at the target item, creating a new sequence from that point forward. | +| `doc.lists.setLevel` | `lists set-level` | Set the absolute nesting level (0..8) of a list item. | +| `doc.lists.setValue` | `lists set-value` | Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first. | +| `doc.lists.continuePrevious` | `lists continue-previous` | Continue numbering from the nearest compatible previous list sequence. | +| `doc.lists.canContinuePrevious` | `lists can-continue-previous` | Check whether the target sequence can continue numbering from a previous compatible sequence. | +| `doc.lists.setLevelRestart` | `lists set-level-restart` | Set the restart behavior for a specific list level. | +| `doc.lists.convertToText` | `lists convert-to-text` | Convert list items to plain paragraphs, optionally prepending the rendered marker text. | #### Comments diff --git a/apps/mcp/src/__tests__/protocol.test.ts b/apps/mcp/src/__tests__/protocol.test.ts index 3652a843b6..a70864982b 100644 --- a/apps/mcp/src/__tests__/protocol.test.ts +++ b/apps/mcp/src/__tests__/protocol.test.ts @@ -29,7 +29,7 @@ const EXPECTED_TOOLS = [ 'superdoc_reply_comment', 'superdoc_resolve_comment', 'superdoc_insert_list', - 'superdoc_list_set_type', + 'superdoc_list_create', ]; function textContent(result: Awaited>): string { diff --git a/apps/mcp/src/tools/lists.ts b/apps/mcp/src/tools/lists.ts index 3facd55f00..1f5e2aefa1 100644 --- a/apps/mcp/src/tools/lists.ts +++ b/apps/mcp/src/tools/lists.ts @@ -43,14 +43,19 @@ export function registerListTools(server: McpServer, sessions: SessionManager): ); server.registerTool( - 'superdoc_list_set_type', + 'superdoc_list_create', { - title: 'Set List Type', - description: 'Change a list between ordered (numbered) and bullet (unordered).', + title: 'Create List', + description: + 'Create a new list from one or more existing paragraphs. Use superdoc_find to locate paragraph addresses first.', inputSchema: { session_id: z.string().describe('Session ID from superdoc_open.'), - target: z.string().describe('JSON-encoded list item address from superdoc_find results.'), - kind: z.enum(['ordered', 'bullet']).describe('The list type to set.'), + target: z + .string() + .describe( + 'JSON-encoded block address (or range) of the paragraph(s) to convert. Use { "kind": "block", "nodeType": "paragraph", "nodeId": "..." }.', + ), + kind: z.enum(['ordered', 'bullet']).describe('The list type to create.'), }, annotations: { readOnlyHint: false }, }, @@ -59,15 +64,15 @@ export function registerListTools(server: McpServer, sessions: SessionManager): const { api } = sessions.get(session_id); const parsed = JSON.parse(target); const result = api.invoke({ - operationId: 'lists.setType', - input: { target: parsed, kind }, + operationId: 'lists.create', + input: { mode: 'fromParagraphs', target: parsed, kind }, }); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; } catch (err) { return { - content: [{ type: 'text' as const, text: `Set list type failed: ${(err as Error).message}` }], + content: [{ type: 'text' as const, text: `Create list failed: ${(err as Error).message}` }], isError: true, }; } diff --git a/lefthook.yml b/lefthook.yml index 2753546d7f..a93d6062e3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -16,13 +16,9 @@ pre-commit: run: pnpm exec eslint --fix --no-warn-ignored {staged_files} stage_fixed: true cli-build: - root: "apps/cli/" - glob: "apps/cli/**/*.ts" - run: pnpm run build + run: pnpm --prefix apps/cli run build cli-test: - root: "apps/cli/" - glob: "apps/cli/**/*.ts" - run: pnpm run test + run: pnpm run build:superdoc && pnpm run test:cli vscode-build: root: "apps/vscode-ext/" glob: "apps/vscode-ext/**/*.{ts,js}" diff --git a/packages/document-api/scripts/lib/contract-output-artifacts.ts b/packages/document-api/scripts/lib/contract-output-artifacts.ts index b0c856853b..71a26bd4f8 100644 --- a/packages/document-api/scripts/lib/contract-output-artifacts.ts +++ b/packages/document-api/scripts/lib/contract-output-artifacts.ts @@ -187,7 +187,7 @@ export function buildAgentArtifacts(): GeneratedFile[] { { id: 'list-manipulation', title: 'List manipulation workflow', - operations: ['lists.insert', 'lists.setType', 'lists.indent', 'lists.outdent', 'lists.exit'], + operations: ['lists.list', 'lists.create', 'lists.insert', 'lists.indent', 'lists.outdent', 'lists.detach'], }, { id: 'capabilities-aware-branching', diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index 7d783ec607..c3d32cb261 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -61,16 +61,21 @@ lives in adapter layers that map engine behavior into discovery envelopes and ot - Tracking is operation-scoped (`changeMode: 'direct' | 'tracked'`), not global editor-mode state. - `insert`, `replace`, `delete`, `format.apply`, and `create.paragraph`, `create.heading` may run in tracked mode. - `trackChanges.*` (`list`, `get`, `decide`) is the review lifecycle namespace. -- `lists.insert` may run in tracked mode; `lists.setType|indent|outdent|restart|exit` are direct-only. +- `lists.insert` may run in tracked mode; all other `lists.*` mutations are direct-only. ## List Namespace Semantics - `lists.*` projects paragraph-based numbering into first-class `listItem` addresses. - `ListItemAddress.nodeId` reuses the underlying paragraph node id directly. - `lists.list({ within })` is inclusive when `within` itself is a list item. -- `lists.setType` normalizes deterministically to canonical defaults (`ordered` decimal / `bullet` default bullet). - `lists.insert` returns `insertionPoint` at the inserted item start (`offset: 0`) even when text is provided. -- `lists.restart` returns `NO_OP` only when target is already the first item of its contiguous run and effectively starts at `1`. +- `lists.create` supports two modes: `empty` (convert a single paragraph) and `fromParagraphs` (convert a range). +- `lists.attach` adds paragraphs to an existing list by inheriting the `attachTo` item's `numId`. +- `lists.join` merges adjacent sequences sharing the same `abstractNumId`; fails with `INCOMPATIBLE_DEFINITIONS` otherwise. +- `lists.separate` splits a sequence at the target, creating a new `numId` pointing to the same abstract. +- `lists.setValue` on a mid-sequence target atomically separates then sets `startOverride`. +- `lists.continuePrevious` merges the target's sequence into the nearest previous compatible sequence. +- `lists.setLevelRestart` supports `scope: 'definition'` (mutates abstract) or `scope: 'instance'` (uses `lvlOverride`). Deterministic outcomes: - Unknown tracked-change ids must fail with `TARGET_NOT_FOUND` at adapter level. @@ -125,14 +130,19 @@ editor.doc.comments.patch({ commentId: thread.id, status: 'resolved' }); ### Workflow: List Manipulation -Insert a list item, change its type, then indent it: +Create a list, insert an item, then indent it: ```ts +// Convert a paragraph into a new ordered list +const paragraph = editor.doc.find({ type: 'node', nodeType: 'paragraph' }); +const target = paragraph.items[0]?.address; +const createResult = editor.doc.lists.create({ mode: 'empty', at: target, kind: 'ordered' }); + +// Insert a new item after the first const lists = editor.doc.lists.list(); const firstItem = lists.items[0]; const insertResult = editor.doc.lists.insert({ target: firstItem.address, position: 'after', text: 'New item' }); if (insertResult.success) { - editor.doc.lists.setType({ target: insertResult.item, kind: 'ordered' }); editor.doc.lists.indent({ target: insertResult.item }); } ``` @@ -333,57 +343,152 @@ Insert a new list item before or after a target item. Returns the new item's `Li - **Idempotency**: non-idempotent - **Failure codes**: `INVALID_TARGET` -### `lists.setType` +### `lists.create` + +Create a new list from one or more paragraphs. Two modes: `empty` (convert a single paragraph at `at`) or `fromParagraphs` (convert a `BlockAddress` or `BlockRange`). Creates a new `numId` + `abstractNum` definition for the requested `kind`. Direct-only. Supports dry-run. + +- **Input**: `ListsCreateInput` (`{ mode: 'empty', at, kind, level? } | { mode: 'fromParagraphs', target, kind, level? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsCreateResult` +- **Mutates**: Yes +- **Idempotency**: non-idempotent +- **Failure codes**: `INVALID_TARGET` + +### `lists.attach` + +Attach non-list paragraphs to an existing list. Target paragraphs inherit the `attachTo` item's `numId`. Direct-only. Supports dry-run. + +- **Input**: `ListsAttachInput` (`{ target, attachTo, level? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: non-idempotent +- **Failure codes**: `INVALID_TARGET`, `NO_OP` + +### `lists.detach` + +Remove numbering properties from targeted list items, converting them back to plain paragraphs. Preserves text and non-list formatting. Direct-only. Supports dry-run. + +- **Input**: `ListsDetachInput` (`{ target }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsDetachResult` +- **Mutates**: Yes +- **Idempotency**: conditional (re-detach is no-op) +- **Failure codes**: `INVALID_TARGET` + +### `lists.join` + +Merge two adjacent list sequences. `withPrevious` merges the target's sequence into the preceding one; `withNext` merges the following sequence into the target's. Requires both sequences to share the same `abstractNumId`. Direct-only. Supports dry-run. + +- **Input**: `ListsJoinInput` (`{ target, direction: 'withPrevious' | 'withNext' }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsJoinResult` +- **Mutates**: Yes +- **Idempotency**: non-idempotent +- **Failure codes**: `INVALID_TARGET`, `NO_ADJACENT_SEQUENCE`, `INCOMPATIBLE_DEFINITIONS`, `ALREADY_SAME_SEQUENCE` + +### `lists.canJoin` + +Read-only preflight check for `lists.join`. Returns whether two adjacent sequences can be joined. + +- **Input**: `ListsCanJoinInput` (`{ target, direction: 'withPrevious' | 'withNext' }`) +- **Output**: `ListsCanJoinResult` (`{ canJoin, reason?, adjacentListId? }`) +- **Mutates**: No +- **Idempotency**: idempotent + +### `lists.separate` + +Split a list sequence at the target item. Creates a new `numId` pointing to the same `abstractNumId`. Items from target through end of sequence are reassigned to the new `numId`. Direct-only. Supports dry-run. + +- **Input**: `ListsSeparateInput` (`{ target, copyOverrides? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsSeparateResult` +- **Mutates**: Yes +- **Idempotency**: non-idempotent +- **Failure codes**: `INVALID_TARGET`, `NO_OP` + +### `lists.setLevel` -Change a list item's kind (`ordered` or `bullet`). Returns `NO_OP` when the item already has the requested kind. Direct-only. Supports dry-run. +Set the absolute indent level (0–8) of a list item. Direct-only. Supports dry-run. -- **Input**: `ListSetTypeInput` (`{ target, kind }`) +- **Input**: `ListsSetLevelInput` (`{ target, level }`) - **Options**: `MutationOptions` (`{ dryRun? }`) - **Output**: `ListsMutateItemResult` - **Mutates**: Yes - **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET` +- **Failure codes**: `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `NO_OP` ### `lists.indent` -Increase the indent level of a list item. Returns `NO_OP` when already at maximum depth. Direct-only. Supports dry-run. +Increase the indent level of a list item by one. Convenience wrapper for `setLevel(current + 1)`. Direct-only. Supports dry-run. - **Input**: `ListTargetInput` (`{ target }`) - **Options**: `MutationOptions` (`{ dryRun? }`) - **Output**: `ListsMutateItemResult` - **Mutates**: Yes - **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET` +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` ### `lists.outdent` -Decrease the indent level of a list item. Returns `NO_OP` when already at top level. Direct-only. Supports dry-run. +Decrease the indent level of a list item by one. Convenience wrapper for `setLevel(current - 1)`. Direct-only. Supports dry-run. - **Input**: `ListTargetInput` (`{ target }`) - **Options**: `MutationOptions` (`{ dryRun? }`) - **Output**: `ListsMutateItemResult` - **Mutates**: Yes - **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET` +- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` -### `lists.restart` +### `lists.setValue` -Restart numbering for an ordered list item. Returns `NO_OP` when the item already starts a new numbering sequence. Direct-only. Supports dry-run. +Set the numbering start value at the target item's position. Pass `value: null` to remove a previously set override. Mid-sequence targets atomically separate then set `startOverride`. Direct-only. Supports dry-run. -- **Input**: `ListTargetInput` (`{ target }`) +- **Input**: `ListsSetValueInput` (`{ target, value: number | null }`) - **Options**: `MutationOptions` (`{ dryRun? }`) - **Output**: `ListsMutateItemResult` - **Mutates**: Yes - **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET` +- **Failure codes**: `INVALID_TARGET`, `NO_OP` -### `lists.exit` +### `lists.continuePrevious` -Convert a list item back into a plain paragraph, exiting the list. Supports dry-run. Direct-only. +Continue numbering from the nearest previous compatible list sequence (same `abstractNumId`). Merges the target's sequence into that previous sequence's `numId`. Direct-only. Supports dry-run. -- **Input**: `ListTargetInput` (`{ target }`) +- **Input**: `ListsContinuePreviousInput` (`{ target }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `INVALID_TARGET`, `NO_COMPATIBLE_PREVIOUS`, `ALREADY_CONTINUOUS` + +### `lists.canContinuePrevious` + +Read-only preflight check for `lists.continuePrevious`. Returns whether a compatible previous sequence exists. + +- **Input**: `ListsCanContinuePreviousInput` (`{ target }`) +- **Output**: `ListsCanContinuePreviousResult` (`{ canContinue, reason?, previousListId? }`) +- **Mutates**: No +- **Idempotency**: idempotent + +### `lists.setLevelRestart` + +Set the `lvlRestart` behavior for a specified level. Controls when the level's counter resets. `scope: 'definition'` mutates the abstract (affects all instances); `scope: 'instance'` uses `lvlOverride` (affects only this `numId`). Direct-only. Supports dry-run. + +- **Input**: `ListsSetLevelRestartInput` (`{ target, level, restartAfterLevel: number | null, scope? }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `ListsMutateItemResult` +- **Mutates**: Yes +- **Idempotency**: conditional +- **Failure codes**: `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` + +### `lists.convertToText` + +Convert list items to plain paragraphs. When `includeMarker` is true, prepends the rendered marker text to paragraph content before clearing numbering properties. Direct-only. Supports dry-run. + +- **Input**: `ListsConvertToTextInput` (`{ target, includeMarker? }`) - **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsExitResult` +- **Output**: `ListsConvertToTextResult` - **Mutates**: Yes - **Idempotency**: conditional - **Failure codes**: `INVALID_TARGET` diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 1e8a0bb144..9823d43da7 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -1042,20 +1042,49 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'lists/insert.mdx', referenceGroup: 'lists', }, - 'lists.setType': { - memberPath: 'lists.setType', - description: 'Change the list type (ordered, unordered) of a target list.', - expectedResult: - 'Returns a ListsMutateItemResult receipt; reports NO_OP if the list already has the requested type.', + 'lists.create': { + memberPath: 'lists.create', + description: 'Create a new list from one or more paragraphs, or convert existing paragraphs into a new list.', + expectedResult: 'Returns a ListsCreateResult with the new listId and the first item address.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'LEVEL_OUT_OF_RANGE'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/create.mdx', + referenceGroup: 'lists', + }, + 'lists.attach': { + memberPath: 'lists.attach', + description: 'Convert non-list paragraphs to list items under an existing list sequence.', + expectedResult: 'Returns a ListsMutateItemResult confirming attachment.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/attach.mdx', + referenceGroup: 'lists', + }, + 'lists.detach': { + memberPath: 'lists.detach', + description: 'Remove numbering properties from list items, converting them to plain paragraphs.', + expectedResult: 'Returns a ListsDetachResult confirming the item was converted to a plain paragraph.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: true, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + possibleFailureCodes: ['INVALID_TARGET'], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], }), - referenceDocPath: 'lists/set-type.mdx', + referenceDocPath: 'lists/detach.mdx', referenceGroup: 'lists', }, 'lists.indent': { @@ -1068,7 +1097,7 @@ export const OPERATION_DEFINITIONS = { idempotency: 'conditional', supportsDryRun: true, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE'], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], }), referenceDocPath: 'lists/indent.mdx', @@ -1083,32 +1112,136 @@ export const OPERATION_DEFINITIONS = { idempotency: 'conditional', supportsDryRun: true, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'LEVEL_OUT_OF_RANGE'], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], }), referenceDocPath: 'lists/outdent.mdx', referenceGroup: 'lists', }, - 'lists.restart': { - memberPath: 'lists.restart', - description: 'Restart numbering of an ordered list at the target item.', - expectedResult: - 'Returns a ListsMutateItemResult receipt; reports NO_OP if numbering already restarts at the target item.', + 'lists.join': { + memberPath: 'lists.join', + description: 'Merge two adjacent list sequences into one.', + expectedResult: 'Returns a ListsJoinResult with the resulting listId of the merged sequence.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: [ + 'INVALID_TARGET', + 'NO_ADJACENT_SEQUENCE', + 'INCOMPATIBLE_DEFINITIONS', + 'ALREADY_SAME_SEQUENCE', + ], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/join.mdx', + referenceGroup: 'lists', + }, + 'lists.canJoin': { + memberPath: 'lists.canJoin', + description: 'Check whether two adjacent list sequences can be joined.', + expectedResult: 'Returns a ListsCanJoinResult indicating feasibility and reason if not possible.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/can-join.mdx', + referenceGroup: 'lists', + }, + 'lists.separate': { + memberPath: 'lists.separate', + description: 'Split a list sequence at the target item, creating a new sequence from that point forward.', + expectedResult: 'Returns a ListsSeparateResult with the new listId and numId.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/separate.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevel': { + memberPath: 'lists.setLevel', + description: 'Set the absolute nesting level (0..8) of a list item.', + expectedResult: 'Returns a ListsMutateItemResult receipt; reports NO_OP if already at the target level.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'LEVEL_OUT_OF_RANGE', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-level.mdx', + referenceGroup: 'lists', + }, + 'lists.setValue': { + memberPath: 'lists.setValue', + description: + 'Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first.', + expectedResult: 'Returns a ListsMutateItemResult receipt.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/set-value.mdx', + referenceGroup: 'lists', + }, + 'lists.continuePrevious': { + memberPath: 'lists.continuePrevious', + description: 'Continue numbering from the nearest compatible previous list sequence.', + expectedResult: 'Returns a ListsMutateItemResult receipt.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_COMPATIBLE_PREVIOUS', 'ALREADY_CONTINUOUS'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/continue-previous.mdx', + referenceGroup: 'lists', + }, + 'lists.canContinuePrevious': { + memberPath: 'lists.canContinuePrevious', + description: 'Check whether the target sequence can continue numbering from a previous compatible sequence.', + expectedResult: 'Returns a ListsCanContinuePreviousResult indicating feasibility.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'lists/can-continue-previous.mdx', + referenceGroup: 'lists', + }, + 'lists.setLevelRestart': { + memberPath: 'lists.setLevelRestart', + description: 'Set the restart behavior for a specific list level.', + expectedResult: 'Returns a ListsMutateItemResult receipt.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: true, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + possibleFailureCodes: ['INVALID_TARGET', 'LEVEL_OUT_OF_RANGE'], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], }), - referenceDocPath: 'lists/restart.mdx', + referenceDocPath: 'lists/set-level-restart.mdx', referenceGroup: 'lists', }, - 'lists.exit': { - memberPath: 'lists.exit', - description: 'Exit a list context, converting the target item to a paragraph.', - expectedResult: 'Returns a ListsExitResult confirming the item was converted to a plain paragraph.', + 'lists.convertToText': { + memberPath: 'lists.convertToText', + description: 'Convert list items to plain paragraphs, optionally prepending the rendered marker text.', + expectedResult: 'Returns a ListsConvertToTextResult confirming the conversion.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', @@ -1117,7 +1250,7 @@ export const OPERATION_DEFINITIONS = { possibleFailureCodes: ['INVALID_TARGET'], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], }), - referenceDocPath: 'lists/exit.mdx', + referenceDocPath: 'lists/convert-to-text.mdx', referenceGroup: 'lists', }, diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 7ba96bc91d..d6c1c2b105 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -48,10 +48,27 @@ import type { ListItemInfo, ListInsertInput, ListsInsertResult, - ListSetTypeInput, ListsMutateItemResult, ListTargetInput, - ListsExitResult, + ListsCreateInput, + ListsCreateResult, + ListsAttachInput, + ListsDetachInput, + ListsDetachResult, + ListsJoinInput, + ListsJoinResult, + ListsCanJoinInput, + ListsCanJoinResult, + ListsSeparateInput, + ListsSeparateResult, + ListsSetLevelInput, + ListsSetValueInput, + ListsContinuePreviousInput, + ListsCanContinuePreviousInput, + ListsCanContinuePreviousResult, + ListsSetLevelRestartInput, + ListsConvertToTextInput, + ListsConvertToTextResult, } from '../lists/lists.types.js'; import type { ParagraphMutationResult, @@ -312,11 +329,32 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'lists.list': { input: ListsListQuery | undefined; options: never; output: ListsListResult }; 'lists.get': { input: ListsGetInput; options: never; output: ListItemInfo }; 'lists.insert': { input: ListInsertInput; options: MutationOptions; output: ListsInsertResult }; - 'lists.setType': { input: ListSetTypeInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.create': { input: ListsCreateInput; options: MutationOptions; output: ListsCreateResult }; + 'lists.attach': { input: ListsAttachInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.detach': { input: ListsDetachInput; options: MutationOptions; output: ListsDetachResult }; 'lists.indent': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult }; 'lists.outdent': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult }; - 'lists.restart': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult }; - 'lists.exit': { input: ListTargetInput; options: MutationOptions; output: ListsExitResult }; + 'lists.join': { input: ListsJoinInput; options: MutationOptions; output: ListsJoinResult }; + 'lists.canJoin': { input: ListsCanJoinInput; options: never; output: ListsCanJoinResult }; + 'lists.separate': { input: ListsSeparateInput; options: MutationOptions; output: ListsSeparateResult }; + 'lists.setLevel': { input: ListsSetLevelInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.setValue': { input: ListsSetValueInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.continuePrevious': { + input: ListsContinuePreviousInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.canContinuePrevious': { + input: ListsCanContinuePreviousInput; + options: never; + output: ListsCanContinuePreviousResult; + }; + 'lists.setLevelRestart': { + input: ListsSetLevelRestartInput; + options: MutationOptions; + output: ListsMutateItemResult; + }; + 'lists.convertToText': { input: ListsConvertToTextInput; options: MutationOptions; output: ListsConvertToTextResult }; // --- sections.* --- 'sections.list': { input: SectionsListQuery | undefined; options: never; output: SectionsListResult }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 2b84eb8e80..8e5dd60873 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -327,6 +327,26 @@ const SHARED_DEFS: Record = { }, ['blockId', 'nodeType', 'range', 'text', 'ref', 'runs'], ), + + // -- Block-level address types (lists) -- + BlockAddress: objectSchema( + { + kind: { const: 'block' }, + nodeType: { const: 'paragraph' }, + nodeId: { type: 'string' }, + }, + ['kind', 'nodeType', 'nodeId'], + ), + BlockRange: objectSchema( + { + from: ref('BlockAddress'), + to: ref('BlockAddress'), + }, + ['from', 'to'], + ), + BlockAddressOrRange: { + oneOf: [ref('BlockAddress'), ref('BlockRange')], + }, }; // --------------------------------------------------------------------------- @@ -757,6 +777,7 @@ const listInsertPositionSchema: JsonSchema = { enum: ['before', 'after'] }; const listItemInfoSchema = objectSchema( { address: listItemAddressSchema, + listId: { type: 'string' }, marker: { type: 'string' }, ordinal: { type: 'integer' }, path: arraySchema({ type: 'integer' }), @@ -764,12 +785,13 @@ const listItemInfoSchema = objectSchema( kind: listKindSchema, text: { type: 'string' }, }, - ['address'], + ['address', 'listId'], ); const listItemDomainItemSchema = discoveryItemSchema( { address: listItemAddressSchema, + listId: { type: 'string' }, marker: { type: 'string' }, ordinal: { type: 'integer' }, path: arraySchema({ type: 'integer' }), @@ -777,7 +799,7 @@ const listItemDomainItemSchema = discoveryItemSchema( kind: listKindSchema, text: { type: 'string' }, }, - ['address'], + ['address', 'listId'], ); const listsListResultSchema = discoveryResultSchema(listItemDomainItemSchema); @@ -2358,17 +2380,67 @@ const operationSchemas: Record = { success: listsInsertSuccessSchema, failure: listsFailureSchemaFor('lists.insert'), }, - 'lists.setType': { + 'lists.create': { + input: { + type: 'object', + properties: { + mode: { enum: ['empty', 'fromParagraphs'] }, + at: ref('BlockAddress'), + target: ref('BlockAddressOrRange'), + kind: listKindSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + }, + required: ['mode', 'kind'], + additionalProperties: false, + if: { properties: { mode: { const: 'empty' } } }, + then: { required: ['mode', 'kind', 'at'] }, + else: { required: ['mode', 'kind', 'target'] }, + }, + output: { + oneOf: [ + objectSchema({ success: { const: true }, listId: { type: 'string' }, item: listItemAddressSchema }, [ + 'success', + 'listId', + 'item', + ]), + listsFailureSchemaFor('lists.create'), + ], + }, + success: objectSchema({ success: { const: true }, listId: { type: 'string' }, item: listItemAddressSchema }, [ + 'success', + 'listId', + 'item', + ]), + failure: listsFailureSchemaFor('lists.create'), + }, + 'lists.attach': { input: objectSchema( { - target: listItemAddressSchema, - kind: listKindSchema, + target: ref('BlockAddressOrRange'), + attachTo: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, }, - ['target', 'kind'], + ['target', 'attachTo'], ), - output: listsMutateItemResultSchemaFor('lists.setType'), + output: listsMutateItemResultSchemaFor('lists.attach'), success: listsMutateItemSuccessSchema, - failure: listsFailureSchemaFor('lists.setType'), + failure: listsFailureSchemaFor('lists.attach'), + }, + 'lists.detach': { + input: objectSchema( + { + target: listItemAddressSchema, + }, + ['target'], + ), + output: { + oneOf: [ + objectSchema({ success: { const: true }, paragraph: ref('ParagraphAddress') }, ['success', 'paragraph']), + listsFailureSchemaFor('lists.detach'), + ], + }, + success: objectSchema({ success: { const: true }, paragraph: ref('ParagraphAddress') }, ['success', 'paragraph']), + failure: listsFailureSchemaFor('lists.detach'), }, 'lists.indent': { input: objectSchema( @@ -2392,27 +2464,146 @@ const operationSchemas: Record = { success: listsMutateItemSuccessSchema, failure: listsFailureSchemaFor('lists.outdent'), }, - 'lists.restart': { + 'lists.join': { + input: objectSchema( + { + target: listItemAddressSchema, + direction: { enum: ['withPrevious', 'withNext'] }, + }, + ['target', 'direction'], + ), + output: { + oneOf: [ + objectSchema({ success: { const: true }, listId: { type: 'string' } }, ['success', 'listId']), + listsFailureSchemaFor('lists.join'), + ], + }, + success: objectSchema({ success: { const: true }, listId: { type: 'string' } }, ['success', 'listId']), + failure: listsFailureSchemaFor('lists.join'), + }, + 'lists.canJoin': { + input: objectSchema( + { + target: listItemAddressSchema, + direction: { enum: ['withPrevious', 'withNext'] }, + }, + ['target', 'direction'], + ), + output: objectSchema( + { + canJoin: { type: 'boolean' }, + reason: { enum: ['NO_ADJACENT_SEQUENCE', 'INCOMPATIBLE_DEFINITIONS', 'ALREADY_SAME_SEQUENCE'] }, + adjacentListId: { type: 'string' }, + }, + ['canJoin'], + ), + }, + 'lists.separate': { + input: objectSchema( + { + target: listItemAddressSchema, + copyOverrides: { type: 'boolean' }, + }, + ['target'], + ), + output: { + oneOf: [ + objectSchema({ success: { const: true }, listId: { type: 'string' }, numId: { type: 'integer' } }, [ + 'success', + 'listId', + 'numId', + ]), + listsFailureSchemaFor('lists.separate'), + ], + }, + success: objectSchema({ success: { const: true }, listId: { type: 'string' }, numId: { type: 'integer' } }, [ + 'success', + 'listId', + 'numId', + ]), + failure: listsFailureSchemaFor('lists.separate'), + }, + 'lists.setLevel': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + }, + ['target', 'level'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevel'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevel'), + }, + 'lists.setValue': { + input: objectSchema( + { + target: listItemAddressSchema, + value: { type: ['integer', 'null'] }, + }, + ['target', 'value'], + ), + output: listsMutateItemResultSchemaFor('lists.setValue'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setValue'), + }, + 'lists.continuePrevious': { input: objectSchema( { target: listItemAddressSchema, }, ['target'], ), - output: listsMutateItemResultSchemaFor('lists.restart'), + output: listsMutateItemResultSchemaFor('lists.continuePrevious'), success: listsMutateItemSuccessSchema, - failure: listsFailureSchemaFor('lists.restart'), + failure: listsFailureSchemaFor('lists.continuePrevious'), }, - 'lists.exit': { + 'lists.canContinuePrevious': { input: objectSchema( { target: listItemAddressSchema, }, ['target'], ), - output: listsExitResultSchemaFor('lists.exit'), - success: listsExitSuccessSchema, - failure: listsFailureSchemaFor('lists.exit'), + output: objectSchema( + { + canContinue: { type: 'boolean' }, + reason: { enum: ['NO_PREVIOUS_LIST', 'INCOMPATIBLE_DEFINITIONS', 'ALREADY_CONTINUOUS'] }, + previousListId: { type: 'string' }, + }, + ['canContinue'], + ), + }, + 'lists.setLevelRestart': { + input: objectSchema( + { + target: listItemAddressSchema, + level: { type: 'integer', minimum: 0, maximum: 8 }, + restartAfterLevel: { type: ['integer', 'null'] }, + scope: { enum: ['definition', 'instance'] }, + }, + ['target', 'level', 'restartAfterLevel'], + ), + output: listsMutateItemResultSchemaFor('lists.setLevelRestart'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setLevelRestart'), + }, + 'lists.convertToText': { + input: objectSchema( + { + target: listItemAddressSchema, + includeMarker: { type: 'boolean' }, + }, + ['target'], + ), + output: { + oneOf: [ + objectSchema({ success: { const: true }, paragraph: ref('ParagraphAddress') }, ['success', 'paragraph']), + listsFailureSchemaFor('lists.convertToText'), + ], + }, + success: objectSchema({ success: { const: true }, paragraph: ref('ParagraphAddress') }, ['success', 'paragraph']), + failure: listsFailureSchemaFor('lists.convertToText'), }, 'comments.create': { input: objectSchema( diff --git a/packages/document-api/src/contract/types.test.ts b/packages/document-api/src/contract/types.test.ts index 3d0d8f799e..fc34505742 100644 --- a/packages/document-api/src/contract/types.test.ts +++ b/packages/document-api/src/contract/types.test.ts @@ -11,7 +11,7 @@ describe('isValidOperationIdFormat', () => { it('accepts namespaced identifiers (namespace.camelCase)', () => { expect(isValidOperationIdFormat('comments.create')).toBe(true); expect(isValidOperationIdFormat('trackChanges.list')).toBe(true); - expect(isValidOperationIdFormat('lists.setType')).toBe(true); + expect(isValidOperationIdFormat('lists.create')).toBe(true); }); it('accepts three-segment identifiers (group.subgroup.camelCase)', () => { diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 238c1f4cbb..7b7f14ba49 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -177,23 +177,61 @@ function makeListsAdapter(): ListsAdapter { item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-2' }, insertionPoint: { kind: 'text' as const, blockId: 'li-2', range: { start: 0, end: 0 } }, })), - setType: vi.fn(() => ({ + indent: vi.fn(() => ({ success: true as const, item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, })), - indent: vi.fn(() => ({ + outdent: vi.fn(() => ({ success: true as const, item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, })), - outdent: vi.fn(() => ({ + create: vi.fn(() => ({ + success: true as const, + listId: '99', + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + attach: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + detach: vi.fn(() => ({ + success: true as const, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }, + })), + join: vi.fn(() => ({ + success: true as const, + listId: '1', + })), + canJoin: vi.fn(() => ({ + canJoin: true as const, + adjacentListId: '2', + })), + separate: vi.fn(() => ({ + success: true as const, + listId: '99', + numId: 99, + })), + setLevel: vi.fn(() => ({ success: true as const, item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, })), - restart: vi.fn(() => ({ + setValue: vi.fn(() => ({ success: true as const, item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, })), - exit: vi.fn(() => ({ + continuePrevious: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + canContinuePrevious: vi.fn(() => ({ + canContinue: true as const, + previousListId: '1', + })), + setLevelRestart: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + convertToText: vi.fn(() => ({ success: true as const, paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }, })), @@ -971,20 +1009,24 @@ describe('createDocumentApi', () => { const listResult = api.lists.list({ limit: 1 }); const getResult = api.lists.get({ address: target }); const insertResult = api.lists.insert({ target, position: 'after', text: 'Inserted' }, { changeMode: 'tracked' }); - const setTypeResult = api.lists.setType({ target, kind: 'bullet' }); const indentResult = api.lists.indent({ target }); const outdentResult = api.lists.outdent({ target }); - const restartResult = api.lists.restart({ target }); - const exitResult = api.lists.exit({ target }); + const createResult = api.lists.create({ + mode: 'empty', + at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' }, + kind: 'ordered', + }); + const detachResult = api.lists.detach({ target }); + const setLevelResult = api.lists.setLevel({ target, level: 2 }); expect(listResult.total).toBe(0); expect(getResult.address).toEqual(target); expect(insertResult.success).toBe(true); - expect(setTypeResult.success).toBe(true); expect(indentResult.success).toBe(true); expect(outdentResult.success).toBe(true); - expect(restartResult.success).toBe(true); - expect(exitResult.success).toBe(true); + expect(createResult.success).toBe(true); + expect(detachResult.success).toBe(true); + expect(setLevelResult.success).toBe(true); expect(listsAdpt.list).toHaveBeenCalledWith({ limit: 1 }); expect(listsAdpt.get).toHaveBeenCalledWith({ address: target }); @@ -992,11 +1034,10 @@ describe('createDocumentApi', () => { { target, position: 'after', text: 'Inserted' }, { changeMode: 'tracked', dryRun: false }, ); - expect(listsAdpt.setType).toHaveBeenCalledWith({ target, kind: 'bullet' }, { changeMode: 'direct', dryRun: false }); expect(listsAdpt.indent).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); expect(listsAdpt.outdent).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); - expect(listsAdpt.restart).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); - expect(listsAdpt.exit).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + expect(listsAdpt.detach).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + expect(listsAdpt.setLevel).toHaveBeenCalledWith({ target, level: 2 }, { changeMode: 'direct', dryRun: false }); }); it('exposes capabilities as a callable function with .get() alias', () => { @@ -2122,9 +2163,9 @@ describe('createDocumentApi', () => { expect(result.success).toBe(true); }); - it('accepts canonical target for lists.setType', () => { + it('accepts canonical target for lists.setLevel', () => { const api = makeApi(); - const result = api.lists.setType({ target, kind: 'bullet' }); + const result = api.lists.setLevel({ target, level: 2 }); expect(result.success).toBe(true); }); @@ -2137,12 +2178,26 @@ describe('createDocumentApi', () => { // -- All list mutation operations validate -- - const LISTS_MUTATIONS = ['outdent', 'restart', 'exit'] as const; + const LISTS_MUTATIONS = [ + 'outdent', + 'detach', + 'setValue', + 'continuePrevious', + 'setLevelRestart', + 'convertToText', + ] as const; for (const method of LISTS_MUTATIONS) { it(`accepts canonical target for lists.${method}`, () => { const api = makeApi(); - const result = api.lists[method]({ target }); - expect(result.success).toBe(true); + const result = ( + api.lists[method] as (input: { + target: typeof target; + level?: number; + value?: number; + restartAfterLevel?: number | null; + }) => unknown + )({ target, level: 0, value: 1, restartAfterLevel: null }); + expect(result).toBeDefined(); }); } diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 7e50aec709..c1782a87df 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -76,24 +76,50 @@ import type { ListsAdapter, ListsApi } from './lists/lists.js'; import type { ListItemInfo, ListInsertInput, - ListSetTypeInput, - ListsExitResult, ListsGetInput, ListsInsertResult, ListsListQuery, ListsListResult, ListsMutateItemResult, ListTargetInput, + ListsCreateInput, + ListsCreateResult, + ListsAttachInput, + ListsDetachInput, + ListsDetachResult, + ListsJoinInput, + ListsJoinResult, + ListsCanJoinInput, + ListsCanJoinResult, + ListsSeparateInput, + ListsSeparateResult, + ListsSetLevelInput, + ListsSetValueInput, + ListsContinuePreviousInput, + ListsCanContinuePreviousInput, + ListsCanContinuePreviousResult, + ListsSetLevelRestartInput, + ListsConvertToTextInput, + ListsConvertToTextResult, } from './lists/lists.types.js'; import { - executeListsExit, executeListsGet, executeListsIndent, executeListsInsert, executeListsList, executeListsOutdent, - executeListsRestart, - executeListsSetType, + executeListsCreate, + executeListsAttach, + executeListsDetach, + executeListsJoin, + executeListsCanJoin, + executeListsSeparate, + executeListsSetLevel, + executeListsSetValue, + executeListsContinuePrevious, + executeListsCanContinuePrevious, + executeListsSetLevelRestart, + executeListsConvertToText, } from './lists/lists.js'; import { executeReplace, type ReplaceInput } from './replace/replace.js'; import type { CreateAdapter, CreateApi } from './create/create.js'; @@ -467,20 +493,44 @@ export { LINE_RULES, } from './paragraphs/paragraphs.js'; export type { + BlockAddress, + BlockRange, + CanContinueReason, + CanJoinReason, + JoinDirection, ListInsertInput, ListItemAddress, ListItemInfo, ListKind, - ListsExitResult, + ListsAttachInput, + ListsCanContinuePreviousInput, + ListsCanContinuePreviousResult, + ListsCanJoinInput, + ListsCanJoinResult, + ListsConvertToTextInput, + ListsConvertToTextResult, + ListsContinuePreviousInput, + ListsCreateInput, + ListsCreateResult, + ListsDetachInput, + ListsDetachResult, + ListsFailureCode, ListsGetInput, ListsInsertResult, + ListsJoinInput, + ListsJoinResult, ListsListQuery, ListsListResult, ListsMutateItemResult, - ListSetTypeInput, + ListsSeparateInput, + ListsSeparateResult, + ListsSetLevelInput, + ListsSetLevelRestartInput, + ListsSetValueInput, ListTargetInput, + MutationScope, } from './lists/lists.types.js'; -export { LIST_KINDS, LIST_INSERT_POSITIONS } from './lists/lists.types.js'; +export { LIST_KINDS, LIST_INSERT_POSITIONS, JOIN_DIRECTIONS, MUTATION_SCOPES } from './lists/lists.types.js'; export type { CreateSectionBreakInput, CreateSectionBreakResult, @@ -967,8 +1017,14 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { insert(input: ListInsertInput, options?: MutationOptions): ListsInsertResult { return executeListsInsert(adapters.lists, input, options); }, - setType(input: ListSetTypeInput, options?: MutationOptions): ListsMutateItemResult { - return executeListsSetType(adapters.lists, input, options); + create(input: ListsCreateInput, options?: MutationOptions): ListsCreateResult { + return executeListsCreate(adapters.lists, input, options); + }, + attach(input: ListsAttachInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsAttach(adapters.lists, input, options); + }, + detach(input: ListsDetachInput, options?: MutationOptions): ListsDetachResult { + return executeListsDetach(adapters.lists, input, options); }, indent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult { return executeListsIndent(adapters.lists, input, options); @@ -976,11 +1032,32 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { outdent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult { return executeListsOutdent(adapters.lists, input, options); }, - restart(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult { - return executeListsRestart(adapters.lists, input, options); + join(input: ListsJoinInput, options?: MutationOptions): ListsJoinResult { + return executeListsJoin(adapters.lists, input, options); + }, + canJoin(input: ListsCanJoinInput): ListsCanJoinResult { + return executeListsCanJoin(adapters.lists, input); + }, + separate(input: ListsSeparateInput, options?: MutationOptions): ListsSeparateResult { + return executeListsSeparate(adapters.lists, input, options); + }, + setLevel(input: ListsSetLevelInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevel(adapters.lists, input, options); + }, + setValue(input: ListsSetValueInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetValue(adapters.lists, input, options); + }, + continuePrevious(input: ListsContinuePreviousInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsContinuePrevious(adapters.lists, input, options); + }, + canContinuePrevious(input: ListsCanContinuePreviousInput): ListsCanContinuePreviousResult { + return executeListsCanContinuePrevious(adapters.lists, input); + }, + setLevelRestart(input: ListsSetLevelRestartInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetLevelRestart(adapters.lists, input, options); }, - exit(input: ListTargetInput, options?: MutationOptions): ListsExitResult { - return executeListsExit(adapters.lists, input, options); + convertToText(input: ListsConvertToTextInput, options?: MutationOptions): ListsConvertToTextResult { + return executeListsConvertToText(adapters.lists, input, options); }, }, sections: { diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 3d70094024..9b18d133bd 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -112,11 +112,20 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'lists.list': (input) => api.lists.list(input), 'lists.get': (input) => api.lists.get(input), 'lists.insert': (input, options) => api.lists.insert(input, options), - 'lists.setType': (input, options) => api.lists.setType(input, options), + 'lists.create': (input, options) => api.lists.create(input, options), + 'lists.attach': (input, options) => api.lists.attach(input, options), + 'lists.detach': (input, options) => api.lists.detach(input, options), 'lists.indent': (input, options) => api.lists.indent(input, options), 'lists.outdent': (input, options) => api.lists.outdent(input, options), - 'lists.restart': (input, options) => api.lists.restart(input, options), - 'lists.exit': (input, options) => api.lists.exit(input, options), + 'lists.join': (input, options) => api.lists.join(input, options), + 'lists.canJoin': (input) => api.lists.canJoin(input), + 'lists.separate': (input, options) => api.lists.separate(input, options), + 'lists.setLevel': (input, options) => api.lists.setLevel(input, options), + 'lists.setValue': (input, options) => api.lists.setValue(input, options), + 'lists.continuePrevious': (input, options) => api.lists.continuePrevious(input, options), + 'lists.canContinuePrevious': (input) => api.lists.canContinuePrevious(input), + 'lists.setLevelRestart': (input, options) => api.lists.setLevelRestart(input, options), + 'lists.convertToText': (input, options) => api.lists.convertToText(input, options), // --- sections.* --- 'sections.list': (input) => api.sections.list(input), diff --git a/packages/document-api/src/lists/lists.ts b/packages/document-api/src/lists/lists.ts index b5202e9024..07310c6e28 100644 --- a/packages/document-api/src/lists/lists.ts +++ b/packages/document-api/src/lists/lists.ts @@ -3,8 +3,6 @@ import { normalizeMutationOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; import type { ListInsertInput, - ListSetTypeInput, - ListsExitResult, ListsGetInput, ListsInsertResult, ListsListQuery, @@ -12,11 +10,29 @@ import type { ListsMutateItemResult, ListTargetInput, ListItemInfo, + ListsCreateInput, + ListsCreateResult, + ListsAttachInput, + ListsDetachInput, + ListsDetachResult, + ListsJoinInput, + ListsJoinResult, + ListsCanJoinInput, + ListsCanJoinResult, + ListsSeparateInput, + ListsSeparateResult, + ListsSetLevelInput, + ListsSetValueInput, + ListsContinuePreviousInput, + ListsCanContinuePreviousInput, + ListsCanContinuePreviousResult, + ListsSetLevelRestartInput, + ListsConvertToTextInput, + ListsConvertToTextResult, } from './lists.types.js'; + export type { ListInsertInput, - ListSetTypeInput, - ListsExitResult, ListsGetInput, ListsInsertResult, ListsListQuery, @@ -24,6 +40,25 @@ export type { ListsMutateItemResult, ListTargetInput, ListItemInfo, + ListsCreateInput, + ListsCreateResult, + ListsAttachInput, + ListsDetachInput, + ListsDetachResult, + ListsJoinInput, + ListsJoinResult, + ListsCanJoinInput, + ListsCanJoinResult, + ListsSeparateInput, + ListsSeparateResult, + ListsSetLevelInput, + ListsSetValueInput, + ListsContinuePreviousInput, + ListsCanContinuePreviousInput, + ListsCanContinuePreviousResult, + ListsSetLevelRestartInput, + ListsConvertToTextInput, + ListsConvertToTextResult, } from './lists.types.js'; /** @@ -35,27 +70,41 @@ function validateListTarget(input: { target?: unknown }, operationName: string): } } +// --------------------------------------------------------------------------- +// Adapter interface +// --------------------------------------------------------------------------- + export interface ListsAdapter { - /** List items matching the given query. */ + // Discovery list(query?: ListsListQuery): ListsListResult; - /** Retrieve full information for a single list item. */ get(input: ListsGetInput): ListItemInfo; - /** Insert a new list item relative to the target. */ + + // Kept operations insert(input: ListInsertInput, options?: MutationOptions): ListsInsertResult; - /** Change the list kind (ordered/bullet) for the target item. */ - setType(input: ListSetTypeInput, options?: MutationOptions): ListsMutateItemResult; - /** Increase the nesting level of the target item. */ indent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; - /** Decrease the nesting level of the target item. */ outdent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; - /** Restart numbering at the target item. */ - restart(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; - /** Exit the list, converting the target item to a plain paragraph. */ - exit(input: ListTargetInput, options?: MutationOptions): ListsExitResult; + + // SD-1272 new operations + create(input: ListsCreateInput, options?: MutationOptions): ListsCreateResult; + attach(input: ListsAttachInput, options?: MutationOptions): ListsMutateItemResult; + detach(input: ListsDetachInput, options?: MutationOptions): ListsDetachResult; + join(input: ListsJoinInput, options?: MutationOptions): ListsJoinResult; + canJoin(input: ListsCanJoinInput): ListsCanJoinResult; + separate(input: ListsSeparateInput, options?: MutationOptions): ListsSeparateResult; + setLevel(input: ListsSetLevelInput, options?: MutationOptions): ListsMutateItemResult; + setValue(input: ListsSetValueInput, options?: MutationOptions): ListsMutateItemResult; + continuePrevious(input: ListsContinuePreviousInput, options?: MutationOptions): ListsMutateItemResult; + canContinuePrevious(input: ListsCanContinuePreviousInput): ListsCanContinuePreviousResult; + setLevelRestart(input: ListsSetLevelRestartInput, options?: MutationOptions): ListsMutateItemResult; + convertToText(input: ListsConvertToTextInput, options?: MutationOptions): ListsConvertToTextResult; } export type ListsApi = ListsAdapter; +// --------------------------------------------------------------------------- +// Execute wrappers — discovery +// --------------------------------------------------------------------------- + export function executeListsList(adapter: ListsAdapter, query?: ListsListQuery): ListsListResult { return adapter.list(query); } @@ -64,6 +113,10 @@ export function executeListsGet(adapter: ListsAdapter, input: ListsGetInput): Li return adapter.get(input); } +// --------------------------------------------------------------------------- +// Execute wrappers — kept operations +// --------------------------------------------------------------------------- + export function executeListsInsert( adapter: ListsAdapter, input: ListInsertInput, @@ -73,15 +126,6 @@ export function executeListsInsert( return adapter.insert(input, normalizeMutationOptions(options)); } -export function executeListsSetType( - adapter: ListsAdapter, - input: ListSetTypeInput, - options?: MutationOptions, -): ListsMutateItemResult { - validateListTarget(input, 'lists.setType'); - return adapter.setType(input, normalizeMutationOptions(options)); -} - export function executeListsIndent( adapter: ListsAdapter, input: ListTargetInput, @@ -100,20 +144,108 @@ export function executeListsOutdent( return adapter.outdent(input, normalizeMutationOptions(options)); } -export function executeListsRestart( +// --------------------------------------------------------------------------- +// Execute wrappers — SD-1272 new operations +// --------------------------------------------------------------------------- + +export function executeListsCreate( adapter: ListsAdapter, - input: ListTargetInput, + input: ListsCreateInput, + options?: MutationOptions, +): ListsCreateResult { + return adapter.create(input, normalizeMutationOptions(options)); +} + +export function executeListsAttach( + adapter: ListsAdapter, + input: ListsAttachInput, options?: MutationOptions, ): ListsMutateItemResult { - validateListTarget(input, 'lists.restart'); - return adapter.restart(input, normalizeMutationOptions(options)); + validateListTarget(input, 'lists.attach'); + return adapter.attach(input, normalizeMutationOptions(options)); } -export function executeListsExit( +export function executeListsDetach( adapter: ListsAdapter, - input: ListTargetInput, + input: ListsDetachInput, + options?: MutationOptions, +): ListsDetachResult { + validateListTarget(input, 'lists.detach'); + return adapter.detach(input, normalizeMutationOptions(options)); +} + +export function executeListsJoin( + adapter: ListsAdapter, + input: ListsJoinInput, + options?: MutationOptions, +): ListsJoinResult { + validateListTarget(input, 'lists.join'); + return adapter.join(input, normalizeMutationOptions(options)); +} + +export function executeListsCanJoin(adapter: ListsAdapter, input: ListsCanJoinInput): ListsCanJoinResult { + validateListTarget(input, 'lists.canJoin'); + return adapter.canJoin(input); +} + +export function executeListsSeparate( + adapter: ListsAdapter, + input: ListsSeparateInput, + options?: MutationOptions, +): ListsSeparateResult { + validateListTarget(input, 'lists.separate'); + return adapter.separate(input, normalizeMutationOptions(options)); +} + +export function executeListsSetLevel( + adapter: ListsAdapter, + input: ListsSetLevelInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevel'); + return adapter.setLevel(input, normalizeMutationOptions(options)); +} + +export function executeListsSetValue( + adapter: ListsAdapter, + input: ListsSetValueInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setValue'); + return adapter.setValue(input, normalizeMutationOptions(options)); +} + +export function executeListsContinuePrevious( + adapter: ListsAdapter, + input: ListsContinuePreviousInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.continuePrevious'); + return adapter.continuePrevious(input, normalizeMutationOptions(options)); +} + +export function executeListsCanContinuePrevious( + adapter: ListsAdapter, + input: ListsCanContinuePreviousInput, +): ListsCanContinuePreviousResult { + validateListTarget(input, 'lists.canContinuePrevious'); + return adapter.canContinuePrevious(input); +} + +export function executeListsSetLevelRestart( + adapter: ListsAdapter, + input: ListsSetLevelRestartInput, + options?: MutationOptions, +): ListsMutateItemResult { + validateListTarget(input, 'lists.setLevelRestart'); + return adapter.setLevelRestart(input, normalizeMutationOptions(options)); +} + +export function executeListsConvertToText( + adapter: ListsAdapter, + input: ListsConvertToTextInput, options?: MutationOptions, -): ListsExitResult { - validateListTarget(input, 'lists.exit'); - return adapter.exit(input, normalizeMutationOptions(options)); +): ListsConvertToTextResult { + validateListTarget(input, 'lists.convertToText'); + return adapter.convertToText(input, normalizeMutationOptions(options)); } diff --git a/packages/document-api/src/lists/lists.types.ts b/packages/document-api/src/lists/lists.types.ts index 236a2b86a2..0e786f191f 100644 --- a/packages/document-api/src/lists/lists.types.ts +++ b/packages/document-api/src/lists/lists.types.ts @@ -1,22 +1,72 @@ import type { BlockNodeType, ReceiptFailure, ReceiptInsert, TextAddress } from '../types/index.js'; import type { DiscoveryOutput } from '../types/discovery.js'; +// --------------------------------------------------------------------------- +// Address types +// --------------------------------------------------------------------------- + export type ListItemAddress = { kind: 'block'; nodeType: 'listItem'; nodeId: string; }; +/** Any block-level paragraph, whether or not it is a list item. */ +export type BlockAddress = { + kind: 'block'; + nodeType: 'paragraph'; + nodeId: string; +}; + +/** Contiguous range of paragraphs. */ +export type BlockRange = { + from: BlockAddress; + to: BlockAddress; +}; + export type ListWithinAddress = { kind: 'block'; nodeType: BlockNodeType; nodeId: string; }; + +// --------------------------------------------------------------------------- +// Enums and constants +// --------------------------------------------------------------------------- + export type ListKind = 'ordered' | 'bullet'; export type ListInsertPosition = 'before' | 'after'; +export type JoinDirection = 'withPrevious' | 'withNext'; +export type MutationScope = 'definition' | 'instance'; export const LIST_KINDS = ['ordered', 'bullet'] as const satisfies readonly ListKind[]; export const LIST_INSERT_POSITIONS = ['before', 'after'] as const satisfies readonly ListInsertPosition[]; +export const JOIN_DIRECTIONS = ['withPrevious', 'withNext'] as const satisfies readonly JoinDirection[]; +export const MUTATION_SCOPES = ['definition', 'instance'] as const satisfies readonly MutationScope[]; + +// --------------------------------------------------------------------------- +// Failure code enums +// --------------------------------------------------------------------------- + +export type ListsFailureCode = + | 'NO_OP' + | 'INVALID_TARGET' + | 'INCOMPATIBLE_DEFINITIONS' + | 'NO_COMPATIBLE_PREVIOUS' + | 'ALREADY_CONTINUOUS' + | 'NO_PREVIOUS_LIST' + | 'NO_ADJACENT_SEQUENCE' + | 'ALREADY_SAME_SEQUENCE' + | 'LEVEL_OUT_OF_RANGE' + | 'CAPABILITY_UNAVAILABLE'; + +export type CanContinueReason = 'NO_PREVIOUS_LIST' | 'INCOMPATIBLE_DEFINITIONS' | 'ALREADY_CONTINUOUS'; + +export type CanJoinReason = 'NO_ADJACENT_SEQUENCE' | 'INCOMPATIBLE_DEFINITIONS' | 'ALREADY_SAME_SEQUENCE'; + +// --------------------------------------------------------------------------- +// Discovery / query types +// --------------------------------------------------------------------------- export interface ListsListQuery { within?: ListWithinAddress; @@ -33,6 +83,7 @@ export interface ListsGetInput { export interface ListItemInfo { address: ListItemAddress; + listId: string; marker?: string; ordinal?: number; path?: number[]; @@ -41,11 +92,9 @@ export interface ListItemInfo { text?: string; } -/** - * Domain fields for a list-item discovery item (C3b). - */ export interface ListItemDomain { address: ListItemAddress; + listId: string; marker?: string; ordinal?: number; path?: number[]; @@ -54,11 +103,12 @@ export interface ListItemDomain { text?: string; } -/** - * Standardized discovery output for `lists.list`. - */ export type ListsListResult = DiscoveryOutput; +// --------------------------------------------------------------------------- +// Input types — kept operations +// --------------------------------------------------------------------------- + export interface ListInsertInput { target: ListItemAddress; position: ListInsertPosition; @@ -69,10 +119,73 @@ export interface ListTargetInput { target: ListItemAddress; } -export interface ListSetTypeInput extends ListTargetInput { - kind: ListKind; +// --------------------------------------------------------------------------- +// Input types — new SD-1272 operations +// --------------------------------------------------------------------------- + +export type ListsCreateInput = + | { mode: 'empty'; at: BlockAddress; kind: ListKind; level?: number } + | { mode: 'fromParagraphs'; target: BlockAddress | BlockRange; kind: ListKind; level?: number }; + +export interface ListsAttachInput { + target: BlockAddress | BlockRange; + attachTo: ListItemAddress; + level?: number; +} + +export interface ListsDetachInput { + target: ListItemAddress; +} + +export interface ListsJoinInput { + target: ListItemAddress; + direction: JoinDirection; +} + +export interface ListsCanJoinInput { + target: ListItemAddress; + direction: JoinDirection; +} + +export interface ListsSeparateInput { + target: ListItemAddress; + copyOverrides?: boolean; +} + +export interface ListsSetLevelInput { + target: ListItemAddress; + level: number; +} + +export interface ListsSetValueInput { + target: ListItemAddress; + value: number | null; } +export interface ListsContinuePreviousInput { + target: ListItemAddress; +} + +export interface ListsCanContinuePreviousInput { + target: ListItemAddress; +} + +export interface ListsSetLevelRestartInput { + target: ListItemAddress; + level: number; + restartAfterLevel: number | null; + scope?: MutationScope; +} + +export interface ListsConvertToTextInput { + target: ListItemAddress; + includeMarker?: boolean; +} + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + export interface ListsInsertSuccessResult { success: true; item: ListItemAddress; @@ -85,7 +198,24 @@ export interface ListsMutateItemSuccessResult { item: ListItemAddress; } -export interface ListsExitSuccessResult { +export interface ListsCreateSuccessResult { + success: true; + listId: string; + item: ListItemAddress; +} + +export interface ListsJoinSuccessResult { + success: true; + listId: string; +} + +export interface ListsSeparateSuccessResult { + success: true; + listId: string; + numId: number; +} + +export interface ListsDetachSuccessResult { success: true; paragraph: { kind: 'block'; @@ -94,6 +224,27 @@ export interface ListsExitSuccessResult { }; } +export interface ListsConvertToTextSuccessResult { + success: true; + paragraph: { + kind: 'block'; + nodeType: 'paragraph'; + nodeId: string; + }; +} + +export interface ListsCanJoinResult { + canJoin: boolean; + reason?: CanJoinReason; + adjacentListId?: string; +} + +export interface ListsCanContinuePreviousResult { + canContinue: boolean; + reason?: CanContinueReason; + previousListId?: string; +} + export interface ListsFailureResult { success: false; failure: ReceiptFailure; @@ -101,4 +252,8 @@ export interface ListsFailureResult { export type ListsInsertResult = ListsInsertSuccessResult | ListsFailureResult; export type ListsMutateItemResult = ListsMutateItemSuccessResult | ListsFailureResult; -export type ListsExitResult = ListsExitSuccessResult | ListsFailureResult; +export type ListsCreateResult = ListsCreateSuccessResult | ListsFailureResult; +export type ListsJoinResult = ListsJoinSuccessResult | ListsFailureResult; +export type ListsSeparateResult = ListsSeparateSuccessResult | ListsFailureResult; +export type ListsDetachResult = ListsDetachSuccessResult | ListsFailureResult; +export type ListsConvertToTextResult = ListsConvertToTextSuccessResult | ListsFailureResult; diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index cfe2dca230..871b89b489 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -252,11 +252,11 @@ function makeCapabilitiesAdapter(): { get: ReturnType } { 'lists.list', 'lists.get', 'lists.insert', - 'lists.setType', + 'lists.create', 'lists.indent', 'lists.outdent', - 'lists.restart', - 'lists.exit', + 'lists.detach', + 'lists.attach', 'comments.create', 'comments.patch', 'comments.delete', @@ -579,7 +579,6 @@ describe('src/README.md workflow examples', () => { const firstItem = lists.items[0].address; const insertResult = doc.lists.insert({ target: firstItem, position: 'after', text: 'New item' }); if (insertResult.success) { - doc.lists.setType({ target: insertResult.item, kind: 'ordered' }); doc.lists.indent({ target: insertResult.item }); } diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index 515c52aff2..27f2708234 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -24,7 +24,15 @@ export type ReceiptFailureCode = | 'DOCUMENT_IDENTITY_CONFLICT' | 'UNSUPPORTED_ENVIRONMENT' | 'INTERNAL_ERROR' - | 'PAGE_NUMBERS_NOT_MATERIALIZED'; + | 'PAGE_NUMBERS_NOT_MATERIALIZED' + // Lists-specific failure codes (SD-1272) + | 'INCOMPATIBLE_DEFINITIONS' + | 'NO_COMPATIBLE_PREVIOUS' + | 'ALREADY_CONTINUOUS' + | 'NO_PREVIOUS_LIST' + | 'NO_ADJACENT_SEQUENCE' + | 'ALREADY_SAME_SEQUENCE' + | 'LEVEL_OUT_OF_RANGE'; export type ReceiptFailure = { code: ReceiptFailureCode; diff --git a/packages/sdk/langs/node/README.md b/packages/sdk/langs/node/README.md index 79283561d0..15dbca14dd 100644 --- a/packages/sdk/langs/node/README.md +++ b/packages/sdk/langs/node/README.md @@ -82,7 +82,7 @@ client.doc.insert(params) | **Mutation** | `insert`, `replace`, `delete` | | **Format** | `format.bold`, `format.italic`, `format.underline`, `format.strikethrough` | | **Create** | `create.paragraph` | -| **Lists** | `lists.list`, `lists.get`, `lists.insert`, `lists.setType`, `lists.indent`, `lists.outdent`, `lists.restart`, `lists.exit` | +| **Lists** | `lists.list`, `lists.get`, `lists.insert`, `lists.create`, `lists.attach`, `lists.detach`, `lists.indent`, `lists.outdent`, `lists.join`, `lists.separate`, `lists.setLevel`, `lists.setValue`, `lists.continuePrevious`, `lists.setLevelRestart`, `lists.convertToText`, `lists.canJoin`, `lists.canContinuePrevious` | | **Comments** | `comments.create`, `comments.patch`, `comments.delete`, `comments.get`, `comments.list` | | **Track Changes** | `trackChanges.list`, `trackChanges.get`, `trackChanges.decide` | | **Lifecycle** | `open`, `save`, `close` | diff --git a/packages/sdk/langs/python/README.md b/packages/sdk/langs/python/README.md index 34840f5dcc..0d90a31590 100644 --- a/packages/sdk/langs/python/README.md +++ b/packages/sdk/langs/python/README.md @@ -131,7 +131,7 @@ client.doc.insert(params) | **Mutation** | `insert`, `replace`, `delete` | | **Format** | `format.bold`, `format.italic`, `format.underline`, `format.strikethrough` | | **Create** | `create.paragraph` | -| **Lists** | `lists.list`, `lists.get`, `lists.insert`, `lists.set_type`, `lists.indent`, `lists.outdent`, `lists.restart`, `lists.exit` | +| **Lists** | `lists.list`, `lists.get`, `lists.insert`, `lists.create`, `lists.attach`, `lists.detach`, `lists.indent`, `lists.outdent`, `lists.join`, `lists.separate`, `lists.set_level`, `lists.set_value`, `lists.continue_previous`, `lists.set_level_restart`, `lists.convert_to_text`, `lists.can_join`, `lists.can_continue_previous` | | **Comments** | `comments.create`, `comments.patch`, `comments.delete`, `comments.get`, `comments.list` | | **Track Changes** | `track_changes.list`, `track_changes.get`, `track_changes.decide` | | **Lifecycle** | `open`, `save`, `close` | diff --git a/packages/super-editor/src/core/commands/restartNumbering.js b/packages/super-editor/src/core/commands/restartNumbering.js index c3b75fbfab..66110c3fc6 100644 --- a/packages/super-editor/src/core/commands/restartNumbering.js +++ b/packages/super-editor/src/core/commands/restartNumbering.js @@ -1,54 +1,27 @@ import { findParentNode } from '@helpers/index.js'; import { isList } from '@core/commands/list-helpers'; import { ListHelpers } from '@helpers/list-numbering-helpers.js'; -import { updateNumberingProperties } from './changeListLevel.js'; import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; +/** + * Restart numbering for the current list by setting a startOverride on the + * existing w:num definition. + * + * This uses the OOXML-correct pattern: w:lvlOverride/w:startOverride on the + * existing w:num. Paragraphs keep their numId — only the definition is mutated. + * This preserves list identity, makes join possible, and produces correct OOXML + * on export. + * + * Note: This command is being replaced by `lists.setValue` in SD-1272 Phase 3. + */ export const restartNumbering = ({ editor, tr, state, dispatch }) => { - // 1) Find the current list item - const { node: paragraph, pos } = findParentNode(isList)(state.selection) || {}; - - // 2) If not found, return false + const { node: paragraph } = findParentNode(isList)(state.selection) || {}; if (!paragraph) return false; - // 3) Find all consecutive list items of the same type following the current one - const allParagraphs = [{ node: paragraph, pos }]; - const startPos = pos + paragraph.nodeSize; - const myNumId = getResolvedParagraphProperties(paragraph).numberingProperties.numId; - let stop = false; - state.doc.nodesBetween(startPos, state.doc.content.size, (node, nodePos) => { - if (node.type.name === 'paragraph') { - const paraProps = getResolvedParagraphProperties(node); - if (isList(node) && paraProps.numberingProperties?.numId === myNumId) { - allParagraphs.push({ node, pos: nodePos }); - } else { - stop = true; - } - return false; - } - return !stop; - }); - - // 4) Create a new numId for the restarted list and generate its definition - const { numberingType } = paragraph.attrs.listRendering || {}; - const listType = numberingType === 'bullet' ? 'bulletList' : 'orderedList'; - const numId = ListHelpers.getNewListId(editor); - ListHelpers.generateNewListDefinition({ numId: Number(numId), listType, editor }); + const { numId, ilvl = 0 } = getResolvedParagraphProperties(paragraph).numberingProperties || {}; + if (numId == null) return false; - // 5) Update numbering properties for all found paragraphs - allParagraphs.forEach(({ node, pos }) => { - const paragraphProps = getResolvedParagraphProperties(node); - updateNumberingProperties( - { - ...(paragraphProps.numberingProperties || {}), - numId: Number(numId), - }, - node, - pos, - editor, - tr, - ); - }); + ListHelpers.setLvlOverride(editor, numId, ilvl, { startOverride: 1 }); if (dispatch) dispatch(tr); return true; diff --git a/packages/super-editor/src/core/commands/restartNumbering.test.js b/packages/super-editor/src/core/commands/restartNumbering.test.js index ab4c5aecfe..76b9ae2cdb 100644 --- a/packages/super-editor/src/core/commands/restartNumbering.test.js +++ b/packages/super-editor/src/core/commands/restartNumbering.test.js @@ -4,7 +4,6 @@ import { restartNumbering } from './restartNumbering.js'; import { findParentNode } from '@helpers/index.js'; import { isList } from '@core/commands/list-helpers'; import { ListHelpers } from '@helpers/list-numbering-helpers.js'; -import { updateNumberingProperties } from './changeListLevel.js'; vi.mock(import('@helpers/index.js'), async (importOriginal) => { const actual = await importOriginal(); @@ -20,15 +19,10 @@ vi.mock('@core/commands/list-helpers', () => ({ vi.mock('@helpers/list-numbering-helpers.js', () => ({ ListHelpers: { - getNewListId: vi.fn(), - generateNewListDefinition: vi.fn(), + setLvlOverride: vi.fn(), }, })); -vi.mock('./changeListLevel.js', () => ({ - updateNumberingProperties: vi.fn(), -})); - vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ getResolvedParagraphProperties: vi.fn((node) => { return node?.attrs?.paragraphProperties || { numberingProperties: null }; @@ -38,22 +32,19 @@ vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ describe('restartNumbering', () => { /** @type {ReturnType} */ let resolveParent; - /** @type {{ doc: { nodesBetween: (from: number, to: number, cb: Function) => void, content: { size: number } }, selection: any }} */ + /** @type {any} */ let state; /** @type {any} */ let tr; - /** @type {Record} */ + /** @type {any} */ let editor; /** @type {ReturnType} */ let dispatch; - /** @type {Array<{ node: any, pos: number, shouldStop?: boolean }>} */ - let nodesBetweenSequence; - const createParagraph = ({ numId, numberingType = 'decimal', addParagraphProps = true, ilvl = 0 }) => ({ + const createParagraph = ({ numId, ilvl = 0 }) => ({ type: { name: 'paragraph' }, attrs: { - paragraphProperties: addParagraphProps ? { numberingProperties: { numId, ilvl } } : undefined, - listRendering: { numberingType }, + paragraphProperties: { numberingProperties: { numId, ilvl } }, }, nodeSize: 4, }); @@ -64,25 +55,11 @@ describe('restartNumbering', () => { resolveParent = vi.fn(); findParentNode.mockReturnValue(resolveParent); - nodesBetweenSequence = []; - state = { - doc: { - content: { size: 100 }, - nodesBetween: (_start, _end, cb) => { - for (const entry of nodesBetweenSequence) { - cb(entry.node, entry.pos); - if (entry.shouldStop) break; - } - }, - }, - selection: {}, - }; - + state = { selection: {} }; tr = {}; editor = {}; dispatch = vi.fn(); - ListHelpers.getNewListId.mockReturnValue('42'); isList.mockReturnValue(true); }); @@ -92,88 +69,69 @@ describe('restartNumbering', () => { const result = restartNumbering({ editor, tr, state, dispatch }); expect(result).toBe(false); - expect(ListHelpers.getNewListId).not.toHaveBeenCalled(); - expect(updateNumberingProperties).not.toHaveBeenCalled(); + expect(ListHelpers.setLvlOverride).not.toHaveBeenCalled(); expect(dispatch).not.toHaveBeenCalled(); }); - it('restarts numbering for the current ordered list chain', () => { - const firstParagraph = createParagraph({ numId: 7, numberingType: 'decimal' }); - resolveParent.mockReturnValue({ node: firstParagraph, pos: 5 }); + it('returns false when paragraph has no numId', () => { + const paragraph = { + type: { name: 'paragraph' }, + attrs: { paragraphProperties: { numberingProperties: null } }, + }; + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); - nodesBetweenSequence = [ - { - node: createParagraph({ numId: 7, numberingType: 'decimal' }), - pos: 10, - }, - { - node: createParagraph({ numId: 7, numberingType: 'decimal' }), - pos: 15, - }, - // different numId should stop aggregation - { - node: createParagraph({ numId: 99, numberingType: 'decimal', addParagraphProps: true }), - pos: 20, - shouldStop: true, - }, - ]; + const result = restartNumbering({ editor, tr, state, dispatch }); + + expect(result).toBe(false); + expect(ListHelpers.setLvlOverride).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('sets startOverride on the existing numId and dispatches', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); const result = restartNumbering({ editor, tr, state, dispatch }); expect(result).toBe(true); - expect(ListHelpers.getNewListId).toHaveBeenCalledTimes(1); - expect(ListHelpers.generateNewListDefinition).toHaveBeenCalledWith({ - numId: 42, - listType: 'orderedList', - editor, - }); - expect(updateNumberingProperties).toHaveBeenCalledTimes(3); - const [firstCall, secondCall, thirdCall] = updateNumberingProperties.mock.calls; - expect(firstCall[0]).toEqual({ numId: 42, ilvl: 0 }); - expect(firstCall[1]).toBe(firstParagraph); - expect(firstCall[2]).toBe(5); - expect(firstCall[3]).toBe(editor); - expect(firstCall[4]).toBe(tr); - - expect(secondCall[0]).toEqual({ numId: 42, ilvl: 0 }); - expect(secondCall[2]).toBe(10); - expect(thirdCall[0]).toEqual({ numId: 42, ilvl: 0 }); - expect(thirdCall[2]).toBe(15); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 7, 0, { startOverride: 1 }); expect(dispatch).toHaveBeenCalledWith(tr); }); - it('applies bullet list type and stops when encountering a non-list node', () => { - const firstParagraph = createParagraph({ numId: 3, numberingType: 'bullet' }); - resolveParent.mockReturnValue({ node: firstParagraph, pos: 2 }); + it('uses the correct ilvl from paragraph properties', () => { + const paragraph = createParagraph({ numId: 3, ilvl: 2 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 10 }); + + const result = restartNumbering({ editor, tr, state, dispatch }); + + expect(result).toBe(true); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 3, 2, { startOverride: 1 }); + expect(dispatch).toHaveBeenCalledWith(tr); + }); - const matchingParagraph = createParagraph({ numId: 3, numberingType: 'bullet' }); - const nonListParagraph = { + it('defaults ilvl to 0 when not specified', () => { + const paragraph = { type: { name: 'paragraph' }, - attrs: { paragraphProperties: { numberingProperties: { numId: 3 } } }, + attrs: { + paragraphProperties: { numberingProperties: { numId: 5 } }, + }, + nodeSize: 4, }; - nodesBetweenSequence = [ - { node: matchingParagraph, pos: 6 }, - // simulate a non-list paragraph followed by another matching entry that should be ignored - { node: nonListParagraph, pos: 12, shouldStop: true }, - { node: createParagraph({ numId: 3, numberingType: 'bullet' }), pos: 18 }, - ]; - - isList.mockImplementation((node) => { - if (node === matchingParagraph || node === firstParagraph) return true; - if (node === nonListParagraph) return false; - return true; - }); + resolveParent.mockReturnValue({ node: paragraph, pos: 3 }); const result = restartNumbering({ editor, tr, state, dispatch }); expect(result).toBe(true); - expect(ListHelpers.generateNewListDefinition).toHaveBeenCalledWith({ - numId: 42, - listType: 'bulletList', - editor, - }); - expect(updateNumberingProperties).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith(tr); - expect(isList).toHaveBeenCalledWith(nonListParagraph); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 5, 0, { startOverride: 1 }); + }); + + it('does not dispatch when dispatch is not provided', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); + + const result = restartNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 7, 0, { startOverride: 1 }); }); }); diff --git a/packages/super-editor/src/core/helpers/list-numbering-helpers.js b/packages/super-editor/src/core/helpers/list-numbering-helpers.js index dddca4c64c..748d731df4 100644 --- a/packages/super-editor/src/core/helpers/list-numbering-helpers.js +++ b/packages/super-editor/src/core/helpers/list-numbering-helpers.js @@ -43,7 +43,7 @@ export const generateNewListDefinition = ({ numId, listType, level, start, text, ); // Generate the new abstractNum definition for copy/paste lists - if (level && start && text && fmt) { + if (level != null && start != null && text != null && fmt != null) { if (newNumbering.definitions[numId]) { const abstractId = newNumbering.definitions[numId]?.elements[0]?.attributes['w:val']; newAbstractId = abstractId; @@ -498,6 +498,244 @@ export const replaceListWithNode = ({ tr, from, to, newNode }) => { tr.replaceWith(from, to, newNode); }; +/** + * Set or update a lvlOverride entry on an existing w:num definition. + * + * This is the canonical write path for per-instance level overrides (w:lvlOverride). + * It syncs both the raw XML model (editor.converter.numbering) and the typed model + * (editor.converter.translatedNumbering), then emits 'list-definitions-change' so the + * numbering plugin recomputes markers. + * + * @param {import('../Editor').Editor} editor + * @param {number} numId - The w:num to modify. + * @param {number} ilvl - The level index (0-8) for the override. + * @param {{ startOverride?: number, lvlRestart?: number | null }} overrides - Override values to set. + */ +export const setLvlOverride = (editor, numId, ilvl, overrides) => { + const numbering = editor.converter.numbering; + const numDef = numbering.definitions[numId]; + if (!numDef) return; + + // --- Raw XML update --- + const ilvlStr = String(ilvl); + + // Find or create the w:lvlOverride element for this level + if (!numDef.elements) numDef.elements = []; + let overrideEl = numDef.elements.find((el) => el.name === 'w:lvlOverride' && el.attributes?.['w:ilvl'] === ilvlStr); + + if (!overrideEl) { + overrideEl = { + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': ilvlStr }, + elements: [], + }; + numDef.elements.push(overrideEl); + } + + if (!overrideEl.elements) overrideEl.elements = []; + + // Set startOverride if provided + if (overrides.startOverride != null) { + const startEl = overrideEl.elements.find((el) => el.name === 'w:startOverride'); + if (startEl) { + startEl.attributes['w:val'] = String(overrides.startOverride); + } else { + overrideEl.elements.push({ + type: 'element', + name: 'w:startOverride', + attributes: { 'w:val': String(overrides.startOverride) }, + }); + } + } + + // Set lvlRestart via a w:lvl child within the lvlOverride (instance-scope restart) + if ('lvlRestart' in overrides) { + let lvlEl = overrideEl.elements.find((el) => el.name === 'w:lvl'); + if (!lvlEl) { + lvlEl = { + type: 'element', + name: 'w:lvl', + attributes: { 'w:ilvl': ilvlStr }, + elements: [], + }; + overrideEl.elements.push(lvlEl); + } + if (!lvlEl.elements) lvlEl.elements = []; + + if (overrides.lvlRestart === null) { + lvlEl.elements = lvlEl.elements.filter((el) => el.name !== 'w:lvlRestart'); + } else { + const restartEl = lvlEl.elements.find((el) => el.name === 'w:lvlRestart'); + if (restartEl) { + restartEl.attributes['w:val'] = String(overrides.lvlRestart); + } else { + lvlEl.elements.push({ + type: 'element', + name: 'w:lvlRestart', + attributes: { 'w:val': String(overrides.lvlRestart) }, + }); + } + } + } + + // Persist raw XML + numbering.definitions[numId] = numDef; + editor.converter.numbering = { ...numbering }; + + // --- Typed model update --- + syncTranslatedDefinition(editor, numId, numDef); + + // --- Notify --- + emitDefinitionChange(editor, numDef); +}; + +/** + * Remove a lvlOverride entry from an existing w:num definition. + * + * Restores the level to its base abstract behavior by deleting the + * w:lvlOverride element for the specified level. + * + * @param {import('../Editor').Editor} editor + * @param {number} numId - The w:num to modify. + * @param {number} ilvl - The level index (0-8) whose override to remove. + */ +export const removeLvlOverride = (editor, numId, ilvl) => { + const numbering = editor.converter.numbering; + const numDef = numbering.definitions[numId]; + if (!numDef?.elements) return; + + const ilvlStr = String(ilvl); + const idx = numDef.elements.findIndex((el) => el.name === 'w:lvlOverride' && el.attributes?.['w:ilvl'] === ilvlStr); + if (idx === -1) return; + + numDef.elements.splice(idx, 1); + + // Persist raw XML + numbering.definitions[numId] = numDef; + editor.converter.numbering = { ...numbering }; + + // --- Typed model update --- + syncTranslatedDefinition(editor, numId, numDef); + + // --- Notify --- + emitDefinitionChange(editor, numDef); +}; + +/** + * Re-encode a raw w:num node into the typed model and persist it. + * @param {import('../Editor').Editor} editor + * @param {number} numId + * @param {Object} rawNumDef - The raw XML w:num node. + */ +const syncTranslatedDefinition = (editor, numId, rawNumDef) => { + const translated = { ...(editor.converter.translatedNumbering || {}) }; + if (!translated.definitions) translated.definitions = {}; + // @ts-expect-error Remaining parameters are not needed for this translator + translated.definitions[numId] = wNumTranslator.encode({ nodes: [rawNumDef] }); + editor.converter.translatedNumbering = translated; +}; + +/** + * Emit the standard numbering change event so the numbering plugin recomputes. + * @param {import('../Editor').Editor} editor + * @param {Object} numDef - The modified w:num raw node. + */ +const emitDefinitionChange = (editor, numDef) => { + editor.emit('list-definitions-change', { + change: { numDef, editor }, + numbering: editor.converter.numbering, + editor, + }); +}; + +/** + * Create a new w:num definition pointing to an existing abstractNumId. + * Optionally copies lvlOverride entries from a source numId. + * + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId - The abstractNumId to reference. + * @param {{ copyOverridesFrom?: number }} [options] + * @returns {{ numId: number, numDef: Object }} + */ +export const createNumDefinition = (editor, abstractNumId, options = {}) => { + const numId = getNewListId(editor, 'definitions'); + const numDef = getBasicNumIdTag(numId, abstractNumId); + + if (options.copyOverridesFrom != null) { + const sourceNumDef = editor.converter.numbering.definitions[options.copyOverridesFrom]; + if (sourceNumDef?.elements) { + const overrideEls = sourceNumDef.elements.filter((el) => el.name === 'w:lvlOverride'); + if (overrideEls.length > 0) { + numDef.elements = [...numDef.elements, ...JSON.parse(JSON.stringify(overrideEls))]; + } + } + } + + const numbering = editor.converter.numbering; + numbering.definitions[numId] = numDef; + editor.converter.numbering = { ...numbering }; + + syncTranslatedDefinition(editor, numId, numDef); + emitDefinitionChange(editor, numDef); + + return { numId, numDef }; +}; + +/** + * Set or remove w:lvlRestart on a w:lvl within a w:abstractNum definition. + * Affects ALL numId instances sharing this abstract (definition-scope). + * + * @param {import('../Editor').Editor} editor + * @param {number} abstractNumId + * @param {number} ilvl - Level index (0-8). + * @param {number | null} restartAfterLevel - Level to restart after, or null to remove. + */ +export const setLvlRestartOnAbstract = (editor, abstractNumId, ilvl, restartAfterLevel) => { + const numbering = editor.converter.numbering; + const abstract = numbering.abstracts[abstractNumId]; + if (!abstract?.elements) return; + + const ilvlStr = String(ilvl); + const lvlEl = abstract.elements.find((el) => el.name === 'w:lvl' && el.attributes?.['w:ilvl'] === ilvlStr); + if (!lvlEl) return; + if (!lvlEl.elements) lvlEl.elements = []; + + if (restartAfterLevel === null) { + lvlEl.elements = lvlEl.elements.filter((el) => el.name !== 'w:lvlRestart'); + } else { + const restartEl = lvlEl.elements.find((el) => el.name === 'w:lvlRestart'); + if (restartEl) { + restartEl.attributes['w:val'] = String(restartAfterLevel); + } else { + lvlEl.elements.push({ + type: 'element', + name: 'w:lvlRestart', + attributes: { 'w:val': String(restartAfterLevel) }, + }); + } + } + + numbering.abstracts[abstractNumId] = abstract; + editor.converter.numbering = { ...numbering }; + + // Re-encode the abstract in the translated model + const translated = { ...(editor.converter.translatedNumbering || {}) }; + if (!translated.abstracts) translated.abstracts = {}; + // @ts-expect-error Remaining parameters are not needed for this translator + translated.abstracts[abstractNumId] = wAbstractNumTranslator.encode({ nodes: [abstract] }); + editor.converter.translatedNumbering = translated; + + // Emit change for all numIds referencing this abstract + const definitions = numbering.definitions || {}; + for (const [, numDef] of Object.entries(definitions)) { + const absId = numDef?.elements?.find((el) => el.name === 'w:abstractNumId')?.attributes?.['w:val']; + if (absId != null && Number(absId) === abstractNumId) { + emitDefinitionChange(editor, numDef); + } + } +}; + /** * ListHelpers is a collection of utility functions for managing lists in the editor. * It includes functions for creating, modifying, and retrieving list items and definitions, @@ -515,6 +753,14 @@ export const ListHelpers = { hasListDefinition, removeListDefinitions, + // lvlOverride helpers + setLvlOverride, + removeLvlOverride, + + // Numbering definition helpers + createNumDefinition, + setLvlRestartOnAbstract, + // Schema helpers createNewList, createSchemaOrderedListNode, diff --git a/packages/super-editor/src/core/helpers/list-numbering-helpers.test.js b/packages/super-editor/src/core/helpers/list-numbering-helpers.test.js index b2e1f6eebc..625d4b3d72 100644 --- a/packages/super-editor/src/core/helpers/list-numbering-helpers.test.js +++ b/packages/super-editor/src/core/helpers/list-numbering-helpers.test.js @@ -853,6 +853,67 @@ describe('getListDefinitionDetails', () => { }); }); + describe('generateNewListDefinition', () => { + it('applies level overrides when level is 0', () => { + const original = ListHelpers.generateNewListDefinition; + generateNewListDefinitionSpy.mockRestore(); + const callThroughSpy = vi + .spyOn(ListHelpers, 'generateNewListDefinition') + .mockImplementation((args) => original(args)); + + const editor = { + converter: { + numbering: { + definitions: { + 10: { + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '100' } }], + }, + }, + abstracts: { + 100: { + attributes: { 'w:abstractNumId': '100' }, + elements: [ + { + name: 'w:lvl', + attributes: { 'w:ilvl': 0 }, + elements: [ + { name: 'w:start', attributes: { 'w:val': 1 } }, + { name: 'w:numFmt', attributes: { 'w:val': 'decimal' } }, + { name: 'w:lvlText', attributes: { 'w:val': '%1.' } }, + ], + }, + ], + }, + }, + }, + translatedNumbering: { + definitions: {}, + abstracts: {}, + }, + }, + emit: vi.fn(), + }; + + ListHelpers.generateNewListDefinition({ + numId: 10, + listType: 'orderedList', + level: 0, + start: 5, + text: '%1.', + fmt: 'decimal', + editor, + }); + + const levelZero = editor.converter.numbering.abstracts[100].elements.find( + (el) => el.name === 'w:lvl' && el.attributes['w:ilvl'] === 0, + ); + const start = levelZero.elements.find((el) => el.name === 'w:start'); + expect(start.attributes['w:val']).toBe(5); + + callThroughSpy.mockRestore(); + }); + }); + describe('getAllListDefinitions', () => { it('should include cloned list definitions even when translatedNumbering is stale', () => { mockEditor.converter.numbering.definitions[1] = { @@ -935,6 +996,350 @@ describe('getListDefinitionDetails', () => { }); }); +describe('setLvlOverride', () => { + let mockEditor; + + beforeEach(() => { + vi.clearAllMocks(); + mockEditor = { + converter: { + numbering: { + definitions: { + 5: { + type: 'element', + name: 'w:num', + attributes: { 'w:numId': '5' }, + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '2' } }], + }, + }, + abstracts: { + 2: { + attributes: { 'w:abstractNumId': '2' }, + elements: [ + { + name: 'w:lvl', + attributes: { 'w:ilvl': '0' }, + elements: [ + { name: 'w:start', attributes: { 'w:val': '1' } }, + { name: 'w:numFmt', attributes: { 'w:val': 'decimal' } }, + ], + }, + ], + }, + }, + }, + translatedNumbering: { definitions: {}, abstracts: {} }, + }, + emit: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should add a startOverride to a w:num that has no lvlOverrides', () => { + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 3 }); + + const numDef = mockEditor.converter.numbering.definitions[5]; + const overrideEl = numDef.elements.find((el) => el.name === 'w:lvlOverride' && el.attributes['w:ilvl'] === '0'); + expect(overrideEl).toBeTruthy(); + + const startEl = overrideEl.elements.find((el) => el.name === 'w:startOverride'); + expect(startEl).toBeTruthy(); + expect(startEl.attributes['w:val']).toBe('3'); + }); + + it('should update an existing startOverride value', () => { + // First set + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 3 }); + // Update + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 10 }); + + const numDef = mockEditor.converter.numbering.definitions[5]; + const overrideEls = numDef.elements.filter((el) => el.name === 'w:lvlOverride' && el.attributes['w:ilvl'] === '0'); + // Should have exactly one override element, not two + expect(overrideEls).toHaveLength(1); + + const startEl = overrideEls[0].elements.find((el) => el.name === 'w:startOverride'); + expect(startEl.attributes['w:val']).toBe('10'); + }); + + it('should handle multiple levels independently', () => { + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 5 }); + listHelpers.setLvlOverride(mockEditor, 5, 1, { startOverride: 10 }); + + const numDef = mockEditor.converter.numbering.definitions[5]; + const lvl0 = numDef.elements.find((el) => el.name === 'w:lvlOverride' && el.attributes['w:ilvl'] === '0'); + const lvl1 = numDef.elements.find((el) => el.name === 'w:lvlOverride' && el.attributes['w:ilvl'] === '1'); + expect(lvl0.elements.find((el) => el.name === 'w:startOverride').attributes['w:val']).toBe('5'); + expect(lvl1.elements.find((el) => el.name === 'w:startOverride').attributes['w:val']).toBe('10'); + }); + + it('should sync translatedNumbering after setting override', () => { + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 3 }); + + const translated = mockEditor.converter.translatedNumbering.definitions[5]; + expect(translated).toBeTruthy(); + expect(translated.lvlOverrides).toBeTruthy(); + expect(translated.lvlOverrides[0]).toEqual(expect.objectContaining({ startOverride: 3 })); + }); + + it('should emit list-definitions-change event', () => { + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 3 }); + + expect(mockEditor.emit).toHaveBeenCalledWith( + 'list-definitions-change', + expect.objectContaining({ + numbering: mockEditor.converter.numbering, + editor: mockEditor, + }), + ); + }); + + it('should be a no-op for non-existent numId', () => { + listHelpers.setLvlOverride(mockEditor, 999, 0, { startOverride: 3 }); + + expect(mockEditor.emit).not.toHaveBeenCalled(); + }); +}); + +describe('removeLvlOverride', () => { + let mockEditor; + + beforeEach(() => { + vi.clearAllMocks(); + mockEditor = { + converter: { + numbering: { + definitions: { + 5: { + type: 'element', + name: 'w:num', + attributes: { 'w:numId': '5' }, + elements: [ + { name: 'w:abstractNumId', attributes: { 'w:val': '2' } }, + { + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '0' }, + elements: [{ name: 'w:startOverride', attributes: { 'w:val': '3' } }], + }, + { + type: 'element', + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '1' }, + elements: [{ name: 'w:startOverride', attributes: { 'w:val': '5' } }], + }, + ], + }, + }, + abstracts: { + 2: { + attributes: { 'w:abstractNumId': '2' }, + elements: [], + }, + }, + }, + translatedNumbering: { definitions: {}, abstracts: {} }, + }, + emit: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should remove the specified lvlOverride element', () => { + listHelpers.removeLvlOverride(mockEditor, 5, 0); + + const numDef = mockEditor.converter.numbering.definitions[5]; + const remaining = numDef.elements.filter((el) => el.name === 'w:lvlOverride'); + expect(remaining).toHaveLength(1); + expect(remaining[0].attributes['w:ilvl']).toBe('1'); + }); + + it('should not affect other levels', () => { + listHelpers.removeLvlOverride(mockEditor, 5, 0); + + const numDef = mockEditor.converter.numbering.definitions[5]; + const lvl1 = numDef.elements.find((el) => el.name === 'w:lvlOverride' && el.attributes['w:ilvl'] === '1'); + expect(lvl1).toBeTruthy(); + expect(lvl1.elements.find((el) => el.name === 'w:startOverride').attributes['w:val']).toBe('5'); + }); + + it('should sync translatedNumbering after removal', () => { + listHelpers.removeLvlOverride(mockEditor, 5, 0); + + const translated = mockEditor.converter.translatedNumbering.definitions[5]; + expect(translated).toBeTruthy(); + }); + + it('should emit list-definitions-change event', () => { + listHelpers.removeLvlOverride(mockEditor, 5, 0); + + expect(mockEditor.emit).toHaveBeenCalledWith( + 'list-definitions-change', + expect.objectContaining({ + numbering: mockEditor.converter.numbering, + editor: mockEditor, + }), + ); + }); + + it('should be a no-op when the level has no override', () => { + listHelpers.removeLvlOverride(mockEditor, 5, 5); // level 5 doesn't exist + + expect(mockEditor.emit).not.toHaveBeenCalled(); + }); + + it('should be a no-op for non-existent numId', () => { + listHelpers.removeLvlOverride(mockEditor, 999, 0); + + expect(mockEditor.emit).not.toHaveBeenCalled(); + }); +}); + +describe('lvlOverride → getAllListDefinitions roundtrip', () => { + let mockEditor; + + beforeEach(() => { + vi.clearAllMocks(); + mockEditor = { + converter: { + numbering: { + definitions: { + 5: { + type: 'element', + name: 'w:num', + attributes: { 'w:numId': '5' }, + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '2' } }], + }, + }, + abstracts: { + 2: { + attributes: { 'w:abstractNumId': '2' }, + elements: [ + { + name: 'w:lvl', + attributes: { 'w:ilvl': '0' }, + elements: [ + { name: 'w:start', attributes: { 'w:val': '1' } }, + { name: 'w:numFmt', attributes: { 'w:val': 'decimal' } }, + { name: 'w:lvlText', attributes: { 'w:val': '%1.' } }, + ], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '1' }, + elements: [ + { name: 'w:start', attributes: { 'w:val': '1' } }, + { name: 'w:numFmt', attributes: { 'w:val': 'lowerLetter' } }, + { name: 'w:lvlText', attributes: { 'w:val': '%2)' } }, + ], + }, + ], + }, + }, + }, + translatedNumbering: { definitions: {}, abstracts: {} }, + }, + emit: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('getAllListDefinitions reflects startOverride set via setLvlOverride', () => { + // Before: no override + // The translator is mocked, so we need to set up translatedNumbering with abstract + mockEditor.converter.translatedNumbering = { + definitions: { 5: { abstractNumId: 2 } }, + abstracts: { + 2: { + levels: { + 0: { ilvl: 0, start: 1, numFmt: { val: 'decimal' }, lvlText: '%1.' }, + 1: { ilvl: 1, start: 1, numFmt: { val: 'lowerLetter' }, lvlText: '%2)' }, + }, + }, + }, + }; + + const before = listHelpers.getAllListDefinitions(mockEditor); + expect(before[5][0].startOverridden).toBe(false); + expect(before[5][0].start).toBe(1); + + // Set override + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 5 }); + + const after = listHelpers.getAllListDefinitions(mockEditor); + expect(after[5][0].startOverridden).toBe(true); + expect(after[5][0].start).toBe(5); + // Other level is unaffected + expect(after[5][1].startOverridden).toBe(false); + expect(after[5][1].start).toBe(1); + }); + + it('getAllListDefinitions reverts after removeLvlOverride', () => { + mockEditor.converter.translatedNumbering = { + definitions: { 5: { abstractNumId: 2 } }, + abstracts: { + 2: { + levels: { + 0: { ilvl: 0, start: 1, numFmt: { val: 'decimal' }, lvlText: '%1.' }, + }, + }, + }, + }; + + // Set then remove + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 10 }); + listHelpers.removeLvlOverride(mockEditor, 5, 0); + + const after = listHelpers.getAllListDefinitions(mockEditor); + expect(after[5][0].startOverridden).toBe(false); + expect(after[5][0].start).toBe(1); + }); + + it('raw XML structure is export-ready after setLvlOverride', () => { + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 3 }); + + // Verify the raw XML structure matches what the exporter writes + const numDef = mockEditor.converter.numbering.definitions[5]; + expect(numDef.name).toBe('w:num'); + expect(numDef.attributes['w:numId']).toBe('5'); + + // w:abstractNumId element preserved + const abstractEl = numDef.elements.find((el) => el.name === 'w:abstractNumId'); + expect(abstractEl.attributes['w:val']).toBe('2'); + + // w:lvlOverride added with correct structure + const overrideEl = numDef.elements.find((el) => el.name === 'w:lvlOverride'); + expect(overrideEl.attributes['w:ilvl']).toBe('0'); + expect(overrideEl.elements).toHaveLength(1); + + const startOverrideEl = overrideEl.elements[0]; + expect(startOverrideEl.name).toBe('w:startOverride'); + expect(startOverrideEl.attributes['w:val']).toBe('3'); + }); + + it('no spurious w:num entries are created by setLvlOverride', () => { + const definitionCountBefore = Object.keys(mockEditor.converter.numbering.definitions).length; + const abstractCountBefore = Object.keys(mockEditor.converter.numbering.abstracts).length; + + listHelpers.setLvlOverride(mockEditor, 5, 0, { startOverride: 1 }); + + const definitionCountAfter = Object.keys(mockEditor.converter.numbering.definitions).length; + const abstractCountAfter = Object.keys(mockEditor.converter.numbering.abstracts).length; + + expect(definitionCountAfter).toBe(definitionCountBefore); + expect(abstractCountAfter).toBe(abstractCountBefore); + }); +}); + vi.mock('@core/super-converter/v2/importer/listImporter.js', () => ({ getStyleTagFromStyleId: vi.fn(), getAbstractDefinition: vi.fn(), diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index f27b47d5d3..ecf696b706 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -15,6 +15,10 @@ import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js import { DocxHelpers } from './docx-helpers/index.js'; import { mergeRelationshipElements } from './relationship-helpers.js'; import { COMMENT_RELATIONSHIP_TYPES } from './constants.js'; +import { + collectReferencedNumIds, + filterOrphanedNumberingDefinitions, +} from './export-helpers/strip-orphaned-numbering.js'; const FONT_FAMILY_FALLBACKS = Object.freeze({ swiss: 'Arial, sans-serif', @@ -1246,14 +1250,15 @@ class SuperConverter { const numberingPath = 'word/numbering.xml'; let numberingXml = this.convertedXml[numberingPath]; - const newNumbering = this.numbering; - if (!numberingXml) numberingXml = baseNumbering; const currentNumberingXml = numberingXml.elements[0]; - const newAbstracts = Object.values(newNumbering.abstracts).map((entry) => entry); - const newNumDefs = Object.values(newNumbering.definitions).map((entry) => entry); - currentNumberingXml.elements = [...newAbstracts, ...newNumDefs]; + // D7: Strip orphaned numbering definitions (entries not referenced by any + // paragraph in the exported document parts). + const referencedNumIds = collectReferencedNumIds(this.convertedXml); + const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(this.numbering, referencedNumIds); + + currentNumberingXml.elements = [...liveAbstracts, ...liveDefinitions]; // Update the numbering file this.convertedXml[numberingPath] = numberingXml; diff --git a/packages/super-editor/src/core/super-converter/export-helpers/strip-orphaned-numbering.js b/packages/super-editor/src/core/super-converter/export-helpers/strip-orphaned-numbering.js new file mode 100644 index 0000000000..fbdbd4b9a3 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/export-helpers/strip-orphaned-numbering.js @@ -0,0 +1,76 @@ +/** + * Collect all w:numId values referenced in exported document parts. + * Walks all word/* XML entries except word/numbering.xml itself. + * + * @param {Record} convertedXml - The full set of exported XML-JSON objects + * @returns {Set} Set of numId values referenced in the document + */ +export function collectReferencedNumIds(convertedXml) { + const numIds = new Set(); + + function walkElements(elements) { + if (!Array.isArray(elements)) return; + for (const el of elements) { + if (el.name === 'w:numId' && el.attributes?.['w:val'] != null) { + numIds.add(Number(el.attributes['w:val'])); + } + if (el.elements) walkElements(el.elements); + } + } + + for (const [path, xml] of Object.entries(convertedXml)) { + if (path.startsWith('word/') && path !== 'word/numbering.xml' && xml?.elements) { + walkElements(xml.elements); + } + } + + return numIds; +} + +/** + * Extract the w:abstractNumId value from a w:num XML-JSON element. + * + * @param {object} numDef - A w:num XML-JSON element from numbering.definitions + * @returns {number | undefined} The abstractNumId, or undefined if not found + */ +function getAbstractNumIdFromDef(numDef) { + const abstractEl = numDef.elements?.find((el) => el.name === 'w:abstractNumId'); + if (abstractEl?.attributes?.['w:val'] != null) { + return Number(abstractEl.attributes['w:val']); + } + return undefined; +} + +/** + * Filter numbering definitions to remove orphaned entries not referenced by + * any paragraph in the exported document. Returns new arrays (does not mutate). + * + * @param {{ abstracts: Record, definitions: Record }} numbering + * The converter's numbering data (abstracts keyed by abstractNumId, definitions keyed by numId) + * @param {Set} referencedNumIds + * The set of numId values actually referenced in the exported document + * @returns {{ liveAbstracts: any[], liveDefinitions: any[] }} + * Filtered arrays ready to be written to word/numbering.xml + */ +export function filterOrphanedNumberingDefinitions(numbering, referencedNumIds) { + // Keep only w:num entries whose numId is still referenced + const liveDefinitions = Object.values(numbering.definitions).filter((def) => + referencedNumIds.has(Number(def.attributes?.['w:numId'])), + ); + + // Derive the set of abstractNumIds referenced by surviving w:num entries + const referencedAbstractIds = new Set(); + for (const def of liveDefinitions) { + const abstractId = getAbstractNumIdFromDef(def); + if (abstractId != null) { + referencedAbstractIds.add(abstractId); + } + } + + // Keep only w:abstractNum entries still referenced by a surviving w:num + const liveAbstracts = Object.values(numbering.abstracts).filter((abs) => + referencedAbstractIds.has(Number(abs.attributes?.['w:abstractNumId'])), + ); + + return { liveAbstracts, liveDefinitions }; +} diff --git a/packages/super-editor/src/core/super-converter/export-helpers/strip-orphaned-numbering.test.js b/packages/super-editor/src/core/super-converter/export-helpers/strip-orphaned-numbering.test.js new file mode 100644 index 0000000000..2d2499ade5 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/export-helpers/strip-orphaned-numbering.test.js @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'vitest'; +import { collectReferencedNumIds, filterOrphanedNumberingDefinitions } from './strip-orphaned-numbering.js'; + +// --------------------------------------------------------------------------- +// Helpers for building XML-JSON structures +// --------------------------------------------------------------------------- + +function makeNumIdElement(numId) { + return { + name: 'w:numId', + type: 'element', + attributes: { 'w:val': String(numId) }, + }; +} + +function makeParagraphWithNumId(numId) { + return { + name: 'w:p', + type: 'element', + elements: [ + { + name: 'w:pPr', + type: 'element', + elements: [ + { + name: 'w:numPr', + type: 'element', + elements: [makeNumIdElement(numId), { name: 'w:ilvl', type: 'element', attributes: { 'w:val': '0' } }], + }, + ], + }, + ], + }; +} + +function makeDocumentXml(paragraphs) { + return { + elements: [ + { + name: 'w:document', + type: 'element', + elements: [{ name: 'w:body', type: 'element', elements: paragraphs }], + }, + ], + }; +} + +function makeNumDef(numId, abstractNumId, extraElements = []) { + return { + name: 'w:num', + type: 'element', + attributes: { 'w:numId': String(numId) }, + elements: [ + { + name: 'w:abstractNumId', + type: 'element', + attributes: { 'w:val': String(abstractNumId) }, + }, + ...extraElements, + ], + }; +} + +function makeAbstractDef(abstractNumId) { + return { + name: 'w:abstractNum', + type: 'element', + attributes: { 'w:abstractNumId': String(abstractNumId) }, + elements: [], + }; +} + +// --------------------------------------------------------------------------- +// collectReferencedNumIds +// --------------------------------------------------------------------------- + +describe('collectReferencedNumIds', () => { + it('collects numIds from document body paragraphs', () => { + const convertedXml = { + 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1), makeParagraphWithNumId(3)]), + }; + const result = collectReferencedNumIds(convertedXml); + expect(result).toEqual(new Set([1, 3])); + }); + + it('collects numIds from headers and footers', () => { + const convertedXml = { + 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1)]), + 'word/header1.xml': { + elements: [{ name: 'w:hdr', type: 'element', elements: [makeParagraphWithNumId(5)] }], + }, + 'word/footer1.xml': { + elements: [{ name: 'w:ftr', type: 'element', elements: [makeParagraphWithNumId(7)] }], + }, + }; + const result = collectReferencedNumIds(convertedXml); + expect(result).toEqual(new Set([1, 5, 7])); + }); + + it('ignores word/numbering.xml to avoid self-referencing', () => { + const convertedXml = { + 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1)]), + 'word/numbering.xml': { + elements: [ + { + name: 'w:numbering', + type: 'element', + elements: [makeNumDef(1, 10), makeNumDef(99, 20)], + }, + ], + }, + }; + const result = collectReferencedNumIds(convertedXml); + // Only numId 1 from document body — numId 99 from numbering.xml should NOT appear + expect(result).toEqual(new Set([1])); + }); + + it('ignores non-word paths', () => { + const convertedXml = { + 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1)]), + 'docProps/custom.xml': { elements: [makeParagraphWithNumId(999)] }, + }; + const result = collectReferencedNumIds(convertedXml); + expect(result).toEqual(new Set([1])); + }); + + it('returns empty set when no paragraphs have numbering', () => { + const convertedXml = { + 'word/document.xml': makeDocumentXml([{ name: 'w:p', type: 'element', elements: [] }]), + }; + const result = collectReferencedNumIds(convertedXml); + expect(result).toEqual(new Set()); + }); + + it('deduplicates repeated numIds', () => { + const convertedXml = { + 'word/document.xml': makeDocumentXml([ + makeParagraphWithNumId(2), + makeParagraphWithNumId(2), + makeParagraphWithNumId(2), + ]), + }; + const result = collectReferencedNumIds(convertedXml); + expect(result).toEqual(new Set([2])); + }); +}); + +// --------------------------------------------------------------------------- +// filterOrphanedNumberingDefinitions +// --------------------------------------------------------------------------- + +describe('filterOrphanedNumberingDefinitions', () => { + it('keeps definitions referenced by document paragraphs', () => { + const numbering = { + abstracts: { 10: makeAbstractDef(10) }, + definitions: { 1: makeNumDef(1, 10) }, + }; + const referencedNumIds = new Set([1]); + + const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); + + expect(liveDefinitions).toHaveLength(1); + expect(liveDefinitions[0].attributes['w:numId']).toBe('1'); + expect(liveAbstracts).toHaveLength(1); + expect(liveAbstracts[0].attributes['w:abstractNumId']).toBe('10'); + }); + + it('strips orphaned w:num not referenced by any paragraph', () => { + const numbering = { + abstracts: { 10: makeAbstractDef(10), 20: makeAbstractDef(20) }, + definitions: { 1: makeNumDef(1, 10), 99: makeNumDef(99, 20) }, + }; + // Only numId 1 is referenced — numId 99 is orphaned + const referencedNumIds = new Set([1]); + + const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); + + expect(liveDefinitions).toHaveLength(1); + expect(liveDefinitions[0].attributes['w:numId']).toBe('1'); + // abstractNum 20 is also orphaned (only referenced by stripped numId 99) + expect(liveAbstracts).toHaveLength(1); + expect(liveAbstracts[0].attributes['w:abstractNumId']).toBe('10'); + }); + + it('keeps abstract shared by multiple w:num when at least one survives', () => { + const numbering = { + abstracts: { 10: makeAbstractDef(10) }, + definitions: { + 1: makeNumDef(1, 10), + 2: makeNumDef(2, 10), // same abstract as numId 1 + 3: makeNumDef(3, 10), // orphaned — not referenced + }, + }; + const referencedNumIds = new Set([1, 2]); + + const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); + + expect(liveDefinitions).toHaveLength(2); + expect(liveAbstracts).toHaveLength(1); + expect(liveAbstracts[0].attributes['w:abstractNumId']).toBe('10'); + }); + + it('strips all definitions when no numIds are referenced', () => { + const numbering = { + abstracts: { 10: makeAbstractDef(10) }, + definitions: { 1: makeNumDef(1, 10) }, + }; + const referencedNumIds = new Set(); + + const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); + + expect(liveDefinitions).toHaveLength(0); + expect(liveAbstracts).toHaveLength(0); + }); + + it('handles empty numbering gracefully', () => { + const numbering = { abstracts: {}, definitions: {} }; + const referencedNumIds = new Set([1]); + + const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); + + expect(liveDefinitions).toHaveLength(0); + expect(liveAbstracts).toHaveLength(0); + }); + + it('preserves w:num entries with lvlOverride elements', () => { + const lvlOverride = { + name: 'w:lvlOverride', + type: 'element', + attributes: { 'w:ilvl': '0' }, + elements: [{ name: 'w:startOverride', type: 'element', attributes: { 'w:val': '5' } }], + }; + const numbering = { + abstracts: { 10: makeAbstractDef(10) }, + definitions: { 1: makeNumDef(1, 10, [lvlOverride]) }, + }; + const referencedNumIds = new Set([1]); + + const { liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); + + expect(liveDefinitions).toHaveLength(1); + // Verify lvlOverride is preserved + expect(liveDefinitions[0].elements).toHaveLength(2); // abstractNumId + lvlOverride + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index e52d4f8dab..188cca8700 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -96,12 +96,21 @@ import { } from '../plan-engine/toc-entry-wrappers.js'; import { listsInsertWrapper, - listsSetTypeWrapper, listsIndentWrapper, listsOutdentWrapper, - listsRestartWrapper, - listsExitWrapper, + listsCreateWrapper, + listsAttachWrapper, + listsDetachWrapper, + listsJoinWrapper, + listsSeparateWrapper, + listsSetLevelWrapper, + listsSetValueWrapper, + listsContinuePreviousWrapper, + listsSetLevelRestartWrapper, + listsConvertToTextWrapper, } from '../plan-engine/lists-wrappers.js'; +import * as listSequenceHelpers from '../helpers/list-sequence-helpers.js'; +import * as planWrappers from '../plan-engine/plan-wrappers.js'; import { trackChangesAcceptWrapper, trackChangesRejectWrapper } from '../plan-engine/track-changes-wrappers.js'; import { registerBuiltInExecutors } from '../plan-engine/register-executors.js'; import { getRevision, initRevision } from '../plan-engine/revision-tracker.js'; @@ -522,12 +531,7 @@ function makeListEditor(children: MockParagraphNode[], commandOverrides: Record< const baseCommands = { insertListItemAt: vi.fn(() => true), - setListTypeAt: vi.fn(() => true), setTextSelection: vi.fn(() => true), - increaseListIndent: vi.fn(() => true), - decreaseListIndent: vi.fn(() => true), - restartNumbering: vi.fn(() => true), - exitListItemAt: vi.fn(() => true), insertTrackedChange: vi.fn(() => true), }; @@ -535,6 +539,7 @@ function makeListEditor(children: MockParagraphNode[], commandOverrides: Record< setMeta: vi.fn().mockReturnThis(), insertText: vi.fn().mockReturnThis(), delete: vi.fn().mockReturnThis(), + setNodeMarkup: vi.fn().mockReturnThis(), mapping: { maps: [] as unknown[], map: (p: number) => p, @@ -546,12 +551,14 @@ function makeListEditor(children: MockParagraphNode[], commandOverrides: Record< return { state: { doc, tr }, dispatch: vi.fn(), + view: { dispatch: vi.fn() }, commands: { ...baseCommands, ...commandOverrides, }, converter: { numbering: { definitions: {}, abstracts: {} }, + translatedNumbering: { definitions: {} }, }, } as unknown as Editor; } @@ -1241,7 +1248,7 @@ function expectThrowCode(operationId: OperationId, run: () => unknown): void { capturedCode = (error as { code?: string }).code ?? null; } - expect(capturedCode).toBeTruthy(); + expect(capturedCode, `${operationId} throwCase did not throw a coded pre-apply error`).toBeTruthy(); expect(COMMAND_CATALOG[operationId].throws.preApply).toContain(capturedCode); } @@ -2567,111 +2574,387 @@ const mutationVectors: Partial> = { ); }, }, - 'lists.setType': { + 'lists.indent': { throwCase: () => { - const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); - return listsSetTypeWrapper( + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsIndentWrapper( editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'ordered' }, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, ); }, failureCase: () => { - const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); - return listsSetTypeWrapper(editor, { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(false); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsIndentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + hasDefinitionSpy.mockRestore(); + return result; + }, + applyCase: () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsIndentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + hasDefinitionSpy.mockRestore(); + return result; + }, + }, + 'lists.outdent': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); + return listsOutdentWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsOutdentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + }, + applyCase: () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); + const result = listsOutdentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + hasDefinitionSpy.mockRestore(); + return result; + }, + }, + 'lists.create': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'p-1' })]); + return listsCreateWrapper( + editor, + { mode: 'empty', at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' }, kind: 'ordered' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsCreateWrapper(editor, { + mode: 'fromParagraphs', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'li-1' }, + kind: 'ordered', + }); + }, + applyCase: () => { + const getNewListIdSpy = vi.spyOn(ListHelpers, 'getNewListId').mockReturnValue(99); + const generateSpy = vi.spyOn(ListHelpers, 'generateNewListDefinition').mockImplementation(() => {}); + const editor = makeListEditor([makeListParagraph({ id: 'p-1' })]); + const result = listsCreateWrapper(editor, { + mode: 'empty', + at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' }, + kind: 'ordered', + }); + getNewListIdSpy.mockRestore(); + generateSpy.mockRestore(); + return result; + }, + }, + 'lists.attach': { + throwCase: () => { + const editor = makeListEditor([ + makeListParagraph({ id: 'p-1' }), + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' }), + ]); + return listsAttachWrapper( + editor, + { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' }, + attachTo: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsAttachWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'li-1' }, + attachTo: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + }, + applyCase: () => { + const editor = makeListEditor([ + makeListParagraph({ id: 'p-1' }), + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' }), + ]); + return listsAttachWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' }, + attachTo: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + }, + }, + 'lists.detach': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsDetachWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const noopReceipt = { steps: [{ effect: 'noop' }], revision: 'r0' }; + const execSpy = vi.spyOn(planWrappers, 'executeDomainCommand').mockReturnValue(noopReceipt as any); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsDetachWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + execSpy.mockRestore(); + return result; + }, + applyCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsDetachWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + }, + }, + 'lists.join': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsJoinWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, direction: 'withNext' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const canJoinSpy = vi.spyOn(listSequenceHelpers, 'evaluateCanJoin').mockReturnValue({ + canJoin: false, + reason: 'NO_ADJACENT_SEQUENCE', + }); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsJoinWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - kind: 'bullet', + direction: 'withNext', }); + canJoinSpy.mockRestore(); + return result; }, applyCase: () => { - const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); - return listsSetTypeWrapper(editor, { + const canJoinSpy = vi.spyOn(listSequenceHelpers, 'evaluateCanJoin').mockReturnValue({ + canJoin: true, + adjacentListId: '2', + }); + const adjacentSpy = vi.spyOn(listSequenceHelpers, 'findAdjacentSequence').mockReturnValue({ + numId: 2, + sequence: [ + { + address: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, + candidate: { + nodeId: 'li-2', + nodeType: 'listItem', + pos: 4, + end: 8, + node: { attrs: { paragraphProperties: { numberingProperties: { numId: 2, ilvl: 0 } } } } as any, + }, + numId: 2, + level: 0, + } as any, + ], + }); + const sequenceSpy = vi.spyOn(listSequenceHelpers, 'getContiguousSequence').mockReturnValue([]); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsJoinWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - kind: 'ordered', + direction: 'withNext', }); + canJoinSpy.mockRestore(); + adjacentSpy.mockRestore(); + sequenceSpy.mockRestore(); + return result; }, }, - 'lists.indent': { + 'lists.separate': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsIndentWrapper( + return listsSeparateWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, ); }, failureCase: () => { - const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(false); + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(true); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - const result = listsIndentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); - hasDefinitionSpy.mockRestore(); + const result = listsSeparateWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + firstInSeqSpy.mockRestore(); return result; }, + applyCase: () => { + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(false); + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const seqSpy = vi.spyOn(listSequenceHelpers, 'getSequenceFromTarget').mockReturnValue([]); + const createNumSpy = vi + .spyOn(ListHelpers, 'createNumDefinition') + .mockReturnValue({ numId: 99, numDef: {} } as any); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSeparateWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + firstInSeqSpy.mockRestore(); + abstractSpy.mockRestore(); + seqSpy.mockRestore(); + createNumSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevel': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 2 }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + }); + }, applyCase: () => { const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - const result = listsIndentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + const result = listsSetLevelWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 2, + }); hasDefinitionSpy.mockRestore(); return result; }, }, - 'lists.outdent': { + 'lists.setValue': { throwCase: () => { - const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); - return listsOutdentWrapper( + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetValueWrapper( editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, value: 5 }, { changeMode: 'tracked' }, ); }, failureCase: () => { + // value: null with noop receipt → NO_OP + const noopReceipt = { steps: [{ effect: 'noop' }], revision: 'r0' }; + const execSpy = vi.spyOn(planWrappers, 'executeDomainCommand').mockReturnValue(noopReceipt as any); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsOutdentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + const result = listsSetValueWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + value: null, + }); + execSpy.mockRestore(); + return result; }, applyCase: () => { - const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); - return listsOutdentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(true); + const overrideSpy = vi.spyOn(ListHelpers, 'setLvlOverride').mockImplementation(() => {}); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetValueWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + value: 5, + }); + firstInSeqSpy.mockRestore(); + overrideSpy.mockRestore(); + return result; }, }, - 'lists.restart': { + 'lists.continuePrevious': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsRestartWrapper( + return listsContinuePreviousWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, ); }, failureCase: () => { + const canContSpy = vi.spyOn(listSequenceHelpers, 'evaluateCanContinuePrevious').mockReturnValue({ + canContinue: false, + reason: 'NO_PREVIOUS_LIST', + }); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsRestartWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + const result = listsContinuePreviousWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + canContSpy.mockRestore(); + return result; }, applyCase: () => { - const editor = makeListEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '1.', path: [1] }), - makeListParagraph({ id: 'li-2', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '2.', path: [2] }), - ]); - return listsRestartWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }); + const canContSpy = vi.spyOn(listSequenceHelpers, 'evaluateCanContinuePrevious').mockReturnValue({ + canContinue: true, + previousListId: '2', + }); + const prevSpy = vi.spyOn(listSequenceHelpers, 'findPreviousCompatibleSequence').mockReturnValue({ + numId: 2, + sequence: [], + }); + const seqSpy = vi.spyOn(listSequenceHelpers, 'getContiguousSequence').mockReturnValue([]); + const removeSpy = vi.spyOn(ListHelpers, 'removeLvlOverride').mockImplementation(() => {}); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsContinuePreviousWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + canContSpy.mockRestore(); + prevSpy.mockRestore(); + seqSpy.mockRestore(); + removeSpy.mockRestore(); + return result; + }, + }, + 'lists.setLevelRestart': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelRestartWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, restartAfterLevel: null }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelRestartWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 99, + restartAfterLevel: null, + }); + }, + applyCase: () => { + const overrideSpy = vi.spyOn(ListHelpers, 'setLvlOverride').mockImplementation(() => {}); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelRestartWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + level: 0, + restartAfterLevel: 0, + scope: 'instance', + }); + overrideSpy.mockRestore(); + return result; }, }, - 'lists.exit': { + 'lists.convertToText': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsExitWrapper( + return listsConvertToTextWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, ); }, failureCase: () => { - const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })], { - exitListItemAt: vi.fn(() => false), + const noopReceipt = { steps: [{ effect: 'noop' }], revision: 'r0' }; + const execSpy = vi.spyOn(planWrappers, 'executeDomainCommand').mockReturnValue(noopReceipt as any); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsConvertToTextWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, }); - return listsExitWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + execSpy.mockRestore(); + return result; }, applyCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsExitWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + return listsConvertToTextWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); }, }, 'comments.create': { @@ -3471,8 +3754,17 @@ const mutationVectors: Partial> = { // ------------------------------------------------------------------------- 'create.tableOfContents': { throwCase: () => { - const editor = makeTocEditor({ insertTableOfContentsAt: undefined }); - return createTableOfContentsWrapper(editor, {}, { changeMode: 'direct' }); + const editor = makeTocEditor(); + return createTableOfContentsWrapper( + editor, + { + at: { + kind: 'before', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing-block' }, + }, + } as any, + { changeMode: 'direct' }, + ); }, failureCase: () => { const editor = makeTocEditor({ insertTableOfContentsAt: vi.fn(() => false) }); @@ -3939,66 +4231,148 @@ const dryRunVectors: Partial unknown>> = { expect(insertListItemAt).not.toHaveBeenCalled(); return result; }, - 'lists.setType': () => { - const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); - const setListTypeAt = editor.commands!.setListTypeAt as ReturnType; - const result = listsSetTypeWrapper( - editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'ordered' }, - { changeMode: 'direct', dryRun: true }, - ); - expect(setListTypeAt).not.toHaveBeenCalled(); - return result; - }, 'lists.indent': () => { const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - const increaseListIndent = editor.commands!.increaseListIndent as ReturnType; const result = listsIndentWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'direct', dryRun: true }, ); - expect(increaseListIndent).not.toHaveBeenCalled(); hasDefinitionSpy.mockRestore(); return result; }, 'lists.outdent': () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); - const decreaseListIndent = editor.commands!.decreaseListIndent as ReturnType; const result = listsOutdentWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'direct', dryRun: true }, ); - expect(decreaseListIndent).not.toHaveBeenCalled(); + hasDefinitionSpy.mockRestore(); return result; }, - 'lists.restart': () => { + 'lists.create': () => { + const editor = makeListEditor([makeListParagraph({ id: 'p-1' })]); + return listsCreateWrapper( + editor, + { mode: 'empty', at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' }, kind: 'ordered' }, + { changeMode: 'direct', dryRun: true }, + ); + }, + 'lists.attach': () => { const editor = makeListEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '1.', path: [1] }), - makeListParagraph({ id: 'li-2', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '2.', path: [2] }), + makeListParagraph({ id: 'p-1' }), + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' }), ]); - const restartNumbering = editor.commands!.restartNumbering as ReturnType; - const result = listsRestartWrapper( + return listsAttachWrapper( + editor, + { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-1' }, + attachTo: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }, + { changeMode: 'direct', dryRun: true }, + ); + }, + 'lists.detach': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsDetachWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'direct', dryRun: true }, + ); + }, + 'lists.join': () => { + const canJoinSpy = vi.spyOn(listSequenceHelpers, 'evaluateCanJoin').mockReturnValue({ + canJoin: true, + adjacentListId: '2', + }); + const adjacentSpy = vi.spyOn(listSequenceHelpers, 'findAdjacentSequence').mockReturnValue({ + numId: 2, + sequence: [], + }); + const seqSpy = vi.spyOn(listSequenceHelpers, 'getContiguousSequence').mockReturnValue([]); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsJoinWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, direction: 'withNext' }, + { changeMode: 'direct', dryRun: true }, + ); + canJoinSpy.mockRestore(); + adjacentSpy.mockRestore(); + seqSpy.mockRestore(); + return result; + }, + 'lists.separate': () => { + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(false); + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const seqSpy = vi.spyOn(listSequenceHelpers, 'getSequenceFromTarget').mockReturnValue([]); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSeparateWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'direct', dryRun: true }, + ); + firstInSeqSpy.mockRestore(); + abstractSpy.mockRestore(); + seqSpy.mockRestore(); + return result; + }, + 'lists.setLevel': () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSetLevelWrapper( editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 2 }, { changeMode: 'direct', dryRun: true }, ); - expect(restartNumbering).not.toHaveBeenCalled(); + hasDefinitionSpy.mockRestore(); return result; }, - 'lists.exit': () => { + 'lists.setValue': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetValueWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, value: 5 }, + { changeMode: 'direct', dryRun: true }, + ); + }, + 'lists.continuePrevious': () => { + const canContSpy = vi.spyOn(listSequenceHelpers, 'evaluateCanContinuePrevious').mockReturnValue({ + canContinue: true, + previousListId: '2', + }); + const prevSpy = vi.spyOn(listSequenceHelpers, 'findPreviousCompatibleSequence').mockReturnValue({ + numId: 2, + sequence: [], + }); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - const exitListItemAt = editor.commands!.exitListItemAt as ReturnType; - const result = listsExitWrapper( + const result = listsContinuePreviousWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'direct', dryRun: true }, ); - expect(exitListItemAt).not.toHaveBeenCalled(); + canContSpy.mockRestore(); + prevSpy.mockRestore(); return result; }, + 'lists.setLevelRestart': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSetLevelRestartWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, level: 0, restartAfterLevel: null }, + { changeMode: 'direct', dryRun: true }, + ); + }, + 'lists.convertToText': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsConvertToTextWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'direct', dryRun: true }, + ); + }, 'styles.apply': () => { const editor = makeStylesEditor(); const result = stylesApplyAdapter( diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts index 41d827e9b9..72c9087268 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts @@ -55,11 +55,20 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('lists.list'); expect(adapters).toHaveProperty('lists.get'); expect(adapters).toHaveProperty('lists.insert'); - expect(adapters).toHaveProperty('lists.setType'); expect(adapters).toHaveProperty('lists.indent'); expect(adapters).toHaveProperty('lists.outdent'); - expect(adapters).toHaveProperty('lists.restart'); - expect(adapters).toHaveProperty('lists.exit'); + expect(adapters).toHaveProperty('lists.create'); + expect(adapters).toHaveProperty('lists.attach'); + expect(adapters).toHaveProperty('lists.detach'); + expect(adapters).toHaveProperty('lists.join'); + expect(adapters).toHaveProperty('lists.canJoin'); + expect(adapters).toHaveProperty('lists.separate'); + expect(adapters).toHaveProperty('lists.setLevel'); + expect(adapters).toHaveProperty('lists.setValue'); + expect(adapters).toHaveProperty('lists.continuePrevious'); + expect(adapters).toHaveProperty('lists.canContinuePrevious'); + expect(adapters).toHaveProperty('lists.setLevelRestart'); + expect(adapters).toHaveProperty('lists.convertToText'); expect(adapters).toHaveProperty('sections.list'); expect(adapters).toHaveProperty('sections.get'); expect(adapters).toHaveProperty('sections.setBreakType'); diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index c775145470..c5af34a919 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -43,11 +43,20 @@ import { listsListWrapper, listsGetWrapper, listsInsertWrapper, - listsSetTypeWrapper, listsIndentWrapper, listsOutdentWrapper, - listsRestartWrapper, - listsExitWrapper, + listsCreateWrapper, + listsAttachWrapper, + listsDetachWrapper, + listsJoinWrapper, + listsCanJoinWrapper, + listsSeparateWrapper, + listsSetLevelWrapper, + listsSetValueWrapper, + listsContinuePreviousWrapper, + listsCanContinuePreviousWrapper, + listsSetLevelRestartWrapper, + listsConvertToTextWrapper, } from './plan-engine/lists-wrappers.js'; import { executePlan } from './plan-engine/executor.js'; import { previewPlan } from './plan-engine/preview.js'; @@ -214,11 +223,20 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters list: (query) => listsListWrapper(editor, query), get: (input) => listsGetWrapper(editor, input), insert: (input, options) => listsInsertWrapper(editor, input, options), - setType: (input, options) => listsSetTypeWrapper(editor, input, options), + create: (input, options) => listsCreateWrapper(editor, input, options), + attach: (input, options) => listsAttachWrapper(editor, input, options), + detach: (input, options) => listsDetachWrapper(editor, input, options), indent: (input, options) => listsIndentWrapper(editor, input, options), outdent: (input, options) => listsOutdentWrapper(editor, input, options), - restart: (input, options) => listsRestartWrapper(editor, input, options), - exit: (input, options) => listsExitWrapper(editor, input, options), + join: (input, options) => listsJoinWrapper(editor, input, options), + canJoin: (input) => listsCanJoinWrapper(editor, input), + separate: (input, options) => listsSeparateWrapper(editor, input, options), + setLevel: (input, options) => listsSetLevelWrapper(editor, input, options), + setValue: (input, options) => listsSetValueWrapper(editor, input, options), + continuePrevious: (input, options) => listsContinuePreviousWrapper(editor, input, options), + canContinuePrevious: (input) => listsCanContinuePreviousWrapper(editor, input), + setLevelRestart: (input, options) => listsSetLevelRestartWrapper(editor, input, options), + convertToText: (input, options) => listsConvertToTextWrapper(editor, input, options), }, sections: { list: (query) => sectionsListAdapter(editor, query), diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index 0636875586..d744465754 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -9,12 +9,7 @@ function makeEditor(overrides: Partial = {}): Editor { insertParagraphAt: vi.fn(() => true), insertHeadingAt: vi.fn(() => true), insertListItemAt: vi.fn(() => true), - setListTypeAt: vi.fn(() => true), setTextSelection: vi.fn(() => true), - increaseListIndent: vi.fn(() => true), - decreaseListIndent: vi.fn(() => true), - restartNumbering: vi.fn(() => true), - exitListItemAt: vi.fn(() => true), addComment: vi.fn(() => true), editComment: vi.fn(() => true), addCommentReply: vi.fn(() => true), @@ -86,7 +81,7 @@ describe('getDocumentApiCapabilities', () => { const editor = makeEditor({ commands: { addComment: undefined, - setListTypeAt: undefined, + insertListItemAt: undefined, insertTrackedChange: undefined, } as unknown as Editor['commands'], schema: { @@ -104,7 +99,7 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.global.trackChanges.enabled).toBe(false); expect(capabilities.global.history.enabled).toBe(false); expect(capabilities.operations['comments.create'].available).toBe(false); - expect(capabilities.operations['lists.setType'].available).toBe(false); + expect(capabilities.operations['lists.insert'].available).toBe(false); expect(capabilities.operations.insert.tracked).toBe(false); expect(capabilities.operations['format.apply'].available).toBe(false); }); @@ -135,8 +130,8 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.operations.insert.tracked).toBe(true); expect(capabilities.operations.insert.dryRun).toBe(true); - expect(capabilities.operations['lists.setType'].tracked).toBe(false); - expect(capabilities.operations['lists.setType'].dryRun).toBe(true); + expect(capabilities.operations['lists.create'].tracked).toBe(false); + expect(capabilities.operations['lists.create'].dryRun).toBe(true); expect(capabilities.operations['trackChanges.decide'].dryRun).toBe(false); expect(capabilities.operations['create.paragraph'].dryRun).toBe(true); expect(capabilities.operations['create.heading'].available).toBe(true); @@ -148,11 +143,18 @@ describe('getDocumentApiCapabilities', () => { const capabilities = getDocumentApiCapabilities(makeEditor()); const listMutations = [ 'lists.insert', - 'lists.setType', 'lists.indent', 'lists.outdent', - 'lists.restart', - 'lists.exit', + 'lists.create', + 'lists.attach', + 'lists.detach', + 'lists.join', + 'lists.separate', + 'lists.setLevel', + 'lists.setValue', + 'lists.continuePrevious', + 'lists.setLevelRestart', + 'lists.convertToText', ] as const; for (const operationId of listMutations) { @@ -206,11 +208,11 @@ describe('getDocumentApiCapabilities', () => { it('does not emit unavailable reasons for modes that are unsupported by design', () => { const capabilities = getDocumentApiCapabilities(makeEditor()); - const setTypeReasons = capabilities.operations['lists.setType'].reasons ?? []; + const createReasons = capabilities.operations['lists.create'].reasons ?? []; const trackChangesDecideReasons = capabilities.operations['trackChanges.decide'].reasons ?? []; - expect(setTypeReasons).not.toContain('TRACKED_MODE_UNAVAILABLE'); - expect(setTypeReasons).not.toContain('DRY_RUN_UNAVAILABLE'); + expect(createReasons).not.toContain('TRACKED_MODE_UNAVAILABLE'); + expect(createReasons).not.toContain('DRY_RUN_UNAVAILABLE'); expect(trackChangesDecideReasons).not.toContain('DRY_RUN_UNAVAILABLE'); }); diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 1296e9fd39..c52904c253 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -33,11 +33,18 @@ const REQUIRED_COMMANDS: Partial { expect(result.items[0]?.id).toBe('li-1'); }); + it('keeps listId stable across within scopes', () => { + const editor = makeEditor([ + makeParagraph({ + id: 'li-1', + text: 'First', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + makeParagraph({ + id: 'li-2', + text: 'Second', + numId: 1, + ilvl: 0, + markerText: '2.', + path: [2], + numberingType: 'decimal', + }), + ]); + + const unscoped = listListItems(editor); + const scoped = listListItems(editor, { + within: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, + }); + + const unscopedSecond = unscoped.items.find((item) => item.id === 'li-2'); + const scopedSecond = scoped.items.find((item) => item.id === 'li-2'); + + expect(unscopedSecond?.listId).toBe('1:li-1'); + expect(scopedSecond?.listId).toBe('1:li-1'); + }); + it('throws TARGET_NOT_FOUND when resolving a stale list address', () => { const editor = makeEditor([ makeParagraph({ id: 'li-1', numId: 1, markerText: '1.', path: [1], numberingType: 'decimal' }), diff --git a/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts index e91cd66d84..1471aeda5e 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts @@ -13,6 +13,7 @@ import { DocumentApiAdapterError } from '../errors.js'; import { getRevision } from '../plan-engine/revision-tracker.js'; import { getBlockIndex } from './index-cache.js'; import { validatePaginationInput } from './adapter-utils.js'; +import { computeSequenceIdMap } from './list-sequence-helpers.js'; import type { BlockCandidate, BlockIndex } from './node-address-resolver.js'; import { toFiniteNumber } from './value-utils.js'; @@ -112,9 +113,10 @@ export function projectListItemCandidate(editor: Editor, candidate: BlockCandida }; } -export function listItemProjectionToInfo(projection: ListItemProjection): ListItemInfo { +export function listItemProjectionToInfo(projection: ListItemProjection, listId: string): ListItemInfo { return { address: projection.address, + listId, marker: projection.marker, ordinal: projection.ordinal, path: projection.path, @@ -190,27 +192,35 @@ export function listListItems(editor: Editor, query?: ListsListQuery): ListsList const index = getBlockIndex(editor); const scope = resolveBlockScopeRange(index, query?.within as BlockNodeAddress | undefined); - const candidates = listItemCandidatesInScope(index, scope); + const allCandidates = index.candidates.filter((candidate) => candidate.nodeType === 'listItem'); + const allProjections = allCandidates.map((candidate) => projectListItemCandidate(editor, candidate)); + const projections = allProjections.filter((projection) => isWithinScope(projection.candidate, scope)); const safeOffset = query?.offset ?? 0; const safeLimit = query?.limit ?? Number.POSITIVE_INFINITY; const pageEnd = safeOffset + safeLimit; const evaluatedRevision = getRevision(editor); + // Compute sequence IDs from document-wide projections so list identity is + // stable across scoped queries. + const sequenceIds = computeSequenceIdMap(allProjections); + let total = 0; const items: ListsListResult['items'] = []; - for (const candidate of candidates) { - const projection = projectListItemCandidate(editor, candidate); + for (const projection of projections) { if (!matchesListQuery(projection, query)) continue; const currentIndex = total; total += 1; if (currentIndex < safeOffset || currentIndex >= pageEnd) continue; - const info = listItemProjectionToInfo(projection); + const listId = sequenceIds.get(projection.address.nodeId) ?? ''; + const info = listItemProjectionToInfo(projection, listId); const handle = buildResolvedHandle(info.address.nodeId, 'stable', 'list'); const { address, marker, ordinal, path, level, kind, text } = info; - items.push(buildDiscoveryItem(info.address.nodeId, handle, { address, marker, ordinal, path, level, kind, text })); + items.push( + buildDiscoveryItem(info.address.nodeId, handle, { address, listId, marker, ordinal, path, level, kind, text }), + ); } return buildDiscoveryResult({ diff --git a/packages/super-editor/src/document-api-adapters/helpers/list-sequence-helpers.ts b/packages/super-editor/src/document-api-adapters/helpers/list-sequence-helpers.ts new file mode 100644 index 0000000000..5b6502c254 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/list-sequence-helpers.ts @@ -0,0 +1,368 @@ +/** + * List sequence helpers — utility functions for reasoning about contiguous + * numbering sequences in a document. + * + * A "contiguous sequence" is a run of list items sharing the same numId, + * where non-list paragraphs between them do NOT break the sequence but + * list items with a different numId DO. + */ + +import type { Editor } from '../../core/Editor.js'; +import type { CanContinueReason, CanJoinReason, JoinDirection } from '@superdoc/document-api'; +import { type ListItemProjection, projectListItemCandidate } from './list-item-resolver.js'; +import { getBlockIndex } from './index-cache.js'; +import type { BlockCandidate } from './node-address-resolver.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +// --------------------------------------------------------------------------- +// Block resolution (for BlockAddress targets) +// --------------------------------------------------------------------------- + +/** + * Resolve a paragraph block address to its BlockCandidate. + * Searches for both 'paragraph' and 'listItem' node types since a paragraph + * with numbering properties is classified as 'listItem' in the block index. + */ +export function resolveBlock(editor: Editor, nodeId: string): BlockCandidate { + const index = getBlockIndex(editor); + const matches = index.candidates.filter( + (c) => c.nodeId === nodeId && (c.nodeType === 'paragraph' || c.nodeType === 'listItem'), + ); + + if (matches.length === 0) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Block target was not found.', { nodeId }); + } + if (matches.length > 1) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Block target id is ambiguous.', { + nodeId, + count: matches.length, + }); + } + + return matches[0]!; +} + +/** + * Resolve a contiguous range of paragraphs between `from` and `to` (inclusive). + * Returns all paragraph/listItem candidates whose positions fall within the range. + */ +export function resolveBlocksInRange(editor: Editor, fromId: string, toId: string): BlockCandidate[] { + const from = resolveBlock(editor, fromId); + const to = resolveBlock(editor, toId); + + if (from.pos > to.pos) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Block range "from" must precede "to" in document order.', { + from: fromId, + to: toId, + }); + } + + const index = getBlockIndex(editor); + return index.candidates.filter( + (c) => (c.nodeType === 'paragraph' || c.nodeType === 'listItem') && c.pos >= from.pos && c.pos <= to.pos, + ); +} + +// --------------------------------------------------------------------------- +// Numbering definition resolution +// --------------------------------------------------------------------------- + +/** + * Get the abstractNumId for a given numId from the raw numbering definitions. + */ +export function getAbstractNumId(editor: Editor, numId: number): number | undefined { + const converter = editor as unknown as { converter?: { numbering?: { definitions?: Record } } }; + const definitions = converter.converter?.numbering?.definitions; + if (!definitions) return undefined; + + const numDef = definitions[numId]; + if (!numDef?.elements) return undefined; + + const abstractEl = numDef.elements.find((el: any) => el.name === 'w:abstractNumId'); + const val = abstractEl?.attributes?.['w:val']; + return val != null ? Number(val) : undefined; +} + +// --------------------------------------------------------------------------- +// Sequence operations +// --------------------------------------------------------------------------- + +/** + * Get all list item projections from the block index, ordered by document position. + */ +export function getAllListItemProjections(editor: Editor): ListItemProjection[] { + const index = getBlockIndex(editor); + return index.candidates.filter((c) => c.nodeType === 'listItem').map((c) => projectListItemCandidate(editor, c)); +} + +/** + * Get the full contiguous sequence containing the target item. + * "Contiguous" means consecutive list items with the same numId — + * non-list paragraphs don't break continuity, but items with a + * different numId do. + */ +export function getContiguousSequence(editor: Editor, target: ListItemProjection): ListItemProjection[] { + if (target.numId == null) return [target]; + + const allItems = getAllListItemProjections(editor); + const targetIdx = allItems.findIndex((item) => item.address.nodeId === target.address.nodeId); + if (targetIdx === -1) return [target]; + + const numId = target.numId; + + // Walk backward to find start + let startIdx = targetIdx; + for (let i = targetIdx - 1; i >= 0; i--) { + if (allItems[i]!.numId !== numId) break; + startIdx = i; + } + + // Walk forward to find end + let endIdx = targetIdx; + for (let i = targetIdx + 1; i < allItems.length; i++) { + if (allItems[i]!.numId !== numId) break; + endIdx = i; + } + + return allItems.slice(startIdx, endIdx + 1); +} + +/** + * Get items from the target to the end of its contiguous sequence. + */ +export function getSequenceFromTarget(editor: Editor, target: ListItemProjection): ListItemProjection[] { + const sequence = getContiguousSequence(editor, target); + const targetIdx = sequence.findIndex((item) => item.address.nodeId === target.address.nodeId); + return sequence.slice(targetIdx); +} + +/** + * Check if the target is the first item in its contiguous sequence. + */ +export function isFirstInSequence(editor: Editor, target: ListItemProjection): boolean { + const sequence = getContiguousSequence(editor, target); + return sequence.length > 0 && sequence[0]!.address.nodeId === target.address.nodeId; +} + +// --------------------------------------------------------------------------- +// Sequence identity +// --------------------------------------------------------------------------- + +/** + * Compute the sequence identity for a single list item. + * + * Format: `{numId}:{anchorNodeId}` — encodes both the numbering definition + * and the position anchor (nodeId of the first item in the contiguous + * sequence). Distinct visual sequences sharing one numId receive different + * IDs because their anchors differ. + */ +export function computeSequenceId(editor: Editor, projection: ListItemProjection): string { + if (projection.numId == null) return ''; + const sequence = getContiguousSequence(editor, projection); + const anchor = sequence[0]?.address.nodeId ?? projection.address.nodeId; + return `${projection.numId}:${anchor}`; +} + +/** + * Batch-compute sequence IDs for an ordered array of list item projections. + * + * Runs in a single O(n) pass — contiguous items sharing the same numId are + * assigned the same sequence ID, anchored on the first item's nodeId. A + * different numId (or a null numId) starts a new sequence. + */ +export function computeSequenceIdMap(items: ListItemProjection[]): Map { + const map = new Map(); + let currentNumId: number | undefined; + let currentAnchor: string | undefined; + + for (const item of items) { + if (item.numId == null) { + map.set(item.address.nodeId, ''); + currentNumId = undefined; + currentAnchor = undefined; + continue; + } + + if (item.numId !== currentNumId) { + currentNumId = item.numId; + currentAnchor = item.address.nodeId; + } + + map.set(item.address.nodeId, `${currentNumId}:${currentAnchor}`); + } + + return map; +} + +// --------------------------------------------------------------------------- +// Adjacency search +// --------------------------------------------------------------------------- + +export type AdjacentSequenceResult = { + sequence: ListItemProjection[]; + numId: number; + abstractNumId: number | undefined; +}; + +/** + * Find the adjacent list sequence in the given direction. + * Returns null if no adjacent sequence exists. + */ +export function findAdjacentSequence( + editor: Editor, + target: ListItemProjection, + direction: JoinDirection, +): AdjacentSequenceResult | null { + if (target.numId == null) return null; + + const allItems = getAllListItemProjections(editor); + const sequence = getContiguousSequence(editor, target); + + if (direction === 'withNext') { + const lastInSequence = sequence[sequence.length - 1]!; + const lastIdx = allItems.findIndex((item) => item.address.nodeId === lastInSequence.address.nodeId); + + for (let i = lastIdx + 1; i < allItems.length; i++) { + const item = allItems[i]!; + if (item.numId != null) { + const adjSequence = getContiguousSequence(editor, item); + return { + sequence: adjSequence, + numId: item.numId, + abstractNumId: getAbstractNumId(editor, item.numId), + }; + } + } + } else { + const firstInSequence = sequence[0]!; + const firstIdx = allItems.findIndex((item) => item.address.nodeId === firstInSequence.address.nodeId); + + for (let i = firstIdx - 1; i >= 0; i--) { + const item = allItems[i]!; + if (item.numId != null) { + const adjSequence = getContiguousSequence(editor, item); + return { + sequence: adjSequence, + numId: item.numId, + abstractNumId: getAbstractNumId(editor, item.numId), + }; + } + } + } + + return null; +} + +/** + * Find the nearest previous list sequence that shares the same abstractNumId. + * Used by continuePrevious to find a compatible sequence to merge with. + */ +export function findPreviousCompatibleSequence( + editor: Editor, + target: ListItemProjection, +): { sequence: ListItemProjection[]; numId: number } | null { + if (target.numId == null) return null; + + const targetAbstractId = getAbstractNumId(editor, target.numId); + if (targetAbstractId == null) return null; + + const allItems = getAllListItemProjections(editor); + const sequence = getContiguousSequence(editor, target); + const firstInSequence = sequence[0]!; + const firstIdx = allItems.findIndex((item) => item.address.nodeId === firstInSequence.address.nodeId); + + for (let i = firstIdx - 1; i >= 0; i--) { + const item = allItems[i]!; + if (item.numId == null) continue; + + const itemAbstractId = getAbstractNumId(editor, item.numId); + if (itemAbstractId === targetAbstractId && item.numId !== target.numId) { + return { + sequence: getContiguousSequence(editor, item), + numId: item.numId, + }; + } + } + + return null; +} + +// --------------------------------------------------------------------------- +// Preflight evaluators (canJoin, canContinuePrevious) +// --------------------------------------------------------------------------- + +/** + * Determine canJoin result for the given target and direction. + */ +export function evaluateCanJoin( + editor: Editor, + target: ListItemProjection, + direction: JoinDirection, +): { canJoin: boolean; reason?: CanJoinReason; adjacentListId?: string } { + const adjacent = findAdjacentSequence(editor, target, direction); + + if (!adjacent) { + return { canJoin: false, reason: 'NO_ADJACENT_SEQUENCE' }; + } + + if (adjacent.numId === target.numId) { + return { canJoin: false, reason: 'ALREADY_SAME_SEQUENCE' }; + } + + const targetAbstractId = target.numId != null ? getAbstractNumId(editor, target.numId) : undefined; + if (targetAbstractId == null || adjacent.abstractNumId == null || targetAbstractId !== adjacent.abstractNumId) { + return { canJoin: false, reason: 'INCOMPATIBLE_DEFINITIONS' }; + } + + const adjacentAnchor = adjacent.sequence[0]?.address.nodeId ?? ''; + return { canJoin: true, adjacentListId: `${adjacent.numId}:${adjacentAnchor}` }; +} + +/** + * Determine canContinuePrevious result for the given target. + */ +export function evaluateCanContinuePrevious( + editor: Editor, + target: ListItemProjection, +): { canContinue: boolean; reason?: CanContinueReason; previousListId?: string } { + if (target.numId == null) { + return { canContinue: false, reason: 'NO_PREVIOUS_LIST' }; + } + + const targetAbstractId = getAbstractNumId(editor, target.numId); + if (targetAbstractId == null) { + return { canContinue: false, reason: 'NO_PREVIOUS_LIST' }; + } + + const allItems = getAllListItemProjections(editor); + const sequence = getContiguousSequence(editor, target); + const firstInSequence = sequence[0]!; + const firstIdx = allItems.findIndex((item) => item.address.nodeId === firstInSequence.address.nodeId); + + let foundAnyPrevious = false; + + for (let i = firstIdx - 1; i >= 0; i--) { + const item = allItems[i]!; + if (item.numId == null) continue; + + foundAnyPrevious = true; + + const itemAbstractId = getAbstractNumId(editor, item.numId); + if (itemAbstractId !== targetAbstractId) continue; + + // Compatible previous found + if (item.numId === target.numId) { + return { canContinue: false, reason: 'ALREADY_CONTINUOUS' }; + } + + const prevSequence = getContiguousSequence(editor, item); + const prevAnchor = prevSequence[0]?.address.nodeId ?? item.address.nodeId; + return { canContinue: true, previousListId: `${item.numId}:${prevAnchor}` }; + } + + if (!foundAnyPrevious) { + return { canContinue: false, reason: 'NO_PREVIOUS_LIST' }; + } + + return { canContinue: false, reason: 'INCOMPATIBLE_DEFINITIONS' }; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.test.ts new file mode 100644 index 0000000000..807ee2db61 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.test.ts @@ -0,0 +1,843 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { PlanReceipt } from '@superdoc/document-api'; +import type { ListItemProjection } from '../helpers/list-item-resolver.js'; + +// --------------------------------------------------------------------------- +// Module mocks — hoisted before any imports of the module under test +// --------------------------------------------------------------------------- + +vi.mock('./plan-wrappers.js', () => ({ + executeDomainCommand: vi.fn((_editor: Editor, handler: () => boolean): PlanReceipt => { + const applied = handler(); + return { + success: true, + revision: { before: '0', after: '0' }, + steps: [ + { + stepId: 'step-1', + op: 'domain.command', + effect: applied ? 'changed' : 'noop', + matchCount: applied ? 1 : 0, + data: { domain: 'command', commandDispatched: applied }, + }, + ], + timing: { totalMs: 0 }, + }; + }), +})); + +vi.mock('../helpers/index-cache.js', () => ({ + getBlockIndex: vi.fn(), + clearIndexCache: vi.fn(), +})); + +vi.mock('../helpers/list-item-resolver.js', () => ({ + listItemProjectionToInfo: vi.fn((proj: ListItemProjection, listId: string) => ({ + address: proj.address, + listId, + level: proj.level, + })), + listListItems: vi.fn(() => ({ items: [], total: 0 })), + resolveListItem: vi.fn(), +})); + +vi.mock('../helpers/list-sequence-helpers.js', () => ({ + resolveBlock: vi.fn(), + resolveBlocksInRange: vi.fn(), + getAbstractNumId: vi.fn(), + getContiguousSequence: vi.fn(), + getSequenceFromTarget: vi.fn(), + isFirstInSequence: vi.fn(), + computeSequenceId: vi.fn(() => '1:p1'), + findAdjacentSequence: vi.fn(), + findPreviousCompatibleSequence: vi.fn(), + evaluateCanJoin: vi.fn(), + evaluateCanContinuePrevious: vi.fn(), +})); + +vi.mock('../../core/helpers/list-numbering-helpers.js', () => ({ + ListHelpers: { + hasListDefinition: vi.fn(() => true), + getNewListId: vi.fn(() => 42), + generateNewListDefinition: vi.fn(), + createNumDefinition: vi.fn(() => ({ numId: 43 })), + setLvlOverride: vi.fn(), + removeLvlOverride: vi.fn(), + setLvlRestartOnAbstract: vi.fn(), + }, +})); + +vi.mock('../../core/commands/changeListLevel.js', () => ({ + updateNumberingProperties: vi.fn(), +})); + +vi.mock('../helpers/mutation-helpers.js', () => ({ + requireEditorCommand: vi.fn((cmd: unknown) => cmd), + ensureTrackedCapability: vi.fn(), + rejectTrackedMode: vi.fn(), +})); + +vi.mock('../helpers/tracked-change-refs.js', () => ({ + collectTrackInsertRefsInRange: vi.fn(() => []), +})); + +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'test-uuid'), +})); + +// --------------------------------------------------------------------------- +// Now import wrappers and mocked modules +// --------------------------------------------------------------------------- + +import { + listsListWrapper, + listsGetWrapper, + listsCanJoinWrapper, + listsCanContinuePreviousWrapper, + listsCreateWrapper, + listsAttachWrapper, + listsDetachWrapper, + listsJoinWrapper, + listsSeparateWrapper, + listsSetLevelWrapper, + listsSetValueWrapper, + listsContinuePreviousWrapper, + listsSetLevelRestartWrapper, + listsConvertToTextWrapper, + listsIndentWrapper, + listsOutdentWrapper, +} from './lists-wrappers.js'; + +import { listListItems, resolveListItem } from '../helpers/list-item-resolver.js'; +import { + resolveBlock, + resolveBlocksInRange, + getAbstractNumId, + getContiguousSequence, + getSequenceFromTarget, + isFirstInSequence, + computeSequenceId, + findAdjacentSequence, + evaluateCanJoin, + evaluateCanContinuePrevious, + findPreviousCompatibleSequence, +} from '../helpers/list-sequence-helpers.js'; +import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; +import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeEditor(overrides: Record = {}): Editor { + return { + state: { + doc: { content: { size: 100 } }, + tr: { setNodeMarkup: vi.fn().mockReturnThis(), insertText: vi.fn().mockReturnThis() }, + }, + view: { dispatch: vi.fn() }, + commands: { insertListItemAt: vi.fn(() => true) }, + converter: { + numbering: { definitions: {}, abstracts: {} }, + translatedNumbering: { definitions: {} }, + }, + ...overrides, + } as unknown as Editor; +} + +function makeProjection(overrides: Partial = {}): ListItemProjection { + return { + address: { kind: 'block', nodeType: 'listItem', nodeId: 'p1' }, + candidate: { + nodeType: 'listItem', + nodeId: 'p1', + node: { attrs: { paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } } } }, + pos: 10, + end: 20, + }, + numId: 1, + level: 0, + kind: 'ordered', + marker: '1.', + ordinal: 1, + ...overrides, + } as unknown as ListItemProjection; +} + +function makeBlockCandidate(nodeId: string, nodeType: 'paragraph' | 'listItem' = 'paragraph') { + return { + nodeId, + nodeType, + node: { attrs: { paragraphProperties: {} } }, + pos: 10, + end: 20, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('lists-wrappers', () => { + let editor: Editor; + + beforeEach(() => { + vi.clearAllMocks(); + editor = makeEditor(); + }); + + // ========================================================================= + // Read operations + // ========================================================================= + + describe('listsListWrapper', () => { + it('delegates to listListItems', () => { + const query = { kind: 'ordered' as const }; + listsListWrapper(editor, query); + expect(listListItems).toHaveBeenCalledWith(editor, query); + }); + + it('returns result from listListItems', () => { + const mockResult = { items: [{ address: { nodeId: 'p1' } }], total: 1 }; + vi.mocked(listListItems).mockReturnValueOnce(mockResult as any); + expect(listsListWrapper(editor)).toEqual(mockResult); + }); + }); + + describe('listsGetWrapper', () => { + it('resolves target and converts to info', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + const result = listsGetWrapper(editor, { address: proj.address }); + expect(resolveListItem).toHaveBeenCalledWith(editor, proj.address); + expect(result).toHaveProperty('address', proj.address); + }); + }); + + describe('listsCanJoinWrapper', () => { + it('delegates to evaluateCanJoin', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanJoin).mockReturnValueOnce({ canJoin: true }); + + const result = listsCanJoinWrapper(editor, { target: proj.address, direction: 'withNext' }); + expect(evaluateCanJoin).toHaveBeenCalledWith(editor, proj, 'withNext'); + expect(result.canJoin).toBe(true); + }); + }); + + describe('listsCanContinuePreviousWrapper', () => { + it('delegates to evaluateCanContinuePrevious', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanContinuePrevious).mockReturnValueOnce({ canContinue: false, reason: 'NO_PREVIOUS_LIST' }); + + const result = listsCanContinuePreviousWrapper(editor, { target: proj.address }); + expect(result.canContinue).toBe(false); + expect(result.reason).toBe('NO_PREVIOUS_LIST'); + }); + }); + + // ========================================================================= + // listsCreateWrapper + // ========================================================================= + + describe('listsCreateWrapper', () => { + it('creates a list in empty mode', () => { + const block = makeBlockCandidate('p1', 'paragraph'); + vi.mocked(resolveBlock).mockReturnValueOnce(block as any); + + const result = listsCreateWrapper(editor, { + mode: 'empty', + at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + kind: 'ordered', + }); + expect(result.success).toBe(true); + expect(result).toHaveProperty('listId', '42:p1'); + }); + + it('fails in empty mode when target is already a list item', () => { + const block = makeBlockCandidate('p1', 'listItem'); + vi.mocked(resolveBlock).mockReturnValueOnce(block as any); + + const result = listsCreateWrapper(editor, { + mode: 'empty', + at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + kind: 'bullet', + }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('INVALID_TARGET'); + }); + + it('creates a list in fromParagraphs mode', () => { + const blocks = [makeBlockCandidate('p1'), makeBlockCandidate('p2')]; + vi.mocked(resolveBlocksInRange).mockReturnValueOnce(blocks as any); + + const result = listsCreateWrapper(editor, { + mode: 'fromParagraphs', + target: { + from: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + to: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + }, + kind: 'ordered', + }); + expect(result.success).toBe(true); + }); + + it('fails in fromParagraphs mode when any target is already a list item', () => { + const blocks = [makeBlockCandidate('p1'), makeBlockCandidate('p2', 'listItem')]; + vi.mocked(resolveBlocksInRange).mockReturnValueOnce(blocks as any); + + const result = listsCreateWrapper(editor, { + mode: 'fromParagraphs', + target: { + from: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + to: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + }, + kind: 'ordered', + }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('INVALID_TARGET'); + }); + + it('returns dry-run result without mutations', () => { + const block = makeBlockCandidate('p1', 'paragraph'); + vi.mocked(resolveBlock).mockReturnValueOnce(block as any); + + const result = listsCreateWrapper( + editor, + { mode: 'empty', at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, kind: 'ordered' }, + { dryRun: true }, + ); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('(dry-run)'); + expect(ListHelpers.generateNewListDefinition).not.toHaveBeenCalled(); + }); + + it('fails with LEVEL_OUT_OF_RANGE when level exceeds bounds', () => { + const result = listsCreateWrapper(editor, { + mode: 'empty', + at: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + kind: 'ordered', + level: 9, + }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('LEVEL_OUT_OF_RANGE'); + }); + }); + + // ========================================================================= + // listsAttachWrapper + // ========================================================================= + + describe('listsAttachWrapper', () => { + it('attaches paragraphs to an existing list', () => { + const attachTo = makeProjection({ numId: 5, level: 2 }); + vi.mocked(resolveListItem).mockReturnValueOnce(attachTo); + const block = makeBlockCandidate('p2', 'paragraph'); + vi.mocked(resolveBlock).mockReturnValueOnce(block as any); + + const result = listsAttachWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + attachTo: attachTo.address, + }); + expect(result.success).toBe(true); + }); + + it('fails when target paragraphs are already list items', () => { + const attachTo = makeProjection({ numId: 5 }); + vi.mocked(resolveListItem).mockReturnValueOnce(attachTo); + const block = makeBlockCandidate('p2', 'listItem'); + vi.mocked(resolveBlock).mockReturnValueOnce(block as any); + + const result = listsAttachWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + attachTo: attachTo.address, + }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('INVALID_TARGET'); + }); + + it('fails when attachTo has no numId', () => { + const attachTo = makeProjection({ numId: undefined as any }); + vi.mocked(resolveListItem).mockReturnValueOnce(attachTo); + + const result = listsAttachWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + attachTo: attachTo.address, + }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('INVALID_TARGET'); + }); + }); + + // ========================================================================= + // listsDetachWrapper + // ========================================================================= + + describe('listsDetachWrapper', () => { + it('detaches a list item to a plain paragraph', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsDetachWrapper(editor, { target: proj.address }); + expect(result.success).toBe(true); + expect((result as any).paragraph.nodeType).toBe('paragraph'); + }); + + it('returns dry-run result without mutations', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsDetachWrapper(editor, { target: proj.address }, { dryRun: true }); + expect(result.success).toBe(true); + expect(editor.view!.dispatch).not.toHaveBeenCalled(); + }); + + it('rejects tracked mode', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + listsDetachWrapper(editor, { target: proj.address }); + expect(rejectTrackedMode).toHaveBeenCalledWith('lists.detach', undefined); + }); + }); + + // ========================================================================= + // listsJoinWrapper + // ========================================================================= + + describe('listsJoinWrapper', () => { + it('joins with previous sequence', () => { + const proj = makeProjection({ numId: 2, address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' } }); + const adjAnchor = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'adj-first' }, + }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanJoin).mockReturnValueOnce({ canJoin: true }); + vi.mocked(findAdjacentSequence).mockReturnValueOnce({ + sequence: [adjAnchor], + numId: 1, + abstractNumId: 10, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([proj]); + + const result = listsJoinWrapper(editor, { target: proj.address, direction: 'withPrevious' }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('1:adj-first'); + }); + + it('joins with next sequence', () => { + const proj = makeProjection({ numId: 1, address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' } }); + const targetAnchor = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'target-first' }, + }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanJoin).mockReturnValueOnce({ canJoin: true }); + const nextItems = [ + makeProjection({ numId: 2, address: { kind: 'block', nodeType: 'listItem', nodeId: 'next' } }), + ]; + vi.mocked(findAdjacentSequence).mockReturnValueOnce({ + sequence: nextItems, + numId: 2, + abstractNumId: 10, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([targetAnchor, proj]); + + const result = listsJoinWrapper(editor, { target: proj.address, direction: 'withNext' }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('1:target-first'); + }); + + it('fails when canJoin is false', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanJoin).mockReturnValueOnce({ canJoin: false, reason: 'NO_ADJACENT_SEQUENCE' }); + + const result = listsJoinWrapper(editor, { target: proj.address, direction: 'withNext' }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_ADJACENT_SEQUENCE'); + }); + + it('returns dry-run result without mutations', () => { + const proj = makeProjection({ numId: 2, address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' } }); + const adjAnchor = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'adj-first' }, + }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanJoin).mockReturnValueOnce({ canJoin: true }); + vi.mocked(findAdjacentSequence).mockReturnValueOnce({ + sequence: [adjAnchor], + numId: 1, + abstractNumId: 10, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([proj]); + + const result = listsJoinWrapper(editor, { target: proj.address, direction: 'withPrevious' }, { dryRun: true }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('1:adj-first'); + expect(editor.view!.dispatch).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================= + // listsSeparateWrapper + // ========================================================================= + + describe('listsSeparateWrapper', () => { + it('separates a sequence at the target', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(isFirstInSequence).mockReturnValueOnce(false); + vi.mocked(getAbstractNumId).mockReturnValueOnce(10); + vi.mocked(getSequenceFromTarget).mockReturnValueOnce([proj]); + + const result = listsSeparateWrapper(editor, { target: proj.address }); + expect(result.success).toBe(true); + expect((result as any).numId).toBe(43); // from createNumDefinition mock + expect((result as any).listId).toBe('43:p1'); // newNumId:target.address.nodeId + }); + + it('returns NO_OP when target is first in sequence', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(isFirstInSequence).mockReturnValueOnce(true); + + const result = listsSeparateWrapper(editor, { target: proj.address }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_OP'); + }); + + it('returns dry-run result', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(isFirstInSequence).mockReturnValueOnce(false); + vi.mocked(getAbstractNumId).mockReturnValueOnce(10); + vi.mocked(getSequenceFromTarget).mockReturnValueOnce([proj]); + + const result = listsSeparateWrapper(editor, { target: proj.address }, { dryRun: true }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('(dry-run)'); + }); + }); + + // ========================================================================= + // listsSetLevelWrapper + // ========================================================================= + + describe('listsSetLevelWrapper', () => { + it('sets the level successfully', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetLevelWrapper(editor, { target: proj.address, level: 2 }); + expect(result.success).toBe(true); + }); + + it('returns NO_OP when already at requested level', () => { + const proj = makeProjection({ numId: 1, level: 3 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetLevelWrapper(editor, { target: proj.address, level: 3 }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_OP'); + }); + + it('returns LEVEL_OUT_OF_RANGE for invalid level', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetLevelWrapper(editor, { target: proj.address, level: 9 }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('LEVEL_OUT_OF_RANGE'); + }); + + it('returns LEVEL_OUT_OF_RANGE when definition missing', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(ListHelpers.hasListDefinition).mockReturnValueOnce(false); + + const result = listsSetLevelWrapper(editor, { target: proj.address, level: 5 }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('LEVEL_OUT_OF_RANGE'); + }); + }); + + // ========================================================================= + // listsIndentWrapper / listsOutdentWrapper + // ========================================================================= + + describe('listsIndentWrapper', () => { + it('increments level by 1', () => { + const proj = makeProjection({ numId: 1, level: 2 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsIndentWrapper(editor, { target: proj.address }); + expect(result.success).toBe(true); + }); + + it('rejects tracked mode', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + listsIndentWrapper(editor, { target: proj.address }); + expect(rejectTrackedMode).toHaveBeenCalledWith('lists.indent', undefined); + }); + }); + + describe('listsOutdentWrapper', () => { + it('decrements level by 1', () => { + const proj = makeProjection({ numId: 1, level: 2 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsOutdentWrapper(editor, { target: proj.address }); + expect(result.success).toBe(true); + }); + + it('returns NO_OP at level 0', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsOutdentWrapper(editor, { target: proj.address }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_OP'); + }); + }); + + // ========================================================================= + // listsSetValueWrapper + // ========================================================================= + + describe('listsSetValueWrapper', () => { + it('sets value on first-in-sequence item', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(isFirstInSequence).mockReturnValueOnce(true); + + const result = listsSetValueWrapper(editor, { target: proj.address, value: 5 }); + expect(result.success).toBe(true); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 1, 0, { startOverride: 5 }); + }); + + it('separates then sets value for mid-sequence item', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(isFirstInSequence).mockReturnValueOnce(false); + vi.mocked(getAbstractNumId).mockReturnValueOnce(10); + vi.mocked(getSequenceFromTarget).mockReturnValueOnce([proj]); + + const result = listsSetValueWrapper(editor, { target: proj.address, value: 3 }); + expect(result.success).toBe(true); + expect(ListHelpers.createNumDefinition).toHaveBeenCalled(); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 43, 0, { startOverride: 3 }); + }); + + it('removes override when value is null', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + editor.converter.numbering.definitions[1] = { + elements: [{ name: 'w:lvlOverride', attributes: { 'w:ilvl': '0' } }], + }; + + const result = listsSetValueWrapper(editor, { target: proj.address, value: null }); + expect(result.success).toBe(true); + expect(ListHelpers.removeLvlOverride).toHaveBeenCalledWith(editor, 1, 0); + }); + + it('returns NO_OP when removing an absent override', () => { + const proj = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + editor.converter.numbering.definitions[1] = { + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '10' } }], + }; + + const result = listsSetValueWrapper(editor, { target: proj.address, value: null }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_OP'); + expect(ListHelpers.removeLvlOverride).not.toHaveBeenCalled(); + }); + + it('returns dry-run result', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetValueWrapper(editor, { target: proj.address, value: 5 }, { dryRun: true }); + expect(result.success).toBe(true); + expect(ListHelpers.setLvlOverride).not.toHaveBeenCalled(); + }); + + it('fails when target has no numId', () => { + const proj = makeProjection({ numId: undefined as any }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetValueWrapper(editor, { target: proj.address, value: 1 }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('INVALID_TARGET'); + }); + }); + + // ========================================================================= + // listsContinuePreviousWrapper + // ========================================================================= + + describe('listsContinuePreviousWrapper', () => { + it('continues from previous compatible sequence', () => { + const proj = makeProjection({ numId: 2, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanContinuePrevious).mockReturnValueOnce({ canContinue: true }); + vi.mocked(findPreviousCompatibleSequence).mockReturnValueOnce({ + sequence: [makeProjection({ numId: 1 })], + numId: 1, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([proj]); + + const result = listsContinuePreviousWrapper(editor, { target: proj.address }); + expect(result.success).toBe(true); + expect(ListHelpers.removeLvlOverride).toHaveBeenCalledWith(editor, 2, 0); + }); + + it('fails with NO_COMPATIBLE_PREVIOUS when no match', () => { + const proj = makeProjection({ numId: 2 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanContinuePrevious).mockReturnValueOnce({ canContinue: false, reason: 'NO_PREVIOUS_LIST' }); + + const result = listsContinuePreviousWrapper(editor, { target: proj.address }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_COMPATIBLE_PREVIOUS'); + }); + + it('fails with ALREADY_CONTINUOUS when already continuous', () => { + const proj = makeProjection({ numId: 2 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanContinuePrevious).mockReturnValueOnce({ canContinue: false, reason: 'ALREADY_CONTINUOUS' }); + + const result = listsContinuePreviousWrapper(editor, { target: proj.address }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('ALREADY_CONTINUOUS'); + }); + + it('returns dry-run result', () => { + const proj = makeProjection({ numId: 2 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(evaluateCanContinuePrevious).mockReturnValueOnce({ canContinue: true }); + vi.mocked(findPreviousCompatibleSequence).mockReturnValueOnce({ sequence: [], numId: 1 } as any); + + const result = listsContinuePreviousWrapper(editor, { target: proj.address }, { dryRun: true }); + expect(result.success).toBe(true); + expect(editor.view!.dispatch).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================= + // listsSetLevelRestartWrapper + // ========================================================================= + + describe('listsSetLevelRestartWrapper', () => { + it('sets lvlRestart at definition scope', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(getAbstractNumId).mockReturnValueOnce(10); + + const result = listsSetLevelRestartWrapper(editor, { + target: proj.address, + level: 1, + restartAfterLevel: 0, + scope: 'definition', + }); + expect(result.success).toBe(true); + expect(ListHelpers.setLvlRestartOnAbstract).toHaveBeenCalledWith(editor, 10, 1, 0); + }); + + it('sets lvlRestart at instance scope', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetLevelRestartWrapper(editor, { + target: proj.address, + level: 2, + restartAfterLevel: 1, + scope: 'instance', + }); + expect(result.success).toBe(true); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 1, 2, { lvlRestart: 1 }); + }); + + it('defaults to definition scope', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(getAbstractNumId).mockReturnValueOnce(10); + + listsSetLevelRestartWrapper(editor, { target: proj.address, level: 0, restartAfterLevel: null }); + expect(ListHelpers.setLvlRestartOnAbstract).toHaveBeenCalled(); + }); + + it('fails with LEVEL_OUT_OF_RANGE for invalid level', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetLevelRestartWrapper(editor, { target: proj.address, level: 99, restartAfterLevel: 0 }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('LEVEL_OUT_OF_RANGE'); + }); + + it('returns dry-run result', () => { + const proj = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsSetLevelRestartWrapper( + editor, + { + target: proj.address, + level: 0, + restartAfterLevel: null, + }, + { dryRun: true }, + ); + expect(result.success).toBe(true); + expect(ListHelpers.setLvlRestartOnAbstract).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================= + // listsConvertToTextWrapper + // ========================================================================= + + describe('listsConvertToTextWrapper', () => { + it('converts a list item to plain text', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsConvertToTextWrapper(editor, { target: proj.address }); + expect(result.success).toBe(true); + expect((result as any).paragraph.nodeType).toBe('paragraph'); + }); + + it('prepends marker text when includeMarker is true', () => { + const proj = makeProjection({ marker: '1.' }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsConvertToTextWrapper(editor, { target: proj.address, includeMarker: true }); + expect(result.success).toBe(true); + expect(editor.state.tr.insertText).toHaveBeenCalledWith('1.', 11); // pos + 1 + }); + + it('does not prepend marker text when includeMarker is false', () => { + const proj = makeProjection({ marker: '1.' }); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + listsConvertToTextWrapper(editor, { target: proj.address, includeMarker: false }); + expect(editor.state.tr.insertText).not.toHaveBeenCalled(); + }); + + it('returns dry-run result without mutations', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + + const result = listsConvertToTextWrapper(editor, { target: proj.address }, { dryRun: true }); + expect(result.success).toBe(true); + expect(editor.view!.dispatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts index fd44d48ef4..edc9ca02c1 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts @@ -1,10 +1,10 @@ /** - * Lists convenience wrappers — bridge lists operations to the plan engine's + * Lists convenience wrappers — bridge list operations to the plan engine's * revision management and execution path. * - * Read operations (list, get) are pure queries. - * Mutating operations (insert, setType, indent, outdent, restart, exit) - * delegate to editor commands with plan-engine revision tracking. + * Read operations (list, get, canJoin, canContinuePrevious) are pure queries. + * Mutating operations delegate to editor commands / direct PM transactions + * with plan-engine revision tracking. */ import { v4 as uuidv4 } from 'uuid'; @@ -12,15 +12,33 @@ import type { Editor } from '../../core/Editor.js'; import type { ListInsertInput, ListItemInfo, - ListSetTypeInput, - ListsExitResult, ListsGetInput, ListsInsertResult, ListsListQuery, ListsListResult, ListsMutateItemResult, ListTargetInput, + ListsCreateInput, + ListsCreateResult, + ListsAttachInput, + ListsDetachInput, + ListsDetachResult, + ListsJoinInput, + ListsJoinResult, + ListsCanJoinInput, + ListsCanJoinResult, + ListsSeparateInput, + ListsSeparateResult, + ListsSetLevelInput, + ListsSetValueInput, + ListsContinuePreviousInput, + ListsCanContinuePreviousInput, + ListsCanContinuePreviousResult, + ListsSetLevelRestartInput, + ListsConvertToTextInput, + ListsConvertToTextResult, MutationOptions, + ReceiptFailureCode, } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; import { requireEditorCommand, ensureTrackedCapability, rejectTrackedMode } from '../helpers/mutation-helpers.js'; @@ -33,7 +51,21 @@ import { resolveListItem, type ListItemProjection, } from '../helpers/list-item-resolver.js'; +import { + resolveBlock, + resolveBlocksInRange, + getAbstractNumId, + getContiguousSequence, + getSequenceFromTarget, + isFirstInSequence, + computeSequenceId, + findAdjacentSequence, + findPreviousCompatibleSequence, + evaluateCanJoin, + evaluateCanContinuePrevious, +} from '../helpers/list-sequence-helpers.js'; import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; +import { updateNumberingProperties } from '../../core/commands/changeListLevel.js'; // --------------------------------------------------------------------------- // Command types @@ -47,18 +79,32 @@ type InsertListItemAtCommand = (options: { tracked?: boolean; }) => boolean; -type SetListTypeAtCommand = (options: { pos: number; kind: 'ordered' | 'bullet' }) => boolean; -type ExitListItemAtCommand = (options: { pos: number }) => boolean; type SetTextSelectionCommand = (options: { from: number; to?: number }) => boolean; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- -function toListsFailure(code: 'NO_OP' | 'INVALID_TARGET', message: string, details?: unknown) { +function toListsFailure(code: ReceiptFailureCode, message: string, details?: unknown) { return { success: false as const, failure: { code, message, details } }; } +function dispatchEditorTransaction(editor: Editor, tr: unknown): void { + if (typeof editor.dispatch === 'function') { + editor.dispatch(tr as Parameters[0]); + return; + } + if (typeof editor.view?.dispatch === 'function') { + editor.view.dispatch(tr as Parameters['dispatch']>[0]); + return; + } + throw new DocumentApiAdapterError( + 'INTERNAL_ERROR', + 'Cannot apply list mutation because no transaction dispatcher is available.', + { reason: 'missing_dispatch' }, + ); +} + function resolveInsertedListItem(editor: Editor, sdBlockId: string): ListItemProjection { const index = getBlockIndex(editor); const byNodeId = index.candidates.find( @@ -82,57 +128,98 @@ function resolveInsertedListItem(editor: Editor, sdBlockId: string): ListItemPro ); } -function selectionAnchorPos(item: ListItemProjection): number { - return item.candidate.pos + 1; +function withListTarget(editor: Editor, input: ListTargetInput): ListItemProjection { + return resolveListItem(editor, input.target); } -function setSelectionToListItem(editor: Editor, item: ListItemProjection): boolean { - const setTextSelection = requireEditorCommand( - editor.commands?.setTextSelection as SetTextSelectionCommand | undefined, - 'lists (setTextSelection)', - ) as SetTextSelectionCommand; - const anchor = selectionAnchorPos(item); - return Boolean(setTextSelection({ from: anchor, to: anchor })); +function hasLevelOverride(editor: Editor, numId: number, level: number): boolean { + const converter = editor as unknown as { + converter?: { + numbering?: { + definitions?: Record }> }>; + }; + }; + }; + const definition = converter.converter?.numbering?.definitions?.[numId]; + const ilvl = String(level); + return ( + definition?.elements?.some( + (element) => element.name === 'w:lvlOverride' && element.attributes?.['w:ilvl'] === ilvl, + ) ?? false + ); } -function isAtMaximumLevel(editor: Editor, item: ListItemProjection): boolean { - if (item.numId == null || item.level == null) return false; - return !ListHelpers.hasListDefinition(editor, item.numId, item.level + 1); -} +/** + * Shared core of setLevel, indent, and outdent. + * Validates preconditions and performs the level change. + */ +function executeSetLevel( + editor: Editor, + target: ListItemProjection, + newLevel: number, + options?: MutationOptions, +): ListsMutateItemResult { + if (target.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { + target: target.address, + }); + } -function isRestartNoOp(editor: Editor, item: ListItemProjection): boolean { - if (item.ordinal !== 1) return false; - if (item.numId == null) return false; + if (newLevel < 0 || newLevel > 8) { + return toListsFailure('LEVEL_OUT_OF_RANGE', 'Level must be between 0 and 8.', { level: newLevel }); + } - const index = getBlockIndex(editor); - const currentIndex = index.candidates.findIndex( - (candidate) => candidate.nodeType === 'listItem' && candidate.nodeId === item.address.nodeId, - ); - if (currentIndex <= 0) return true; + if (target.level === newLevel) { + return toListsFailure('NO_OP', 'Item is already at the requested level.', { + target: target.address, + level: newLevel, + }); + } - for (let cursor = currentIndex - 1; cursor >= 0; cursor -= 1) { - const previous = index.candidates[cursor]!; - if (previous.node.type.name !== 'paragraph') { - return true; - } - if (previous.nodeType !== 'listItem') { + if (!ListHelpers.hasListDefinition(editor, target.numId, newLevel)) { + return toListsFailure('LEVEL_OUT_OF_RANGE', 'Target level is not defined in the active numbering definition.', { + target: target.address, + level: newLevel, + }); + } + + if (options?.dryRun) { + return { success: true, item: target.address }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + updateNumberingProperties( + { numId: target.numId!, ilvl: newLevel }, + target.candidate.node, + target.candidate.pos, + editor, + tr, + ); + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); return true; - } + }, + { expectedRevision: options?.expectedRevision }, + ); - const previousProjection = resolveListItem(editor, { - kind: 'block', - nodeType: 'listItem', - nodeId: previous.nodeId, + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'Level change could not be applied.', { + target: target.address, + level: newLevel, }); - - return previousProjection.numId !== item.numId || previousProjection.level !== item.level; } - return true; + return { success: true, item: target.address }; } -function withListTarget(editor: Editor, input: ListTargetInput): ListItemProjection { - return resolveListItem(editor, input.target); +/** + * Determine if a target is a BlockRange (has `from` property) or a single BlockAddress. + */ +function isBlockRange(target: unknown): target is { from: { nodeId: string }; to: { nodeId: string } } { + return typeof target === 'object' && target !== null && 'from' in target; } // --------------------------------------------------------------------------- @@ -145,11 +232,24 @@ export function listsListWrapper(editor: Editor, query?: ListsListQuery): ListsL export function listsGetWrapper(editor: Editor, input: ListsGetInput): ListItemInfo { const item = resolveListItem(editor, input.address); - return listItemProjectionToInfo(item); + return listItemProjectionToInfo(item, computeSequenceId(editor, item)); +} + +export function listsCanJoinWrapper(editor: Editor, input: ListsCanJoinInput): ListsCanJoinResult { + const target = resolveListItem(editor, input.target); + return evaluateCanJoin(editor, target, input.direction); +} + +export function listsCanContinuePreviousWrapper( + editor: Editor, + input: ListsCanContinuePreviousInput, +): ListsCanContinuePreviousResult { + const target = resolveListItem(editor, input.target); + return evaluateCanContinuePrevious(editor, target); } // --------------------------------------------------------------------------- -// Mutating operations (wrappers) +// Kept mutations (insert, indent, outdent) // --------------------------------------------------------------------------- export function listsInsertWrapper( @@ -212,7 +312,10 @@ export function listsInsertWrapper( }); } - if (!created) { + // TypeScript cannot track closure mutations — cast after the null guard. + const resolved = created as ListItemProjection | null; + + if (!resolved) { return { success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: createdId }, @@ -226,208 +329,658 @@ export function listsInsertWrapper( return { success: true, - item: created.address, + item: resolved.address, insertionPoint: { kind: 'text', - blockId: created.address.nodeId, + blockId: resolved.address.nodeId, range: { start: 0, end: 0 }, }, trackedChangeRefs: mode === 'tracked' - ? collectTrackInsertRefsInRange(editor, created.candidate.pos, created.candidate.end) + ? collectTrackInsertRefsInRange(editor, resolved.candidate.pos, resolved.candidate.end) : undefined, }; } -export function listsSetTypeWrapper( +export function listsIndentWrapper( editor: Editor, - input: ListSetTypeInput, + input: ListTargetInput, options?: MutationOptions, ): ListsMutateItemResult { - rejectTrackedMode('lists.setType', options); + rejectTrackedMode('lists.indent', options); const target = withListTarget(editor, input); - if (target.kind === input.kind) { - return toListsFailure('NO_OP', 'List item already has the requested list kind.', { - target: input.target, - kind: input.kind, + const currentLevel = target.level ?? 0; + return executeSetLevel(editor, target, currentLevel + 1, options); +} + +export function listsOutdentWrapper( + editor: Editor, + input: ListTargetInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.outdent', options); + const target = withListTarget(editor, input); + const currentLevel = target.level ?? 0; + if (currentLevel <= 0) { + return toListsFailure('NO_OP', 'List item is already at level 0.', { target: input.target }); + } + return executeSetLevel(editor, target, currentLevel - 1, options); +} + +// --------------------------------------------------------------------------- +// New SD-1272 mutations +// --------------------------------------------------------------------------- + +export function listsCreateWrapper( + editor: Editor, + input: ListsCreateInput, + options?: MutationOptions, +): ListsCreateResult { + rejectTrackedMode('lists.create', options); + + // Runtime guard: the TypeScript union enforces mode-conditional fields at compile time, + // but JSON/HTTP callers bypass that. Validate before destructuring. + const raw = input as Record; + if (input.mode === 'empty' && raw.at == null) { + return toListsFailure('INVALID_TARGET', 'Mode "empty" requires an "at" field.', { mode: 'empty' }); + } + if (input.mode === 'fromParagraphs' && raw.target == null) { + return toListsFailure('INVALID_TARGET', 'Mode "fromParagraphs" requires a "target" field.', { + mode: 'fromParagraphs', }); } - const setListTypeAt = requireEditorCommand( - editor.commands?.setListTypeAt as SetListTypeAtCommand | undefined, - 'lists.setType (setListTypeAt)', - ) as SetListTypeAtCommand; + const level = input.level ?? 0; + if (level < 0 || level > 8) { + return toListsFailure('LEVEL_OUT_OF_RANGE', 'Level must be between 0 and 8.', { level }); + } + + const listType = input.kind === 'ordered' ? 'orderedList' : 'bulletList'; + + if (input.mode === 'empty') { + const block = resolveBlock(editor, input.at.nodeId); + if (block.nodeType === 'listItem') { + return toListsFailure('INVALID_TARGET', 'Target paragraph is already a list item.', { target: input.at }); + } + + if (options?.dryRun) { + return { success: true, listId: '(dry-run)', item: { kind: 'block', nodeType: 'listItem', nodeId: '(dry-run)' } }; + } + + let numId: number | undefined; + const receipt = executeDomainCommand( + editor, + () => { + numId = ListHelpers.getNewListId(editor); + ListHelpers.generateNewListDefinition({ numId, listType, editor }); + const { tr } = editor.state; + updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr); + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'List creation could not be applied.', { mode: input.mode }); + } + + return { + success: true, + listId: `${numId!}:${block.nodeId}`, + item: { kind: 'block', nodeType: 'listItem', nodeId: block.nodeId }, + }; + } + + // mode: 'fromParagraphs' + const targets = isBlockRange(input.target) + ? resolveBlocksInRange(editor, input.target.from.nodeId, input.target.to.nodeId) + : [resolveBlock(editor, input.target.nodeId)]; + + if (targets.length === 0) { + return toListsFailure('INVALID_TARGET', 'No paragraphs found in the specified range.', { target: input.target }); + } + + const alreadyListItem = targets.find((t) => t.nodeType === 'listItem'); + if (alreadyListItem) { + return toListsFailure('INVALID_TARGET', 'One or more target paragraphs are already list items.', { + nodeId: alreadyListItem.nodeId, + }); + } if (options?.dryRun) { - return { success: true, item: target.address }; + return { + success: true, + listId: '(dry-run)', + item: { kind: 'block', nodeType: 'listItem', nodeId: targets[0]!.nodeId }, + }; } - const receipt = executeDomainCommand(editor, () => setListTypeAt({ pos: target.candidate.pos, kind: input.kind }), { - expectedRevision: options?.expectedRevision, - }); + let numId: number | undefined; + const receipt = executeDomainCommand( + editor, + () => { + numId = ListHelpers.getNewListId(editor); + ListHelpers.generateNewListDefinition({ numId, listType, editor }); + const { tr } = editor.state; + for (const block of targets) { + updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); if (receipt.steps[0]?.effect !== 'changed') { - return toListsFailure('INVALID_TARGET', 'List type conversion could not be applied.', { - target: input.target, - kind: input.kind, - }); + return toListsFailure('INVALID_TARGET', 'List creation could not be applied.', { mode: input.mode }); } - return { success: true, item: target.address }; + return { + success: true, + listId: `${numId!}:${targets[0]!.nodeId}`, + item: { kind: 'block', nodeType: 'listItem', nodeId: targets[0]!.nodeId }, + }; } -export function listsIndentWrapper( +export function listsAttachWrapper( editor: Editor, - input: ListTargetInput, + input: ListsAttachInput, options?: MutationOptions, ): ListsMutateItemResult { - rejectTrackedMode('lists.indent', options); - const target = withListTarget(editor, input); - if (isAtMaximumLevel(editor, target)) { - return toListsFailure('NO_OP', 'List item is already at the maximum supported level.', { target: input.target }); + rejectTrackedMode('lists.attach', options); + + const attachTo = resolveListItem(editor, input.attachTo); + if (attachTo.numId == null) { + return toListsFailure('INVALID_TARGET', 'attachTo target must be a list item with numbering metadata.', { + attachTo: input.attachTo, + }); } - const increaseListIndent = requireEditorCommand( - editor.commands?.increaseListIndent as (() => boolean) | undefined, - 'lists.indent (increaseListIndent)', - ) as () => boolean; + const numId = attachTo.numId; + const level = input.level ?? attachTo.level ?? 0; + + const targets = isBlockRange(input.target) + ? resolveBlocksInRange(editor, input.target.from.nodeId, input.target.to.nodeId) + : [resolveBlock(editor, (input.target as { nodeId: string }).nodeId)]; + + if (targets.length === 0) { + return toListsFailure('INVALID_TARGET', 'No paragraphs found in the specified target.', { target: input.target }); + } + + const alreadyListItem = targets.find((t) => t.nodeType === 'listItem'); + if (alreadyListItem) { + return toListsFailure('INVALID_TARGET', 'Target paragraphs are already list items.', { + nodeId: alreadyListItem.nodeId, + }); + } if (options?.dryRun) { - return { success: true, item: target.address }; + return { success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: targets[0]!.nodeId } }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + for (const block of targets) { + updateNumberingProperties({ numId, ilvl: level }, block.node, block.pos, editor, tr); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'List attachment could not be applied.', { target: input.target }); + } + + return { success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: targets[0]!.nodeId } }; +} + +export function listsDetachWrapper( + editor: Editor, + input: ListsDetachInput, + options?: MutationOptions, +): ListsDetachResult { + rejectTrackedMode('lists.detach', options); + const target = resolveListItem(editor, input.target); + + if (options?.dryRun) { + return { success: true, paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: target.address.nodeId } }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + updateNumberingProperties(null, target.candidate.node, target.candidate.pos, editor, tr); + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'List detach could not be applied.', { target: input.target }); + } + + return { success: true, paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: target.address.nodeId } }; +} + +export function listsJoinWrapper(editor: Editor, input: ListsJoinInput, options?: MutationOptions): ListsJoinResult { + rejectTrackedMode('lists.join', options); + + const target = resolveListItem(editor, input.target); + if (target.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); } - if (!setSelectionToListItem(editor, target)) { - return toListsFailure('INVALID_TARGET', 'List item target could not be selected for indentation.', { + const canJoinResult = evaluateCanJoin(editor, target, input.direction); + if (!canJoinResult.canJoin) { + return toListsFailure(canJoinResult.reason!, `Cannot join: ${canJoinResult.reason}`, { target: input.target, + direction: input.direction, }); } - const receipt = executeDomainCommand(editor, () => increaseListIndent(), { - expectedRevision: options?.expectedRevision, - }); + const adjacent = findAdjacentSequence(editor, target, input.direction)!; + + // Determine absorbing numId, merged anchor, and items to reassign. + // The anchor is the first item of the absorbing sequence (pre-mutation), + // which becomes the first item of the merged sequence post-mutation. + let absorbingNumId: number; + let absorbedItems: ListItemProjection[]; + let anchorNodeId: string; + + if (input.direction === 'withPrevious') { + absorbingNumId = adjacent.numId; + absorbedItems = getContiguousSequence(editor, target); + anchorNodeId = adjacent.sequence[0]?.address.nodeId ?? target.address.nodeId; + } else { + absorbingNumId = target.numId; + absorbedItems = adjacent.sequence; + const targetSequence = getContiguousSequence(editor, target); + anchorNodeId = targetSequence[0]?.address.nodeId ?? target.address.nodeId; + } + + const mergedListId = `${absorbingNumId}:${anchorNodeId}`; + + if (options?.dryRun) { + return { success: true, listId: mergedListId }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + for (const item of absorbedItems) { + const currentLevel = item.level ?? 0; + updateNumberingProperties( + { numId: absorbingNumId, ilvl: currentLevel }, + item.candidate.node, + item.candidate.pos, + editor, + tr, + ); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); if (receipt.steps[0]?.effect !== 'changed') { - return toListsFailure('INVALID_TARGET', 'List indentation could not be applied.', { target: input.target }); + return toListsFailure('INVALID_TARGET', 'List join could not be applied.', { + target: input.target, + direction: input.direction, + }); } - return { success: true, item: target.address }; + return { success: true, listId: mergedListId }; } -export function listsOutdentWrapper( +export function listsSeparateWrapper( editor: Editor, - input: ListTargetInput, + input: ListsSeparateInput, + options?: MutationOptions, +): ListsSeparateResult { + rejectTrackedMode('lists.separate', options); + + const target = resolveListItem(editor, input.target); + if (target.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); + } + + if (isFirstInSequence(editor, target)) { + return toListsFailure('NO_OP', 'Target is already the first item in its sequence.', { target: input.target }); + } + + const copyOverrides = input.copyOverrides !== false; + const abstractNumId = getAbstractNumId(editor, target.numId); + if (abstractNumId == null) { + return toListsFailure('INVALID_TARGET', 'Could not resolve abstract definition for target.', { + target: input.target, + }); + } + + const itemsToReassign = getSequenceFromTarget(editor, target); + + if (options?.dryRun) { + return { success: true, listId: '(dry-run)', numId: 0 }; + } + + let newNumId: number | undefined; + const receipt = executeDomainCommand( + editor, + () => { + const result = ListHelpers.createNumDefinition(editor, abstractNumId, { + copyOverridesFrom: copyOverrides ? target.numId! : undefined, + }); + newNumId = result.numId; + + const { tr } = editor.state; + for (const item of itemsToReassign) { + const currentLevel = item.level ?? 0; + updateNumberingProperties( + { numId: newNumId, ilvl: currentLevel }, + item.candidate.node, + item.candidate.pos, + editor, + tr, + ); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'List separation could not be applied.', { target: input.target }); + } + + return { success: true, listId: `${newNumId!}:${target.address.nodeId}`, numId: newNumId! }; +} + +export function listsSetLevelWrapper( + editor: Editor, + input: ListsSetLevelInput, options?: MutationOptions, ): ListsMutateItemResult { - rejectTrackedMode('lists.outdent', options); - const target = withListTarget(editor, input); - if ((target.level ?? 0) <= 0) { - return toListsFailure('NO_OP', 'List item is already at level 0.', { target: input.target }); + rejectTrackedMode('lists.setLevel', options); + const target = resolveListItem(editor, input.target); + return executeSetLevel(editor, target, input.level, options); +} + +export function listsSetValueWrapper( + editor: Editor, + input: ListsSetValueInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.setValue', options); + + const target = resolveListItem(editor, input.target); + if (target.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); } - const decreaseListIndent = requireEditorCommand( - editor.commands?.decreaseListIndent as (() => boolean) | undefined, - 'lists.outdent (decreaseListIndent)', - ) as () => boolean; + const level = target.level ?? 0; if (options?.dryRun) { return { success: true, item: target.address }; } - if (!setSelectionToListItem(editor, target)) { - return toListsFailure('INVALID_TARGET', 'List item target could not be selected for outdent.', { + // Remove override + if (input.value === null) { + if (!hasLevelOverride(editor, target.numId, level)) { + return toListsFailure('NO_OP', 'No startOverride to remove.', { target: input.target }); + } + + const receipt = executeDomainCommand( + editor, + () => { + ListHelpers.removeLvlOverride(editor, target.numId!, level); + dispatchEditorTransaction(editor, editor.state.tr); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('NO_OP', 'No startOverride to remove.', { target: input.target }); + } + + return { success: true, item: target.address }; + } + + const isFirst = isFirstInSequence(editor, target); + + if (isFirst) { + // Simple case: set startOverride on existing numId + const receipt = executeDomainCommand( + editor, + () => { + ListHelpers.setLvlOverride(editor, target.numId!, level, { startOverride: input.value as number }); + dispatchEditorTransaction(editor, editor.state.tr); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'setValue could not be applied.', { target: input.target }); + } + + return { success: true, item: target.address }; + } + + // Mid-sequence: separate first, then set value + const abstractNumId = getAbstractNumId(editor, target.numId); + if (abstractNumId == null) { + return toListsFailure('INVALID_TARGET', 'Could not resolve abstract definition for target.', { target: input.target, }); } - const receipt = executeDomainCommand(editor, () => decreaseListIndent(), { - expectedRevision: options?.expectedRevision, - }); + const itemsToReassign = getSequenceFromTarget(editor, target); + + const receipt = executeDomainCommand( + editor, + () => { + // 1. Create new numId pointing to same abstract, copying overrides + const { numId: newNumId } = ListHelpers.createNumDefinition(editor, abstractNumId, { + copyOverridesFrom: target.numId!, + }); + + // 2. Set startOverride on the new numId + ListHelpers.setLvlOverride(editor, newNumId, level, { startOverride: input.value as number }); + + // 3. Reassign items from target onwards to new numId + const { tr } = editor.state; + for (const item of itemsToReassign) { + const currentLevel = item.level ?? 0; + updateNumberingProperties( + { numId: newNumId, ilvl: currentLevel }, + item.candidate.node, + item.candidate.pos, + editor, + tr, + ); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); if (receipt.steps[0]?.effect !== 'changed') { - return toListsFailure('INVALID_TARGET', 'List outdent could not be applied.', { target: input.target }); + return toListsFailure('INVALID_TARGET', 'setValue could not be applied.', { target: input.target }); } return { success: true, item: target.address }; } -export function listsRestartWrapper( +export function listsContinuePreviousWrapper( editor: Editor, - input: ListTargetInput, + input: ListsContinuePreviousInput, options?: MutationOptions, ): ListsMutateItemResult { - rejectTrackedMode('lists.restart', options); - const target = withListTarget(editor, input); + rejectTrackedMode('lists.continuePrevious', options); + + const target = resolveListItem(editor, input.target); if (target.numId == null) { - return toListsFailure('INVALID_TARGET', 'List restart requires numbering metadata on the target item.', { - target: input.target, - }); + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); } - if (isRestartNoOp(editor, target)) { - return toListsFailure('NO_OP', 'List item is already the start of a sequence that effectively starts at 1.', { + + const canContinue = evaluateCanContinuePrevious(editor, target); + if (!canContinue.canContinue) { + // Map read-only query reasons to declared mutation failure codes + const reasonToFailureCode: Record = { + NO_PREVIOUS_LIST: 'NO_COMPATIBLE_PREVIOUS', + INCOMPATIBLE_DEFINITIONS: 'NO_COMPATIBLE_PREVIOUS', + ALREADY_CONTINUOUS: 'ALREADY_CONTINUOUS', + }; + const code = reasonToFailureCode[canContinue.reason!] ?? 'INVALID_TARGET'; + return toListsFailure(code, `Cannot continue previous: ${canContinue.reason}`, { target: input.target, }); } - const restartNumbering = requireEditorCommand( - editor.commands?.restartNumbering as (() => boolean) | undefined, - 'lists.restart (restartNumbering)', - ) as () => boolean; + const previous = findPreviousCompatibleSequence(editor, target)!; if (options?.dryRun) { return { success: true, item: target.address }; } - if (!setSelectionToListItem(editor, target)) { - return toListsFailure('INVALID_TARGET', 'List item target could not be selected for restart.', { - target: input.target, - }); - } + const sequence = getContiguousSequence(editor, target); + const level = target.level ?? 0; - const receipt = executeDomainCommand(editor, () => restartNumbering(), { - expectedRevision: options?.expectedRevision, - }); + const receipt = executeDomainCommand( + editor, + () => { + // Remove startOverride on target's level (if any) + ListHelpers.removeLvlOverride(editor, target.numId!, level); + + const { tr } = editor.state; + for (const item of sequence) { + const currentLevel = item.level ?? 0; + updateNumberingProperties( + { numId: previous.numId, ilvl: currentLevel }, + item.candidate.node, + item.candidate.pos, + editor, + tr, + ); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); if (receipt.steps[0]?.effect !== 'changed') { - return toListsFailure('INVALID_TARGET', 'List restart could not be applied.', { target: input.target }); + return toListsFailure('INVALID_TARGET', 'continuePrevious could not be applied.', { target: input.target }); } return { success: true, item: target.address }; } -export function listsExitWrapper(editor: Editor, input: ListTargetInput, options?: MutationOptions): ListsExitResult { - rejectTrackedMode('lists.exit', options); - const target = withListTarget(editor, input); +export function listsSetLevelRestartWrapper( + editor: Editor, + input: ListsSetLevelRestartInput, + options?: MutationOptions, +): ListsMutateItemResult { + rejectTrackedMode('lists.setLevelRestart', options); - const exitListItemAt = requireEditorCommand( - editor.commands?.exitListItemAt as ExitListItemAtCommand | undefined, - 'lists.exit (exitListItemAt)', - ) as ExitListItemAtCommand; + const target = resolveListItem(editor, input.target); + if (target.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); + } + + if (input.level < 0 || input.level > 8) { + return toListsFailure('LEVEL_OUT_OF_RANGE', 'Level must be between 0 and 8.', { level: input.level }); + } if (options?.dryRun) { - return { - success: true, - paragraph: { - kind: 'block', - nodeType: 'paragraph', - nodeId: '(dry-run)', - }, - }; + return { success: true, item: target.address }; } - const receipt = executeDomainCommand(editor, () => exitListItemAt({ pos: target.candidate.pos }), { - expectedRevision: options?.expectedRevision, - }); + const scope = input.scope ?? 'definition'; + + const receipt = executeDomainCommand( + editor, + () => { + if (scope === 'instance') { + ListHelpers.setLvlOverride(editor, target.numId!, input.level, { + lvlRestart: input.restartAfterLevel, + }); + } else { + const abstractNumId = getAbstractNumId(editor, target.numId!); + if (abstractNumId == null) return false; + ListHelpers.setLvlRestartOnAbstract(editor, abstractNumId, input.level, input.restartAfterLevel); + } + dispatchEditorTransaction(editor, editor.state.tr); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); if (receipt.steps[0]?.effect !== 'changed') { - return toListsFailure('INVALID_TARGET', 'List exit could not be applied.', { target: input.target }); + return toListsFailure('INVALID_TARGET', 'setLevelRestart could not be applied.', { target: input.target }); } - return { - success: true, - paragraph: { - kind: 'block', - nodeType: 'paragraph', - nodeId: target.address.nodeId, + return { success: true, item: target.address }; +} + +export function listsConvertToTextWrapper( + editor: Editor, + input: ListsConvertToTextInput, + options?: MutationOptions, +): ListsConvertToTextResult { + rejectTrackedMode('lists.convertToText', options); + + const target = resolveListItem(editor, input.target); + const includeMarker = input.includeMarker ?? false; + + if (options?.dryRun) { + return { success: true, paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: target.address.nodeId } }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + + // Optionally prepend marker text before clearing numbering + if (includeMarker && target.marker) { + const startPos = target.candidate.pos + 1; + tr.insertText(target.marker, startPos); + } + + // Clear numbering properties (uses original node position — still valid + // because insertText only modifies content inside the node, not the node boundary) + updateNumberingProperties(null, target.candidate.node, target.candidate.pos, editor, tr); + + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; }, - }; + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'convertToText could not be applied.', { target: input.target }); + } + + return { success: true, paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: target.address.nodeId } }; } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts index 24df1be01b..9f59d760a1 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts @@ -41,7 +41,7 @@ import { collectTocSources, buildTocEntryParagraphs, type EntryParagraphJson } f import { paginate } from '../helpers/adapter-utils.js'; import { getRevision } from './revision-tracker.js'; import { executeDomainCommand } from './plan-wrappers.js'; -import { requireEditorCommand, rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; import { resolveBlockInsertionPos } from './create-insertion.js'; @@ -145,6 +145,27 @@ function runTocCommand(editor: Editor, command: unknown, args: TocCommandArgs, e return runTocAction(editor, () => executeCommand(args), expectedRevision); } +function normalizeTocContent(content: unknown, editor: Editor): ProseMirrorNode[] | null { + if (!Array.isArray(content)) return null; + return content.map((entry) => + entry && typeof entry === 'object' && typeof (entry as { type?: unknown }).type === 'string' + ? editor.state.schema.nodeFromJSON(entry as Record) + : (entry as ProseMirrorNode), + ); +} + +function dispatchEditorTransaction(editor: Editor, tr: unknown): void { + if (typeof editor.dispatch === 'function') { + editor.dispatch(tr as Parameters[0]); + return; + } + if (typeof editor.view?.dispatch === 'function') { + editor.view.dispatch(tr as Parameters['dispatch']>[0]); + return; + } + throw new Error('No transaction dispatcher available.'); +} + /** Returns true if the receipt indicates the command had an effect. */ function receiptApplied(receipt: ReturnType): boolean { return receipt.steps[0]?.effect === 'changed'; @@ -192,10 +213,44 @@ function withRightAlign(config: TocSwitchConfig, rightAlignPageNumbers: boolean return { ...config, display: { ...config.display, rightAlignPageNumbers } }; } -function materializeTocContent(doc: ProseMirrorNode, config: TocSwitchConfig): EntryParagraphJson[] { +/** + * Removes tocPageNumber marks when the active schema doesn't define that mark. + * Some headless/test schemas omit TOC-specific marks, and nodeFromJSON fails if + * unknown marks are present in generated TOC paragraph content. + */ +function sanitizeTocContentForSchema(content: EntryParagraphJson[], editor: Editor): EntryParagraphJson[] { + if (editor.state.schema?.marks?.tocPageNumber) return content; + + return content.map((paragraph) => { + const paragraphContent = paragraph.content; + if (!Array.isArray(paragraphContent)) return paragraph; + + let changed = false; + const sanitizedContent = paragraphContent.map((node) => { + if (!node || typeof node !== 'object') return node; + const typedNode = node as { marks?: Array<{ type?: string }> }; + const marks = typedNode.marks; + if (!Array.isArray(marks)) return node; + const filteredMarks = marks.filter((mark) => mark?.type !== 'tocPageNumber'); + if (filteredMarks.length === marks.length) return node; + + changed = true; + if (filteredMarks.length === 0) { + const { marks: _removed, ...rest } = typedNode; + return rest as typeof node; + } + return { ...typedNode, marks: filteredMarks } as typeof node; + }); + + return changed ? ({ ...paragraph, content: sanitizedContent } as EntryParagraphJson) : paragraph; + }); +} + +function materializeTocContent(doc: ProseMirrorNode, config: TocSwitchConfig, editor: Editor): EntryParagraphJson[] { const sources = collectTocSources(doc, config); const entryParagraphs = buildTocEntryParagraphs(sources, config); - return entryParagraphs.length > 0 ? entryParagraphs : NO_ENTRIES_PLACEHOLDER; + const content = entryParagraphs.length > 0 ? entryParagraphs : NO_ENTRIES_PLACEHOLDER; + return sanitizeTocContentForSchema(content, editor); } // --------------------------------------------------------------------------- @@ -208,11 +263,11 @@ export function tocConfigureWrapper( options?: MutationOptions, ): TocMutationResult { rejectTrackedMode('toc.configure', options); - const command = requireEditorCommand(editor.commands?.setTableOfContentsInstructionById, 'toc.configure'); const resolved = resolveTocTarget(editor.state.doc, input.target); const currentConfig = parseTocInstruction(resolved.node.attrs?.instruction ?? ''); const patched = applyTocPatchTyped(currentConfig, input.patch); + const instruction = serializeTocInstruction(patched); // rightAlignPageNumbers is a PM node attr, not an instruction switch const rightAlignChanged = @@ -223,7 +278,7 @@ export function tocConfigureWrapper( // Patch value takes priority; fall back to existing node attr. const effectiveRightAlign = input.patch.rightAlignPageNumbers ?? (resolved.node.attrs?.rightAlignPageNumbers as boolean | undefined); - const nextContent = materializeTocContent(editor.state.doc, withRightAlign(patched, effectiveRightAlign)); + const nextContent = materializeTocContent(editor.state.doc, withRightAlign(patched, effectiveRightAlign), editor); if (areTocConfigsEqual(currentConfig, patched) && !rightAlignChanged) { return tocFailure('NO_OP', 'Configuration patch produced no change.'); @@ -234,18 +289,44 @@ export function tocConfigureWrapper( } const shouldRefreshContent = !isTocContentUnchanged(resolved.node, nextContent); + const command = editor.commands?.setTableOfContentsInstructionById; const commandNodeId = resolved.commandNodeId ?? resolved.nodeId; - const receipt = runTocCommand( - editor, - command, - { - sdBlockId: commandNodeId, - instruction: serializeTocInstruction(patched), - ...(shouldRefreshContent ? { content: nextContent } : {}), - ...(rightAlignChanged ? { rightAlignPageNumbers: input.patch.rightAlignPageNumbers } : {}), - }, - options?.expectedRevision, - ); + const receipt = + typeof command === 'function' + ? runTocCommand( + editor, + command, + { + sdBlockId: commandNodeId, + instruction, + ...(shouldRefreshContent ? { content: nextContent } : {}), + ...(rightAlignChanged ? { rightAlignPageNumbers: input.patch.rightAlignPageNumbers } : {}), + }, + options?.expectedRevision, + ) + : runTocAction( + editor, + () => { + try { + const { tr } = editor.state; + tr.setNodeMarkup(resolved.pos, undefined, { + ...resolved.node.attrs, + instruction, + ...(rightAlignChanged ? { rightAlignPageNumbers: input.patch.rightAlignPageNumbers } : {}), + }); + if (shouldRefreshContent) { + const from = resolved.pos + 1; + const to = resolved.pos + resolved.node.nodeSize - 1; + tr.replaceWith(from, to, normalizeTocContent(nextContent, editor) ?? []); + } + dispatchEditorTransaction(editor, tr); + return true; + } catch { + return false; + } + }, + options?.expectedRevision, + ); if (!receiptApplied(receipt)) { return tocFailure('NO_OP', 'Configuration change could not be applied.'); @@ -277,12 +358,10 @@ export function tocUpdateWrapper(editor: Editor, input: TocUpdateInput, options? * This is the original toc.update behavior. */ function tocUpdateAll(editor: Editor, input: TocUpdateInput, options?: MutationOptions): TocMutationResult { - const command = requireEditorCommand(editor.commands?.replaceTableOfContentsContentById, 'toc.update'); - const resolved = resolveTocTarget(editor.state.doc, input.target); const config = parseTocInstruction(resolved.node.attrs?.instruction ?? ''); const rightAlign = resolved.node.attrs?.rightAlignPageNumbers as boolean | undefined; - const content = materializeTocContent(editor.state.doc, withRightAlign(config, rightAlign)); + const content = materializeTocContent(editor.state.doc, withRightAlign(config, rightAlign), editor); // NO_OP detection: compare new content against existing before executing. // The PM command returns "found" (not "content changed"), so receipt-based @@ -295,15 +374,34 @@ function tocUpdateAll(editor: Editor, input: TocUpdateInput, options?: MutationO return tocSuccess(resolved.nodeId); } - const receipt = runTocCommand( - editor, - command, - { - sdBlockId: resolved.commandNodeId ?? resolved.nodeId, - content, - }, - options?.expectedRevision, - ); + const command = editor.commands?.replaceTableOfContentsContentById; + const receipt = + typeof command === 'function' + ? runTocCommand( + editor, + command, + { + sdBlockId: resolved.commandNodeId ?? resolved.nodeId, + content, + }, + options?.expectedRevision, + ) + : runTocAction( + editor, + () => { + try { + const { tr } = editor.state; + const from = resolved.pos + 1; + const to = resolved.pos + resolved.node.nodeSize - 1; + tr.replaceWith(from, to, normalizeTocContent(content, editor) ?? []); + dispatchEditorTransaction(editor, tr); + return true; + } catch { + return false; + } + }, + options?.expectedRevision, + ); return receiptApplied(receipt) ? tocSuccess(resolved.nodeId) : tocFailure('NO_OP', 'TOC update produced no change.'); } @@ -350,8 +448,6 @@ function getPageMap(editor: Editor): Map | null { * 4. Marks found, page map available → update each marked run, success */ function tocUpdatePageNumbers(editor: Editor, input: TocUpdateInput, options?: MutationOptions): TocMutationResult { - const command = requireEditorCommand(editor.commands?.replaceTableOfContentsContentById, 'toc.update'); - const resolved = resolveTocTarget(editor.state.doc, input.target); const config = parseTocInstruction(resolved.node.attrs?.instruction ?? ''); @@ -387,15 +483,34 @@ function tocUpdatePageNumbers(editor: Editor, input: TocUpdateInput, options?: M return tocSuccess(resolved.nodeId); } - const receipt = runTocCommand( - editor, - command, - { - sdBlockId: resolved.commandNodeId ?? resolved.nodeId, - content: updatedContent, - }, - options?.expectedRevision, - ); + const command = editor.commands?.replaceTableOfContentsContentById; + const receipt = + typeof command === 'function' + ? runTocCommand( + editor, + command, + { + sdBlockId: resolved.commandNodeId ?? resolved.nodeId, + content: updatedContent, + }, + options?.expectedRevision, + ) + : runTocAction( + editor, + () => { + try { + const { tr } = editor.state; + const from = resolved.pos + 1; + const to = resolved.pos + resolved.node.nodeSize - 1; + tr.replaceWith(from, to, normalizeTocContent(updatedContent, editor) ?? []); + dispatchEditorTransaction(editor, tr); + return true; + } catch { + return false; + } + }, + options?.expectedRevision, + ); return receiptApplied(receipt) ? tocSuccess(resolved.nodeId) @@ -466,7 +581,6 @@ function buildPageNumberUpdatedContent( export function tocRemoveWrapper(editor: Editor, input: TocRemoveInput, options?: MutationOptions): TocMutationResult { rejectTrackedMode('toc.remove', options); - const command = requireEditorCommand(editor.commands?.deleteTableOfContentsById, 'toc.remove'); const resolved = resolveTocTarget(editor.state.doc, input.target); @@ -474,14 +588,31 @@ export function tocRemoveWrapper(editor: Editor, input: TocRemoveInput, options? return tocSuccess(resolved.nodeId); } - const receipt = runTocCommand( - editor, - command, - { - sdBlockId: resolved.commandNodeId ?? resolved.nodeId, - }, - options?.expectedRevision, - ); + const command = editor.commands?.deleteTableOfContentsById; + const receipt = + typeof command === 'function' + ? runTocCommand( + editor, + command, + { + sdBlockId: resolved.commandNodeId ?? resolved.nodeId, + }, + options?.expectedRevision, + ) + : runTocAction( + editor, + () => { + try { + const { tr } = editor.state; + tr.delete(resolved.pos, resolved.pos + resolved.node.nodeSize); + dispatchEditorTransaction(editor, tr); + return true; + } catch { + return false; + } + }, + options?.expectedRevision, + ); return receiptApplied(receipt) ? tocSuccess(resolved.nodeId) : tocFailure('NO_OP', 'TOC removal produced no change.'); } @@ -496,7 +627,6 @@ export function createTableOfContentsWrapper( options?: MutationOptions, ): CreateTableOfContentsResult { rejectTrackedMode('create.tableOfContents', options); - const command = requireEditorCommand(editor.commands?.insertTableOfContentsAt, 'create.tableOfContents'); // Resolve insertion position const at = input.at ?? { kind: 'documentEnd' as const }; @@ -512,7 +642,11 @@ export function createTableOfContentsWrapper( // Build instruction from config patch or use defaults const config = input.config ? applyTocPatchTyped(DEFAULT_TOC_CONFIG, input.config) : DEFAULT_TOC_CONFIG; const instruction = serializeTocInstruction(config); - const content = materializeTocContent(editor.state.doc, withRightAlign(config, input.config?.rightAlignPageNumbers)); + const content = materializeTocContent( + editor.state.doc, + withRightAlign(config, input.config?.rightAlignPageNumbers), + editor, + ); const sdBlockId = uuidv4(); @@ -520,20 +654,57 @@ export function createTableOfContentsWrapper( return { success: true, toc: buildTocAddress('(dry-run)') }; } - const receipt = runTocCommand( - editor, - command, - { - pos, - instruction, - sdBlockId, - content, - ...(input.config?.rightAlignPageNumbers !== undefined - ? { rightAlignPageNumbers: input.config.rightAlignPageNumbers } - : {}), - }, - options?.expectedRevision, - ); + const command = editor.commands?.insertTableOfContentsAt; + const receipt = + typeof command === 'function' + ? runTocCommand( + editor, + command, + { + pos, + instruction, + sdBlockId, + content, + ...(input.config?.rightAlignPageNumbers !== undefined + ? { rightAlignPageNumbers: input.config.rightAlignPageNumbers } + : {}), + }, + options?.expectedRevision, + ) + : runTocAction( + editor, + () => { + const tocType = editor.state.schema.nodes.tableOfContents; + const paragraphType = editor.state.schema.nodes.paragraph; + if (!tocType || !paragraphType) return false; + + const defaultContent = [ + paragraphType.create({}, editor.state.schema.text('Update table of contents to populate entries.')), + ]; + const materializedContent = normalizeTocContent(content, editor) ?? defaultContent; + const tocNode = tocType.create( + { + instruction, + sdBlockId, + ...(input.config?.rightAlignPageNumbers !== undefined + ? { rightAlignPageNumbers: input.config.rightAlignPageNumbers } + : {}), + }, + materializedContent, + ); + + try { + const { tr } = editor.state; + tr.insert(pos, tocNode); + dispatchEditorTransaction(editor, tr); + return true; + } catch (error) { + if (error instanceof RangeError) return false; + throw error; + } + }, + options?.expectedRevision, + ); if (!receiptApplied(receipt)) { return { diff --git a/packages/super-editor/src/tests/data/pre-separated-list.docx b/packages/super-editor/src/tests/data/pre-separated-list.docx new file mode 100644 index 0000000000..8125601470 Binary files /dev/null and b/packages/super-editor/src/tests/data/pre-separated-list.docx differ diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index e37d880ccc..6081962962 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -655,26 +655,35 @@ function createFixture(page: Page, editor: Locator, modKey: string) { page.evaluate( ({ text, commentId }) => { const normalize = (value: string) => value.replace(/\s+/g, ' ').trim(); - const highlights = Array.from(document.querySelectorAll('.superdoc-comment-highlight')); + const highlights = Array.from(document.querySelectorAll('.superdoc-comment-highlight')).map((el) => ({ + text: normalize(el.textContent ?? ''), + commentIds: (el.getAttribute('data-comment-ids') ?? '').split(/[\s,]+/).filter(Boolean), + })); if (highlights.length === 0) return false; - if (text) { - const expected = normalize(text); - const hasTextMatch = highlights.some((el) => normalize(el.textContent ?? '').includes(expected)); - if (!hasTextMatch) return false; - } + const relevant = commentId + ? highlights.filter((entry) => entry.commentIds.includes(commentId)) + : highlights; + if (relevant.length === 0) return false; - if (commentId) { - const hasCommentId = highlights.some((el) => - (el.getAttribute('data-comment-ids') ?? '') - .split(/[\s,]+/) - .filter(Boolean) - .includes(commentId), - ); - if (!hasCommentId) return false; - } + if (!text) return true; + + const expected = normalize(text); + if (expected.length === 0) return true; + + const hasDirectTextMatch = relevant.some((entry) => entry.text.includes(expected)); + if (hasDirectTextMatch) return true; + + if (!commentId) return false; - return true; + // Highlights for the same comment may be split across multiple DOM nodes. + const aggregatedText = normalize( + relevant + .map((entry) => entry.text) + .filter(Boolean) + .join(' '), + ); + return aggregatedText.includes(expected); }, { text: expectedText, commentId: expectedCommentId }, ), diff --git a/tests/behavior/helpers/comments.ts b/tests/behavior/helpers/comments.ts index 0c8d6a0c5b..a36255663e 100644 --- a/tests/behavior/helpers/comments.ts +++ b/tests/behavior/helpers/comments.ts @@ -69,18 +69,21 @@ export async function activateCommentDialog( await superdoc.waitForStable(); } + const activeDialog = superdoc.page.locator('.comment-placeholder .comments-dialog.is-active').last(); const dialog = activeCommentDialog(superdoc.page); - const isActive = await dialog.isVisible({ timeout: 2_000 }).catch(() => false); + const hasActiveDialog = (await activeDialog.count()) > 0; - if (!isActive) { + if (!hasActiveDialog) { // Fallback: click the floating dialog directly to trigger setFocus → is-active const floatingDialog = superdoc.page.locator('.comment-placeholder .comments-dialog').last(); await expect(floatingDialog).toBeVisible({ timeout: timeoutMs }); - await floatingDialog.click(); + // Click near the top-left to avoid accidentally hitting interactive controls + // such as the "N more replies" collapse/expand pill in the middle of the card. + await floatingDialog.click({ position: { x: 12, y: 12 } }); await superdoc.waitForStable(); - const isActiveNow = await dialog.isVisible({ timeout: 2_000 }).catch(() => false); - if (!isActiveNow) { + const hasActiveDialogNow = (await activeDialog.count()) > 0; + if (!hasActiveDialogNow) { // Last resort: set activeComment directly on the Pinia store. This is // needed when click events don't propagate to activate the dialog // (Firefox/WebKit) or replyToComment calls set it to a child ID. @@ -99,6 +102,11 @@ export async function activateCommentDialog( } } + if ((await activeDialog.count()) > 0) { + await expect(activeDialog).toBeVisible({ timeout: timeoutMs }); + return activeDialog; + } + await expect(dialog).toBeVisible({ timeout: timeoutMs }); return dialog; } diff --git a/tests/behavior/tests/comments/comment-on-tracked-change.spec.ts b/tests/behavior/tests/comments/comment-on-tracked-change.spec.ts index 34c461bf8f..9b4871a76c 100644 --- a/tests/behavior/tests/comments/comment-on-tracked-change.spec.ts +++ b/tests/behavior/tests/comments/comment-on-tracked-change.spec.ts @@ -39,11 +39,18 @@ test('comment thread on tracked change shows both the change and replies', async await expect(dialog.locator('.tracked-change-text.is-inserted', { hasText: 'new text' })).toBeVisible(); await expect(dialog.locator('.tracked-change-text.is-deleted').first()).toBeVisible(); - // The threaded comment replies should be visible below the tracked change + // Threads with >=2 replies are collapsed by default: only the latest reply is visible + const collapsedPill = dialog.locator('.collapsed-replies'); + await expect(collapsedPill).toBeVisible({ timeout: 5_000 }); + await expect(collapsedPill).toContainText('1 more reply'); + + // In collapsed state, only one reply body is visible const commentBodies = dialog.locator('.comment-body .comment'); - await expect(commentBodies).toHaveCount(2); - await expect(commentBodies.nth(0)).toContainText('reply to tracked change'); - await expect(commentBodies.nth(1)).toContainText('reply to reply'); + await expect(commentBodies).toHaveCount(1); + await expect(commentBodies.first()).toContainText('reply to reply'); + + // Hidden reply summary should remain visible in collapsed mode + await expect(collapsedPill).toBeVisible(); await superdoc.snapshot('comment thread on tracked change'); }); diff --git a/tests/behavior/tests/comments/comment-thread-collapse.spec.ts b/tests/behavior/tests/comments/comment-thread-collapse.spec.ts index 5dd3082c6f..ee27ec3168 100644 --- a/tests/behavior/tests/comments/comment-thread-collapse.spec.ts +++ b/tests/behavior/tests/comments/comment-thread-collapse.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../fixtures/superdoc.js'; -import { addCommentViaUIWithId, activateCommentDialog } from '../../helpers/comments.js'; +import { addCommentViaUIWithId } from '../../helpers/comments.js'; import { assertDocumentApiReady, replyToComment } from '../../helpers/document-api.js'; test.use({ config: { toolbar: 'full', comments: 'on' } }); @@ -42,13 +42,15 @@ test('thread with 2+ replies collapses and expands on click', async ({ superdoc }); await superdoc.waitForStable(); - // Wait for dialog to lose active state before re-activating — Firefox needs - // this gap so the component fully unmounts its expanded state. - const activeDialog = superdoc.page.locator('.comment-placeholder .comments-dialog.is-active'); - await expect(activeDialog).toHaveCount(0, { timeout: 5_000 }); + // Activate parent thread deterministically (avoid click-path races in Firefox). + await superdoc.page.evaluate((id: string) => { + const sd = (window as any).superdoc; + sd.commentsStore.$patch({ activeComment: id }); + }, commentId); + await superdoc.waitForStable(); - // Activate the comment dialog - const dialog = await activateCommentDialog(superdoc, 'collapse'); + const dialog = superdoc.page.locator(`.comment-placeholder[data-comment-id="${commentId}"] .comments-dialog`).first(); + await expect(dialog).toBeVisible({ timeout: 10_000 }); // The collapsed-replies pill should be visible with "more replies" text const collapsedPill = dialog.locator('.collapsed-replies'); diff --git a/tests/behavior/tests/formatting/decoration-survives-mark-change.spec.ts b/tests/behavior/tests/formatting/decoration-survives-mark-change.spec.ts index 4c17c44a2b..eb93832352 100644 --- a/tests/behavior/tests/formatting/decoration-survives-mark-change.spec.ts +++ b/tests/behavior/tests/formatting/decoration-survives-mark-change.spec.ts @@ -43,7 +43,6 @@ test.describe('comment highlight survives mark changes', () => { // Comment highlight must still be present await superdoc.assertCommentHighlightExists({ - text: 'brown fox', commentId, }); @@ -78,9 +77,9 @@ test.describe('comment highlight survives mark changes', () => { await superdoc.assertCommentHighlightExists({ commentId }); // Each part of the range should still carry the highlight - await superdoc.assertCommentHighlightExists({ text: 'quick' }); - await superdoc.assertCommentHighlightExists({ text: 'brown' }); - await superdoc.assertCommentHighlightExists({ text: 'fox' }); + await superdoc.assertCommentHighlightExists({ text: 'quick', commentId }); + await superdoc.assertCommentHighlightExists({ text: 'brown', commentId }); + await superdoc.assertCommentHighlightExists({ text: 'fox', commentId }); // Italic applied to "brown" await superdoc.assertTextHasMarks('brown', ['italic']); @@ -107,15 +106,15 @@ test.describe('comment highlight survives mark changes', () => { await superdoc.bold(); await superdoc.waitForStable(); - await superdoc.assertCommentHighlightExists({ text: 'resilience test', commentId }); + await superdoc.assertCommentHighlightExists({ commentId }); await superdoc.italic(); await superdoc.waitForStable(); - await superdoc.assertCommentHighlightExists({ text: 'resilience test', commentId }); + await superdoc.assertCommentHighlightExists({ commentId }); await superdoc.underline(); await superdoc.waitForStable(); - await superdoc.assertCommentHighlightExists({ text: 'resilience test', commentId }); + await superdoc.assertCommentHighlightExists({ commentId }); // All three marks should be present await superdoc.assertTextHasMarks('resilience test', ['bold', 'italic', 'underline']);