diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 6d652249b0..2a4e65fc0c 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -59,6 +59,10 @@ const INTENT_NAMES = { 'doc.replace': 'replace_content', 'doc.delete': 'delete_content', 'doc.format.apply': 'format_apply', + 'doc.format.fontSize': 'format_font_size', + 'doc.format.fontFamily': 'format_font_family', + 'doc.format.color': 'format_color', + 'doc.format.align': 'format_align', 'doc.create.paragraph': 'create_paragraph', 'doc.create.heading': 'create_heading', 'doc.lists.list': 'list_lists', diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 38142f0fe7..fe537d16a2 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -521,6 +521,82 @@ export const SUCCESS_SCENARIOS = { ], }; }, + 'doc.format.fontSize': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-format-font-size-success'); + const docPath = await harness.copyFixtureDoc('doc-format-font-size'); + const target = await harness.firstTextRange(docPath, stateDir); + return { + stateDir, + args: [ + 'format', + 'font-size', + docPath, + '--target-json', + JSON.stringify(target), + '--value-json', + JSON.stringify('14pt'), + '--out', + harness.createOutputPath('doc-format-font-size-output'), + ], + }; + }, + 'doc.format.fontFamily': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-format-font-family-success'); + const docPath = await harness.copyFixtureDoc('doc-format-font-family'); + const target = await harness.firstTextRange(docPath, stateDir); + return { + stateDir, + args: [ + 'format', + 'font-family', + docPath, + '--target-json', + JSON.stringify(target), + '--value-json', + JSON.stringify('Arial'), + '--out', + harness.createOutputPath('doc-format-font-family-output'), + ], + }; + }, + 'doc.format.color': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-format-color-success'); + const docPath = await harness.copyFixtureDoc('doc-format-color'); + const target = await harness.firstTextRange(docPath, stateDir); + return { + stateDir, + args: [ + 'format', + 'color', + docPath, + '--target-json', + JSON.stringify(target), + '--value-json', + JSON.stringify('#ff0000'), + '--out', + harness.createOutputPath('doc-format-color-output'), + ], + }; + }, + 'doc.format.align': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-format-align-success'); + const docPath = await harness.copyFixtureDoc('doc-format-align'); + const target = await harness.firstTextRange(docPath, stateDir); + return { + stateDir, + args: [ + 'format', + 'align', + docPath, + '--target-json', + JSON.stringify(target), + '--alignment-json', + JSON.stringify('center'), + '--out', + harness.createOutputPath('doc-format-align-output'), + ], + }; + }, 'doc.trackChanges.list': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-track-changes-list-success'); const fixture = await harness.addTrackedChangeFixture(stateDir, 'doc-track-changes-list'); diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 09b7b283e1..15326e15ea 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -36,6 +36,10 @@ export const SUCCESS_VERB: Record = { replace: 'replaced text', delete: 'deleted text', 'format.apply': 'applied style', + 'format.fontSize': 'set font size', + 'format.fontFamily': 'set font family', + 'format.color': 'set text color', + 'format.align': 'set alignment', 'create.paragraph': 'created paragraph', 'create.heading': 'created heading', 'lists.list': 'listed items', @@ -93,6 +97,10 @@ export const OUTPUT_FORMAT: Record = { replace: 'mutationReceipt', delete: 'mutationReceipt', 'format.apply': 'mutationReceipt', + 'format.fontSize': 'mutationReceipt', + 'format.fontFamily': 'mutationReceipt', + 'format.color': 'mutationReceipt', + 'format.align': 'mutationReceipt', 'create.paragraph': 'createResult', 'create.heading': 'createResult', 'lists.list': 'listResult', @@ -138,6 +146,10 @@ export const RESPONSE_ENVELOPE_KEY: Record replace: null, delete: null, 'format.apply': null, + 'format.fontSize': null, + 'format.fontFamily': null, + 'format.color': null, + 'format.align': null, 'create.paragraph': 'result', 'create.heading': 'result', 'lists.list': 'result', @@ -178,6 +190,10 @@ export const RESPONSE_VALIDATION_KEY: Partial = replace: 'textMutation', delete: 'textMutation', 'format.apply': 'textMutation', + 'format.fontSize': 'textMutation', + 'format.fontFamily': 'textMutation', + 'format.color': 'textMutation', + 'format.align': 'textMutation', 'create.paragraph': 'create', 'create.heading': 'create', 'lists.list': 'lists', diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index ce2c194643..18d8d28543 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -347,6 +347,10 @@ const EXTRA_CLI_PARAMS: Partial> = { 'doc.replace': [...TEXT_TARGET_FLAT_PARAMS], 'doc.delete': [...TEXT_TARGET_FLAT_PARAMS], 'doc.format.apply': [...TEXT_TARGET_FLAT_PARAMS], + 'doc.format.fontSize': [...TEXT_TARGET_FLAT_PARAMS], + 'doc.format.fontFamily': [...TEXT_TARGET_FLAT_PARAMS], + 'doc.format.color': [...TEXT_TARGET_FLAT_PARAMS], + 'doc.format.align': [...TEXT_TARGET_FLAT_PARAMS], 'doc.comments.create': [...TEXT_TARGET_FLAT_PARAMS], 'doc.comments.patch': [...TEXT_TARGET_FLAT_PARAMS], // List operations: flat flag (--node-id) as shortcut for --target-json, plus --input-json @@ -398,9 +402,9 @@ const CLI_ONLY_METADATA: Record = { 'doc.open': { command: 'open', positionalParams: ['doc'], - docRequirement: 'required', + docRequirement: 'none', params: [ - { name: 'doc', kind: 'doc', type: 'string', required: true }, + { name: 'doc', kind: 'doc', type: 'string' }, SESSION_PARAM, { name: 'collaboration', kind: 'jsonFlag', flag: 'collaboration-json', type: 'json' }, { name: 'collabDocumentId', kind: 'flag', flag: 'collab-document-id', type: 'string' }, diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index a6bc83d5d8..31d69e889e 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -1,4 +1,4 @@ -import { getBooleanOption, getStringOption, requireDocArg, resolveJsonInput } from '../lib/args'; +import { getBooleanOption, getStringOption, resolveDocArg, resolveJsonInput } from '../lib/args'; import { parseCollaborationInput, resolveCollaborationProfile } from '../lib/collaboration'; import { getProjectRoot, @@ -27,21 +27,21 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis command: 'open', data: { usage: [ - 'superdoc open [--session ]', - 'superdoc open --collaboration-json "{...}" [--session ]', + 'superdoc open [doc] [--session ]', + 'superdoc open [doc] --collaboration-json "{...}" [--session ]', ], }, pretty: [ 'Usage:', - ' superdoc open [--session ]', - ' superdoc open --collaboration-json "{...}" [--session ]', + ' superdoc open [doc] [--session ]', + ' superdoc open [doc] --collaboration-json "{...}" [--session ]', ].join('\n'), }; } - const { doc } = requireDocArg(parsed, 'open'); + const { doc } = resolveDocArg(parsed, 'open'); - const sessionId = context.sessionId ?? generateSessionId(doc); + const sessionId = context.sessionId ?? generateSessionId(doc ?? 'blank'); const collaborationPayload = await resolveJsonInput(parsed, 'collaboration'); const collabUrl = getStringOption(parsed, 'collab-url'); const collabDocumentId = getStringOption(parsed, 'collab-document-id'); @@ -98,8 +98,12 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis ); } + if (collaboration && doc == null) { + throw new CliError('MISSING_REQUIRED', 'open: a document path is required when using collaboration.'); + } + const opened = collaboration - ? await openCollaborativeDocument(doc, context.io, collaboration) + ? await openCollaborativeDocument(doc!, context.io, collaboration) : await openDocument(doc, context.io); let adoptedToHostPool = false; try { @@ -143,7 +147,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis openedAt: metadata.openedAt, updatedAt: metadata.updatedAt, }, - pretty: `Opened ${metadata.sourcePath ?? ''} in context ${metadata.contextId} (${metadata.sessionType})`, + pretty: `Opened ${metadata.sourcePath ?? (metadata.source === 'blank' ? '' : '')} in context ${metadata.contextId} (${metadata.sessionType})`, }; } finally { if (!adoptedToHostPool) { diff --git a/apps/cli/src/lib/context.ts b/apps/cli/src/lib/context.ts index 00b1a955df..4aff3fa7af 100644 --- a/apps/cli/src/lib/context.ts +++ b/apps/cli/src/lib/context.ts @@ -25,7 +25,7 @@ export type SessionType = 'local' | 'collab'; export type ContextMetadata = { contextId: string; projectRoot: string; - source: 'path' | 'stdin'; + source: 'path' | 'stdin' | 'blank'; sourcePath?: string; workingDocPath: string; dirty: boolean; @@ -48,7 +48,7 @@ export type ContextPaths = { export type ProjectSessionSummary = { sessionId: string; - source: 'path' | 'stdin'; + source: 'path' | 'stdin' | 'blank'; sourcePath?: string; dirty: boolean; revision: number; @@ -653,7 +653,7 @@ export function createInitialContextMetadata( paths: ContextPaths, contextId: string, input: { - source: 'path' | 'stdin'; + source: 'path' | 'stdin' | 'blank'; sourcePath?: string; sourceSnapshot?: SourceSnapshot; sessionType?: SessionType; diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index 4daa46dd75..a345ef9213 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -1,6 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { Editor } from 'superdoc/super-editor'; +import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx'; import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; import { createDocumentApi, type DocumentApi } from '@superdoc/document-api'; @@ -80,14 +81,28 @@ async function readDocumentSource(doc: string, io: CliIO): Promise<{ bytes: Uint }; } -export async function openDocument(doc: string, io: CliIO, options: OpenDocumentOptions = {}): Promise { - const { bytes, meta } = await readDocumentSource(doc, io); +export async function openDocument( + doc: string | undefined, + io: CliIO, + options: OpenDocumentOptions = {}, +): Promise { + let source: Uint8Array; + let meta: DocumentSourceMeta; + + if (doc != null) { + const result = await readDocumentSource(doc, io); + source = result.bytes; + meta = result.meta; + } else { + source = Buffer.from(BLANK_DOCX_BASE64, 'base64'); + meta = { source: 'blank', byteLength: source.byteLength }; + } let editor: Editor; try { const isTest = process.env.NODE_ENV === 'test'; - editor = await Editor.open(Buffer.from(bytes), { - documentId: options.documentId ?? meta.path ?? 'stdin.docx', + editor = await Editor.open(Buffer.from(source), { + documentId: options.documentId ?? meta.path ?? 'blank.docx', user: { id: 'cli', name: 'CLI' }, ...(isTest ? { telemetry: { enabled: false } } : {}), ydoc: options.ydoc, diff --git a/apps/cli/src/lib/invoke-input.ts b/apps/cli/src/lib/invoke-input.ts index 1fcaf9163d..f94a17b04b 100644 --- a/apps/cli/src/lib/invoke-input.ts +++ b/apps/cli/src/lib/invoke-input.ts @@ -74,6 +74,10 @@ const TEXT_TARGET_OPERATIONS = new Set([ 'replace', 'delete', 'format.apply', + 'format.fontSize', + 'format.fontFamily', + 'format.color', + 'format.align', 'comments.create', 'comments.patch', ]); diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index 041cee9ba0..d65d19a5de 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -33,7 +33,7 @@ const STATELESS_OUT_EXEMPT = new Set([]); type DocumentPayload = { path?: string; - source: 'path' | 'stdin'; + source: 'path' | 'stdin' | 'blank'; byteLength: number; revision: number; }; diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index 2c03988ea0..acfded47d0 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -21,7 +21,7 @@ import { extractInvokeInput } from './invoke-input.js'; type DocumentPayload = { path?: string; - source: 'path' | 'stdin'; + source: 'path' | 'stdin' | 'blank'; byteLength: number; revision: number; }; diff --git a/apps/cli/src/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index e793e541d5..fe37441e4b 100644 --- a/apps/cli/src/lib/special-handlers.ts +++ b/apps/cli/src/lib/special-handlers.ts @@ -244,6 +244,10 @@ export const POST_INVOKE_HOOKS: Partial { const record = asRecord(result); diff --git a/apps/cli/src/lib/types.ts b/apps/cli/src/lib/types.ts index 88749a9aba..688cc41515 100644 --- a/apps/cli/src/lib/types.ts +++ b/apps/cli/src/lib/types.ts @@ -79,7 +79,7 @@ export interface CommandContext { } export interface DocumentSourceMeta { - source: 'path' | 'stdin'; + source: 'path' | 'stdin' | 'blank'; path?: string; byteLength: number; } diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 470e858132..eaee51654a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -7,7 +7,10 @@ "skipLibCheck": true, "types": ["bun"], "paths": { - "@superdoc/super-editor/document-api-adapters": ["../../packages/super-editor/src/document-api-adapters/index.ts"] + "@superdoc/super-editor/document-api-adapters": [ + "../../packages/super-editor/src/document-api-adapters/index.ts" + ], + "@superdoc/super-editor/blank-docx": ["../../packages/super-editor/src/core/blank-docx.ts"] } }, "include": ["src"] diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 95cfc5f271..124d25cdc8 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -18,7 +18,7 @@ Use the tables below to see what operations are available and where each one is | Comments | 5 | 0 | 5 | [Reference](/document-api/reference/comments/index) | | Core | 8 | 0 | 8 | [Reference](/document-api/reference/core/index) | | Create | 2 | 0 | 2 | [Reference](/document-api/reference/create/index) | -| Format | 1 | 4 | 5 | [Reference](/document-api/reference/format/index) | +| Format | 5 | 4 | 9 | [Reference](/document-api/reference/format/index) | | Lists | 8 | 0 | 8 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | | Query | 1 | 0 | 1 | [Reference](/document-api/reference/query/index) | @@ -43,6 +43,10 @@ Use the tables below to see what operations are available and where each one is | editor.doc.create.paragraph(...) | [`create.paragraph`](/document-api/reference/create/paragraph) | | editor.doc.create.heading(...) | [`create.heading`](/document-api/reference/create/heading) | | editor.doc.format.apply(...) | [`format.apply`](/document-api/reference/format/apply) | +| editor.doc.format.fontSize(...) | [`format.fontSize`](/document-api/reference/format/font-size) | +| editor.doc.format.fontFamily(...) | [`format.fontFamily`](/document-api/reference/format/font-family) | +| editor.doc.format.color(...) | [`format.color`](/document-api/reference/format/color) | +| editor.doc.format.align(...) | [`format.align`](/document-api/reference/format/align) | | editor.doc.format.bold(...) | [`format.apply`](/document-api/reference/format/apply) | | editor.doc.format.italic(...) | [`format.apply`](/document-api/reference/format/apply) | | editor.doc.format.underline(...) | [`format.apply`](/document-api/reference/format/apply) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index fe29c19564..e4ba769ce4 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -15,7 +15,11 @@ "apps/docs/document-api/reference/create/paragraph.mdx", "apps/docs/document-api/reference/delete.mdx", "apps/docs/document-api/reference/find.mdx", + "apps/docs/document-api/reference/format/align.mdx", "apps/docs/document-api/reference/format/apply.mdx", + "apps/docs/document-api/reference/format/color.mdx", + "apps/docs/document-api/reference/format/font-family.mdx", + "apps/docs/document-api/reference/format/font-size.mdx", "apps/docs/document-api/reference/format/index.mdx", "apps/docs/document-api/reference/get-node-by-id.mdx", "apps/docs/document-api/reference/get-node.mdx", @@ -69,7 +73,7 @@ { "aliasMemberPaths": ["format.bold", "format.italic", "format.underline", "format.strikethrough"], "key": "format", - "operationIds": ["format.apply"], + "operationIds": ["format.apply", "format.fontSize", "format.fontFamily", "format.color", "format.align"], "pagePath": "apps/docs/document-api/reference/format/index.mdx", "title": "Format" }, @@ -119,5 +123,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "81ed2589e4d1277639235807ef0028264465e71a2a01ec9f5e86a8c2f5755cef" + "sourceHash": "80b39063fa1bd5bf9ba81bada2ad7e8602d0851ba76d002258aae2328f207cfe" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 3e2fffc316..45a085fe4e 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -152,6 +152,14 @@ _No fields._ ], "tracked": true }, + "format.align": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "format.apply": { "available": true, "dryRun": true, @@ -160,6 +168,30 @@ _No fields._ ], "tracked": true }, + "format.color": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "format.fontFamily": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "format.fontSize": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "getNode": { "available": true, "dryRun": true, @@ -801,6 +833,38 @@ _No fields._ ], "type": "object" }, + "format.align": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "format.apply": { "additionalProperties": false, "properties": { @@ -833,6 +897,102 @@ _No fields._ ], "type": "object" }, + "format.color": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "format.fontFamily": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "format.fontSize": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "getNode": { "additionalProperties": false, "properties": { @@ -1484,6 +1644,10 @@ _No fields._ "replace", "delete", "format.apply", + "format.fontSize", + "format.fontFamily", + "format.color", + "format.align", "create.paragraph", "create.heading", "lists.list", diff --git a/apps/docs/document-api/reference/format/align.mdx b/apps/docs/document-api/reference/format/align.mdx new file mode 100644 index 0000000000..242f01e3fa --- /dev/null +++ b/apps/docs/document-api/reference/format/align.mdx @@ -0,0 +1,237 @@ +--- +title: format.align +sidebarTitle: format.align +description: Reference for format.align +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `format.align` +- API member path: `editor.doc.format.align(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `alignment` | enum \\| null | yes | One of: enum, null | +| `target` | TextAddress | yes | TextAddress | + +### Example request + +```json +{ + "alignment": "left", + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + } +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "inserted": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ], + "resolution": { + "range": { + "from": 0, + "to": 10 + }, + "requestedTarget": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "text": "Hello, world." + }, + "success": true, + "updated": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ] +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "alignment": { + "oneOf": [ + { + "enum": [ + "left", + "center", + "right", + "justify" + ] + }, + { + "type": "null" + } + ] + }, + "target": { + "$ref": "#/$defs/TextAddress" + } + }, + "required": [ + "target", + "alignment" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "$ref": "#/$defs/TextMutationSuccess" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "$ref": "#/$defs/TextMutationSuccess" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/format/color.mdx b/apps/docs/document-api/reference/format/color.mdx new file mode 100644 index 0000000000..279b08234c --- /dev/null +++ b/apps/docs/document-api/reference/format/color.mdx @@ -0,0 +1,233 @@ +--- +title: format.color +sidebarTitle: format.color +description: Reference for format.color +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `format.color` +- API member path: `editor.doc.format.color(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | TextAddress | yes | TextAddress | +| `value` | string \\| null | yes | One of: string, null | + +### Example request + +```json +{ + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "value": "example" +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "inserted": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ], + "resolution": { + "range": { + "from": 0, + "to": 10 + }, + "requestedTarget": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "text": "Hello, world." + }, + "success": true, + "updated": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ] +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "$ref": "#/$defs/TextAddress" + }, + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "target", + "value" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "$ref": "#/$defs/TextMutationSuccess" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "$ref": "#/$defs/TextMutationSuccess" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/format/font-family.mdx b/apps/docs/document-api/reference/format/font-family.mdx new file mode 100644 index 0000000000..ace8d2108d --- /dev/null +++ b/apps/docs/document-api/reference/format/font-family.mdx @@ -0,0 +1,233 @@ +--- +title: format.fontFamily +sidebarTitle: format.fontFamily +description: Reference for format.fontFamily +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `format.fontFamily` +- API member path: `editor.doc.format.fontFamily(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | TextAddress | yes | TextAddress | +| `value` | string \\| null | yes | One of: string, null | + +### Example request + +```json +{ + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "value": "example" +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "inserted": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ], + "resolution": { + "range": { + "from": 0, + "to": 10 + }, + "requestedTarget": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "text": "Hello, world." + }, + "success": true, + "updated": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ] +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "$ref": "#/$defs/TextAddress" + }, + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "target", + "value" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "$ref": "#/$defs/TextMutationSuccess" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "$ref": "#/$defs/TextMutationSuccess" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/format/font-size.mdx b/apps/docs/document-api/reference/format/font-size.mdx new file mode 100644 index 0000000000..8f35ec3f58 --- /dev/null +++ b/apps/docs/document-api/reference/format/font-size.mdx @@ -0,0 +1,236 @@ +--- +title: format.fontSize +sidebarTitle: format.fontSize +description: Reference for format.fontSize +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `format.fontSize` +- API member path: `editor.doc.format.fontSize(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | TextAddress | yes | TextAddress | +| `value` | string \\| number \\| null | yes | One of: string, number, null | + +### Example request + +```json +{ + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "value": "example" +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "inserted": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ], + "resolution": { + "range": { + "from": 0, + "to": 10 + }, + "requestedTarget": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "text": "Hello, world." + }, + "success": true, + "updated": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ] +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "$ref": "#/$defs/TextAddress" + }, + "value": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "target", + "value" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "$ref": "#/$defs/TextMutationSuccess" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "$ref": "#/$defs/TextMutationSuccess" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "$ref": "#/$defs/TextMutationResolution" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/format/index.mdx b/apps/docs/document-api/reference/format/index.mdx index 6ec4957d88..fd00b38cca 100644 --- a/apps/docs/document-api/reference/format/index.mdx +++ b/apps/docs/document-api/reference/format/index.mdx @@ -15,6 +15,10 @@ Canonical formatting mutation with boolean patch semantics. | Operation | Member path | Mutates | Idempotency | Tracked | Dry run | | --- | --- | --- | --- | --- | --- | | format.apply | `format.apply` | Yes | `conditional` | Yes | Yes | +| format.fontSize | `format.fontSize` | Yes | `conditional` | No | Yes | +| format.fontFamily | `format.fontFamily` | Yes | `conditional` | No | Yes | +| format.color | `format.color` | Yes | `conditional` | No | Yes | +| format.align | `format.align` | Yes | `conditional` | No | Yes | ## Convenience aliases diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index ac9d65bff5..9a18ec531d 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -23,7 +23,7 @@ Document API is currently alpha and subject to breaking changes. | Core | 8 | 0 | 8 | [Open](/document-api/reference/core/index) | | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | | Create | 2 | 0 | 2 | [Open](/document-api/reference/create/index) | -| Format | 1 | 4 | 5 | [Open](/document-api/reference/format/index) | +| Format | 5 | 4 | 9 | [Open](/document-api/reference/format/index) | | Lists | 8 | 0 | 8 | [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) | @@ -65,6 +65,10 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | | format.apply | editor.doc.format.apply(...) | Apply explicit inline style changes (bold, italic, underline, strike) to the target range using boolean patch semantics. | +| format.fontSize | editor.doc.format.fontSize(...) | Set or unset the font size on the target text range. Pass null to remove. | +| format.fontFamily | editor.doc.format.fontFamily(...) | Set or unset the font family on the target text range. Pass null to remove. | +| format.color | editor.doc.format.color(...) | Set or unset the text color on the target text range. Pass null to remove. | +| format.align | editor.doc.format.align(...) | Set or unset paragraph alignment on the block containing the target. Pass null to reset to default. | | format.bold | editor.doc.format.bold(...) | Convenience alias for `format.apply` with `inline.bold: true`. | | format.italic | editor.doc.format.italic(...) | Convenience alias for `format.apply` with `inline.italic: true`. | | format.underline | editor.doc.format.underline(...) | Convenience alias for `format.apply` with `inline.underline: true`. | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 51beb049a2..dfc053a2f4 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -158,6 +158,10 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | | `doc.format.apply` | `format apply` | Apply explicit inline style changes (bold, italic, underline, strike) to the target range using boolean patch semantics. | +| `doc.format.fontSize` | `format font-size` | Set or unset the font size on the target text range. Pass null to remove. | +| `doc.format.fontFamily` | `format font-family` | Set or unset the font family on the target text range. Pass null to remove. | +| `doc.format.color` | `format color` | Set or unset the text color on the target text range. Pass null to remove. | +| `doc.format.align` | `format align` | Set or unset paragraph alignment on the block containing the target. Pass null to reset to default. | #### Create diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index c0006652c5..7abb6824fb 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -245,6 +245,62 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'format/apply.mdx', referenceGroup: 'format', }, + 'format.fontSize': { + memberPath: 'format.fontSize', + description: 'Set or unset the font size on the target text range. Pass null to remove.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'format/font-size.mdx', + referenceGroup: 'format', + }, + 'format.fontFamily': { + memberPath: 'format.fontFamily', + description: 'Set or unset the font family on the target text range. Pass null to remove.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'format/font-family.mdx', + referenceGroup: 'format', + }, + 'format.color': { + memberPath: 'format.color', + description: 'Set or unset the text color on the target text range. Pass null to remove.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'format/color.mdx', + referenceGroup: 'format', + }, + 'format.align': { + memberPath: 'format.align', + description: 'Set or unset paragraph alignment on the block containing the target. Pass null to reset to default.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'format/align.mdx', + referenceGroup: 'format', + }, 'create.paragraph': { memberPath: 'create.paragraph', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index d785d349b4..f7e991dfa7 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -26,7 +26,13 @@ import type { InsertInput } from '../insert/insert.js'; import type { ReplaceInput } from '../replace/replace.js'; import type { DeleteInput } from '../delete/delete.js'; import type { MutationOptions, RevisionGuardOptions } from '../write/write.js'; -import type { StyleApplyInput } from '../format/format.js'; +import type { + StyleApplyInput, + FormatFontSizeInput, + FormatFontFamilyInput, + FormatColorInput, + FormatAlignInput, +} from '../format/format.js'; import type { CommentsCreateInput, CommentsPatchInput, @@ -72,6 +78,10 @@ export interface OperationRegistry { // --- format.* --- 'format.apply': { input: StyleApplyInput; options: MutationOptions; output: TextMutationReceipt }; + 'format.fontSize': { input: FormatFontSizeInput; options: MutationOptions; output: TextMutationReceipt }; + 'format.fontFamily': { input: FormatFontFamilyInput; options: MutationOptions; output: TextMutationReceipt }; + 'format.color': { input: FormatColorInput; options: MutationOptions; output: TextMutationReceipt }; + 'format.align': { input: FormatAlignInput; options: MutationOptions; output: TextMutationReceipt }; // --- create.* --- 'create.paragraph': { input: CreateParagraphInput; options: MutationOptions; output: CreateParagraphResult }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index c258b8882e..f2938ae0fb 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2,6 +2,7 @@ import { COMMAND_CATALOG } from './command-catalog.js'; import { CONTRACT_VERSION, JSON_SCHEMA_DIALECT, OPERATION_IDS, type OperationId } from './types.js'; import { NODE_TYPES, BLOCK_NODE_TYPES, INLINE_NODE_TYPES } from '../types/base.js'; import { MARK_KEYS } from '../types/style-policy.types.js'; +import { ALIGNMENTS } from '../format/format.js'; type JsonSchema = Record; @@ -946,6 +947,54 @@ const operationSchemas: Record = { success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('format.apply'), }, + 'format.fontSize': { + input: objectSchema( + { + target: textAddressSchema, + value: { oneOf: [{ type: 'string', minLength: 1 }, { type: 'number' }, { type: 'null' }] }, + }, + ['target', 'value'], + ), + output: textMutationResultSchemaFor('format.fontSize'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('format.fontSize'), + }, + 'format.fontFamily': { + input: objectSchema( + { + target: textAddressSchema, + value: { oneOf: [{ type: 'string', minLength: 1 }, { type: 'null' }] }, + }, + ['target', 'value'], + ), + output: textMutationResultSchemaFor('format.fontFamily'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('format.fontFamily'), + }, + 'format.color': { + input: objectSchema( + { + target: textAddressSchema, + value: { oneOf: [{ type: 'string', minLength: 1 }, { type: 'null' }] }, + }, + ['target', 'value'], + ), + output: textMutationResultSchemaFor('format.color'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('format.color'), + }, + 'format.align': { + input: objectSchema( + { + target: textAddressSchema, + alignment: { oneOf: [{ enum: [...ALIGNMENTS] }, { type: 'null' }] }, + }, + ['target', 'alignment'], + ), + output: textMutationResultSchemaFor('format.align'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('format.align'), + }, 'create.paragraph': { input: objectSchema({ at: { diff --git a/packages/document-api/src/format/format.test.ts b/packages/document-api/src/format/format.test.ts index ca661d2e20..2730ef767f 100644 --- a/packages/document-api/src/format/format.test.ts +++ b/packages/document-api/src/format/format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { FormatAdapter, StyleApplyInput } from './format.js'; -import { executeStyleApply } from './format.js'; +import { executeStyleApply, executeFontSize, executeFontFamily, executeColor, executeAlign } from './format.js'; import { DocumentApiValidationError } from '../errors.js'; import type { TextMutationReceipt } from '../types/index.js'; @@ -19,8 +19,14 @@ function makeReceipt(): TextMutationReceipt { }; } -function makeAdapter(): FormatAdapter & { apply: ReturnType } { - return { apply: vi.fn(() => makeReceipt()) }; +function makeAdapter(): FormatAdapter & Record> { + return { + apply: vi.fn(() => makeReceipt()), + fontSize: vi.fn(() => makeReceipt()), + fontFamily: vi.fn(() => makeReceipt()), + color: vi.fn(() => makeReceipt()), + align: vi.fn(() => makeReceipt()), + }; } describe('executeStyleApply validation', () => { @@ -255,3 +261,174 @@ describe('executeStyleApply validation', () => { expect(adapter.apply).toHaveBeenCalledWith(input, expect.objectContaining({})); }); }); + +// --------------------------------------------------------------------------- +// Shared target validation helper for value-based format operations +// --------------------------------------------------------------------------- + +function targetValidationSuite( + name: string, + exec: (adapter: ReturnType, input: unknown, options?: unknown) => unknown, +) { + describe(`${name} target validation`, () => { + it('rejects non-object input', () => { + expect(() => exec(makeAdapter(), null)).toThrow(DocumentApiValidationError); + }); + + it('rejects missing target', () => { + expect(() => exec(makeAdapter(), { value: '12pt' })).toThrow('requires a target'); + }); + + it('rejects invalid target', () => { + expect(() => exec(makeAdapter(), { target: 'bad', value: '12pt' })).toThrow('text address'); + }); + }); +} + +// --------------------------------------------------------------------------- +// executeFontSize validation +// --------------------------------------------------------------------------- + +describe('executeFontSize validation', () => { + targetValidationSuite('format.fontSize', (a, i) => executeFontSize(a, i as any)); + + it('rejects missing value', () => { + expect(() => executeFontSize(makeAdapter(), { target: TARGET } as any)).toThrow('requires a value'); + }); + + it('rejects empty string value', () => { + expect(() => executeFontSize(makeAdapter(), { target: TARGET, value: '' })).toThrow('empty string'); + }); + + it('rejects boolean value', () => { + expect(() => executeFontSize(makeAdapter(), { target: TARGET, value: true } as any)).toThrow( + 'string, number, or null', + ); + }); + + it('rejects unknown fields', () => { + expect(() => executeFontSize(makeAdapter(), { target: TARGET, value: 12, extra: 1 } as any)).toThrow('extra'); + }); + + it('accepts null value (unset)', () => { + const adapter = makeAdapter(); + executeFontSize(adapter, { target: TARGET, value: null }); + expect(adapter.fontSize).toHaveBeenCalled(); + }); + + it('accepts string value', () => { + const adapter = makeAdapter(); + executeFontSize(adapter, { target: TARGET, value: '14pt' }); + expect(adapter.fontSize).toHaveBeenCalledWith({ target: TARGET, value: '14pt' }, expect.any(Object)); + }); + + it('accepts numeric value', () => { + const adapter = makeAdapter(); + executeFontSize(adapter, { target: TARGET, value: 16 }); + expect(adapter.fontSize).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// executeFontFamily validation +// --------------------------------------------------------------------------- + +describe('executeFontFamily validation', () => { + targetValidationSuite('format.fontFamily', (a, i) => executeFontFamily(a, i as any)); + + it('rejects missing value', () => { + expect(() => executeFontFamily(makeAdapter(), { target: TARGET } as any)).toThrow('requires a value'); + }); + + it('rejects empty string value', () => { + expect(() => executeFontFamily(makeAdapter(), { target: TARGET, value: '' })).toThrow('empty string'); + }); + + it('rejects non-string value', () => { + expect(() => executeFontFamily(makeAdapter(), { target: TARGET, value: 42 } as any)).toThrow('string or null'); + }); + + it('accepts null value (unset)', () => { + const adapter = makeAdapter(); + executeFontFamily(adapter, { target: TARGET, value: null }); + expect(adapter.fontFamily).toHaveBeenCalled(); + }); + + it('accepts valid string value', () => { + const adapter = makeAdapter(); + executeFontFamily(adapter, { target: TARGET, value: 'Arial' }); + expect(adapter.fontFamily).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// executeColor validation +// --------------------------------------------------------------------------- + +describe('executeColor validation', () => { + targetValidationSuite('format.color', (a, i) => executeColor(a, i as any)); + + it('rejects missing value', () => { + expect(() => executeColor(makeAdapter(), { target: TARGET } as any)).toThrow('requires a value'); + }); + + it('rejects empty string value', () => { + expect(() => executeColor(makeAdapter(), { target: TARGET, value: '' })).toThrow('empty string'); + }); + + it('rejects non-string value', () => { + expect(() => executeColor(makeAdapter(), { target: TARGET, value: 123 } as any)).toThrow('string or null'); + }); + + it('accepts null value (unset)', () => { + const adapter = makeAdapter(); + executeColor(adapter, { target: TARGET, value: null }); + expect(adapter.color).toHaveBeenCalled(); + }); + + it('accepts hex color string', () => { + const adapter = makeAdapter(); + executeColor(adapter, { target: TARGET, value: '#ff0000' }); + expect(adapter.color).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// executeAlign validation +// --------------------------------------------------------------------------- + +describe('executeAlign validation', () => { + targetValidationSuite('format.align', (a, i) => executeAlign(a, i as any)); + + it('rejects missing alignment', () => { + expect(() => executeAlign(makeAdapter(), { target: TARGET } as any)).toThrow('requires an alignment'); + }); + + it('rejects invalid alignment value', () => { + expect(() => executeAlign(makeAdapter(), { target: TARGET, alignment: 'middle' } as any)).toThrow( + 'left, center, right, justify', + ); + }); + + it('rejects empty string alignment', () => { + expect(() => executeAlign(makeAdapter(), { target: TARGET, alignment: '' } as any)).toThrow( + 'left, center, right, justify', + ); + }); + + it('rejects unknown fields', () => { + expect(() => executeAlign(makeAdapter(), { target: TARGET, alignment: 'left', extra: 1 } as any)).toThrow('extra'); + }); + + it('accepts null alignment (unset)', () => { + const adapter = makeAdapter(); + executeAlign(adapter, { target: TARGET, alignment: null }); + expect(adapter.align).toHaveBeenCalled(); + }); + + it.each(['left', 'center', 'right', 'justify'] as const)('accepts alignment "%s"', (alignment) => { + const adapter = makeAdapter(); + executeAlign(adapter, { target: TARGET, alignment }); + expect(adapter.align).toHaveBeenCalled(); + }); +}); diff --git a/packages/document-api/src/format/format.ts b/packages/document-api/src/format/format.ts index e3137a0b42..a4ec2343ed 100644 --- a/packages/document-api/src/format/format.ts +++ b/packages/document-api/src/format/format.ts @@ -4,6 +4,19 @@ import { MARK_KEY_SET } from '../types/style-policy.types.js'; import { DocumentApiValidationError } from '../errors.js'; import { isRecord, isTextAddress, assertNoUnknownFields } from '../validation-primitives.js'; +// --------------------------------------------------------------------------- +// Alignment enum +// --------------------------------------------------------------------------- + +/** Valid paragraph alignment values. */ +export const ALIGNMENTS = ['left', 'center', 'right', 'justify'] as const; +export type Alignment = (typeof ALIGNMENTS)[number]; +const ALIGNMENT_SET: ReadonlySet = new Set(ALIGNMENTS); + +// --------------------------------------------------------------------------- +// Input types — boolean toggle marks (existing) +// --------------------------------------------------------------------------- + /** * Input payload for `format.bold`. */ @@ -46,15 +59,56 @@ export interface StyleApplyInput { /** Options for `format.apply` — same shape as all other mutations. */ export type StyleApplyOptions = MutationOptions; +// --------------------------------------------------------------------------- +// Input types — value-based format operations (new) +// --------------------------------------------------------------------------- + +/** Input payload for `format.fontSize`. Pass `null` to unset. */ +export interface FormatFontSizeInput { + target: TextAddress; + value: string | number | null; +} + +/** Input payload for `format.fontFamily`. Pass `null` to unset. */ +export interface FormatFontFamilyInput { + target: TextAddress; + value: string | null; +} + +/** Input payload for `format.color`. Pass `null` to unset. */ +export interface FormatColorInput { + target: TextAddress; + value: string | null; +} + +/** Input payload for `format.align`. Pass `null` to unset (reset to default). */ +export interface FormatAlignInput { + target: TextAddress; + alignment: Alignment | null; +} + +// --------------------------------------------------------------------------- +// Adapter interface +// --------------------------------------------------------------------------- + /** - * Engine-specific adapter — only `apply()` is required. - * Per-mark methods were removed in the Phase 2c contract simplification. + * Engine-specific adapter for format operations. + * + * `apply()` handles boolean toggle marks. + * Value-based methods handle fontSize, fontFamily, color, and paragraph alignment. */ export interface FormatAdapter { - /** Apply explicit inline-style changes using boolean patch semantics. */ apply(input: StyleApplyInput, options?: MutationOptions): TextMutationReceipt; + fontSize(input: FormatFontSizeInput, options?: MutationOptions): TextMutationReceipt; + fontFamily(input: FormatFontFamilyInput, options?: MutationOptions): TextMutationReceipt; + color(input: FormatColorInput, options?: MutationOptions): TextMutationReceipt; + align(input: FormatAlignInput, options?: MutationOptions): TextMutationReceipt; } +// --------------------------------------------------------------------------- +// Public API surface +// --------------------------------------------------------------------------- + /** * Public helper surface exposed on `DocumentApi.format`. * Per-mark helpers route through `executeStyleApply` internally. @@ -65,6 +119,10 @@ export interface FormatApi { underline(input: FormatUnderlineInput, options?: MutationOptions): TextMutationReceipt; strikethrough(input: FormatStrikethroughInput, options?: MutationOptions): TextMutationReceipt; apply(input: StyleApplyInput, options?: MutationOptions): TextMutationReceipt; + fontSize(input: FormatFontSizeInput, options?: MutationOptions): TextMutationReceipt; + fontFamily(input: FormatFontFamilyInput, options?: MutationOptions): TextMutationReceipt; + color(input: FormatColorInput, options?: MutationOptions): TextMutationReceipt; + align(input: FormatAlignInput, options?: MutationOptions): TextMutationReceipt; } // --------------------------------------------------------------------------- @@ -165,3 +223,162 @@ export function executeStyleApply( validateStyleApplyInput(input); return adapter.apply(input, normalizeMutationOptions(options)); } + +// --------------------------------------------------------------------------- +// Shared validation: target field +// --------------------------------------------------------------------------- + +function validateTarget(input: unknown, operation: string): asserts input is { target: TextAddress } { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${operation} input must be a non-null object.`); + } + if (input.target === undefined) { + throw new DocumentApiValidationError('INVALID_TARGET', `${operation} requires a target.`); + } + if (!isTextAddress(input.target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + field: 'target', + value: input.target, + }); + } +} + +// --------------------------------------------------------------------------- +// format.fontSize — validation and execution +// --------------------------------------------------------------------------- + +const FONT_SIZE_ALLOWED_KEYS = new Set(['target', 'value']); + +function validateFontSizeInput(input: unknown): asserts input is FormatFontSizeInput { + validateTarget(input, 'format.fontSize'); + assertNoUnknownFields(input as Record, FONT_SIZE_ALLOWED_KEYS, 'format.fontSize'); + + const { value } = input as Record; + if (value === undefined) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.fontSize requires a value field.'); + } + if (value !== null && typeof value !== 'string' && typeof value !== 'number') { + throw new DocumentApiValidationError('INVALID_INPUT', `format.fontSize value must be a string, number, or null.`, { + field: 'value', + value, + }); + } + if (typeof value === 'string' && value.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.fontSize value must not be an empty string.', { + field: 'value', + }); + } +} + +export function executeFontSize( + adapter: FormatAdapter, + input: FormatFontSizeInput, + options?: MutationOptions, +): TextMutationReceipt { + validateFontSizeInput(input); + return adapter.fontSize(input, normalizeMutationOptions(options)); +} + +// --------------------------------------------------------------------------- +// format.fontFamily — validation and execution +// --------------------------------------------------------------------------- + +const FONT_FAMILY_ALLOWED_KEYS = new Set(['target', 'value']); + +function validateFontFamilyInput(input: unknown): asserts input is FormatFontFamilyInput { + validateTarget(input, 'format.fontFamily'); + assertNoUnknownFields(input as Record, FONT_FAMILY_ALLOWED_KEYS, 'format.fontFamily'); + + const { value } = input as Record; + if (value === undefined) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.fontFamily requires a value field.'); + } + if (value !== null && typeof value !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.fontFamily value must be a string or null.', { + field: 'value', + value, + }); + } + if (typeof value === 'string' && value.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.fontFamily value must not be an empty string.', { + field: 'value', + }); + } +} + +export function executeFontFamily( + adapter: FormatAdapter, + input: FormatFontFamilyInput, + options?: MutationOptions, +): TextMutationReceipt { + validateFontFamilyInput(input); + return adapter.fontFamily(input, normalizeMutationOptions(options)); +} + +// --------------------------------------------------------------------------- +// format.color — validation and execution +// --------------------------------------------------------------------------- + +const COLOR_ALLOWED_KEYS = new Set(['target', 'value']); + +function validateColorInput(input: unknown): asserts input is FormatColorInput { + validateTarget(input, 'format.color'); + assertNoUnknownFields(input as Record, COLOR_ALLOWED_KEYS, 'format.color'); + + const { value } = input as Record; + if (value === undefined) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.color requires a value field.'); + } + if (value !== null && typeof value !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.color value must be a string or null.', { + field: 'value', + value, + }); + } + if (typeof value === 'string' && value.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.color value must not be an empty string.', { + field: 'value', + }); + } +} + +export function executeColor( + adapter: FormatAdapter, + input: FormatColorInput, + options?: MutationOptions, +): TextMutationReceipt { + validateColorInput(input); + return adapter.color(input, normalizeMutationOptions(options)); +} + +// --------------------------------------------------------------------------- +// format.align — validation and execution +// --------------------------------------------------------------------------- + +const ALIGN_ALLOWED_KEYS = new Set(['target', 'alignment']); + +function validateAlignInput(input: unknown): asserts input is FormatAlignInput { + validateTarget(input, 'format.align'); + assertNoUnknownFields(input as Record, ALIGN_ALLOWED_KEYS, 'format.align'); + + const { alignment } = input as Record; + if (alignment === undefined) { + throw new DocumentApiValidationError('INVALID_INPUT', 'format.align requires an alignment field.'); + } + if (alignment !== null && (typeof alignment !== 'string' || !ALIGNMENT_SET.has(alignment))) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `format.align alignment must be one of ${ALIGNMENTS.join(', ')}, or null.`, + { field: 'alignment', value: alignment }, + ); + } +} + +export function executeAlign( + adapter: FormatAdapter, + input: FormatAlignInput, + options?: MutationOptions, +): TextMutationReceipt { + validateAlignInput(input); + return adapter.align(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index d531d38e8d..7947e4f3e0 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -117,6 +117,10 @@ function makeFormatReceipt() { function makeFormatAdapter(): FormatAdapter { return { apply: vi.fn(() => makeFormatReceipt()), + fontSize: vi.fn(() => makeFormatReceipt()), + fontFamily: vi.fn(() => makeFormatReceipt()), + color: vi.fn(() => makeFormatReceipt()), + align: vi.fn(() => makeFormatReceipt()), }; } @@ -585,6 +589,98 @@ describe('createDocumentApi', () => { ); }); + it('delegates format.fontSize to adapter.fontSize', () => { + const formatAdpt = makeFormatAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: formatAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + api.format.fontSize({ target, value: '14pt' }); + expect(formatAdpt.fontSize).toHaveBeenCalledWith( + { target, value: '14pt' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + + it('delegates format.fontFamily to adapter.fontFamily', () => { + const formatAdpt = makeFormatAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: formatAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + api.format.fontFamily({ target, value: 'Arial' }); + expect(formatAdpt.fontFamily).toHaveBeenCalledWith( + { target, value: 'Arial' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + + it('delegates format.color to adapter.color', () => { + const formatAdpt = makeFormatAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: formatAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + api.format.color({ target, value: '#ff0000' }); + expect(formatAdpt.color).toHaveBeenCalledWith( + { target, value: '#ff0000' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + + it('delegates format.align to adapter.align', () => { + const formatAdpt = makeFormatAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: formatAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + api.format.align({ target, alignment: 'center' }); + expect(formatAdpt.align).toHaveBeenCalledWith( + { target, alignment: 'center' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + it('delegates trackChanges read operations', () => { const trackAdpt = makeTrackChangesAdapter(); const api = createDocumentApi({ diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 4416896f48..cd53d377c1 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -52,8 +52,12 @@ import type { FormatUnderlineInput, FormatStrikethroughInput, StyleApplyInput, + FormatFontSizeInput, + FormatFontFamilyInput, + FormatColorInput, + FormatAlignInput, } from './format/format.js'; -import { executeStyleApply } from './format/format.js'; +import { executeStyleApply, executeFontSize, executeFontFamily, executeColor, executeAlign } from './format/format.js'; import type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; import { executeGetNode, executeGetNodeById } from './get-node/get-node.js'; import { executeGetText, type GetTextAdapter, type GetTextInput } from './get-text/get-text.js'; @@ -123,7 +127,12 @@ export type { FormatStrikethroughInput, StyleApplyInput, StyleApplyOptions, + FormatFontSizeInput, + FormatFontFamilyInput, + FormatColorInput, + FormatAlignInput, } from './format/format.js'; +export { ALIGNMENTS, type Alignment } from './format/format.js'; export type { CreateAdapter } from './create/create.js'; export type { TrackChangesAdapter, @@ -395,6 +404,18 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { apply(input: StyleApplyInput, options?: MutationOptions): TextMutationReceipt { return executeStyleApply(adapters.format, input, options); }, + fontSize(input: FormatFontSizeInput, options?: MutationOptions): TextMutationReceipt { + return executeFontSize(adapters.format, input, options); + }, + fontFamily(input: FormatFontFamilyInput, options?: MutationOptions): TextMutationReceipt { + return executeFontFamily(adapters.format, input, options); + }, + color(input: FormatColorInput, options?: MutationOptions): TextMutationReceipt { + return executeColor(adapters.format, input, options); + }, + align(input: FormatAlignInput, options?: MutationOptions): TextMutationReceipt { + return executeAlign(adapters.format, input, options); + }, }, trackChanges: { list(input?: TrackChangesListInput): TrackChangesListResult { diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 9b24056b8b..5c7d8268bb 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -85,6 +85,10 @@ function makeAdapters() { }); const formatAdapter: FormatAdapter = { apply: vi.fn(formatReceipt), + fontSize: vi.fn(formatReceipt), + fontFamily: vi.fn(formatReceipt), + color: vi.fn(formatReceipt), + align: vi.fn(formatReceipt), }; const trackChangesAdapter: TrackChangesAdapter = { list: vi.fn(() => ({ evaluatedRevision: '', total: 0, items: [], page: { limit: 50, offset: 0, returned: 0 } })), @@ -282,6 +286,45 @@ describe('invoke', () => { expect(invoked).toEqual(direct); }); + it('format.fontSize: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, value: '14pt' }; + const direct = api.format.fontSize(input); + const invoked = api.invoke({ operationId: 'format.fontSize', input }); + expect(invoked).toEqual(direct); + }); + + it('format.fontFamily: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, value: 'Arial' }; + const direct = api.format.fontFamily(input); + const invoked = api.invoke({ operationId: 'format.fontFamily', input }); + expect(invoked).toEqual(direct); + }); + + it('format.color: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, value: '#ff0000' }; + const direct = api.format.color(input); + const invoked = api.invoke({ operationId: 'format.color', input }); + expect(invoked).toEqual(direct); + }); + + it('format.align: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, + alignment: 'center' as const, + }; + const direct = api.format.align(input); + const invoked = api.invoke({ operationId: 'format.align', input }); + expect(invoked).toEqual(direct); + }); + it('create.heading: invoke returns same result as direct call', () => { const { adapters } = makeAdapters(); const api = createDocumentApi(adapters); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 90061e7061..adcd83a7d0 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -46,6 +46,10 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { // --- format.* --- 'format.apply': (input, options) => api.format.apply(input, options), + 'format.fontSize': (input, options) => api.format.fontSize(input, options), + 'format.fontFamily': (input, options) => api.format.fontFamily(input, options), + 'format.color': (input, options) => api.format.color(input, options), + 'format.align': (input, options) => api.format.align(input, options), // --- create.* --- 'create.paragraph': (input, options) => api.create.paragraph(input, options), diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index 86010e1f8f..ede440400c 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -75,6 +75,10 @@ function makeWriteAdapter() { function makeFormatAdapter() { return { apply: vi.fn(() => makeTextMutationReceipt()), + fontSize: vi.fn(() => makeTextMutationReceipt()), + fontFamily: vi.fn(() => makeTextMutationReceipt()), + color: vi.fn(() => makeTextMutationReceipt()), + align: vi.fn(() => makeTextMutationReceipt()), }; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.js index b6cf9722a2..c14bcf4d7b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.js @@ -6,4 +6,12 @@ import { NodeTranslator } from '@translator'; * @type {import('@translator').NodeTranslator} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 411 */ -export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:jc', 'justification')); +export const translator = NodeTranslator.from( + createSingleAttrPropertyHandler( + 'w:jc', + 'justification', + 'w:val', + (v) => (v === 'both' ? 'justify' : v), + (v) => (v === 'justify' ? 'both' : v), + ), +); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.test.js index 1aaba0936c..152a6ae9a1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/jc/jc-translator.test.js @@ -8,6 +8,11 @@ describe('w:jc translator', () => { expect(result).toBe('center'); }); + it('maps OOXML "both" to "justify"', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'both' } }] }); + expect(result).toBe('justify'); + }); + it('returns undefined if w:val is missing', () => { const result = translator.encode({ nodes: [{ attributes: {} }] }); expect(result).toBeUndefined(); @@ -20,6 +25,11 @@ describe('w:jc translator', () => { expect(result).toEqual({ 'w:val': 'right' }); }); + it('maps "justify" back to OOXML "both"', () => { + const { attributes: result } = translator.decode({ node: { attrs: { justification: 'justify' } } }); + expect(result).toEqual({ 'w:val': 'both' }); + }); + it('returns undefined if jc property is missing', () => { const result = translator.decode({ node: { attrs: {} } }); expect(result).toBeUndefined(); 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 31b0cf479a..dc9a16531f 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 @@ -17,6 +17,12 @@ import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; import { createCommentsWrapper } from '../plan-engine/comments-wrappers.js'; import { createParagraphWrapper, createHeadingWrapper } from '../plan-engine/create-wrappers.js'; import { writeWrapper, styleApplyWrapper } from '../plan-engine/plan-wrappers.js'; +import { + formatFontSizeWrapper, + formatFontFamilyWrapper, + formatColorWrapper, + formatAlignWrapper, +} from '../plan-engine/format-value-wrappers.js'; import { getDocumentApiCapabilities } from '../capabilities-adapter.js'; import { listsExitWrapper, @@ -194,6 +200,14 @@ function makeTextEditor( decreaseListIndent: vi.fn(() => true), restartNumbering: vi.fn(() => true), exitListItemAt: vi.fn(() => true), + setFontSize: vi.fn(() => true), + unsetFontSize: vi.fn(() => true), + setFontFamily: vi.fn(() => true), + unsetFontFamily: vi.fn(() => true), + setColor: vi.fn(() => true), + unsetColor: vi.fn(() => true), + setTextAlign: vi.fn(() => true), + unsetTextAlign: vi.fn(() => true), }; const baseMarks = { @@ -209,6 +223,9 @@ function makeTextEditor( strike: { create: vi.fn(() => ({ type: 'strike' })), }, + textStyle: { + create: vi.fn(() => ({ type: 'textStyle' })), + }, [TrackFormatMarkName]: { create: vi.fn(() => ({ type: TrackFormatMarkName })), }, @@ -239,6 +256,11 @@ function makeTextEditor( state: { doc: { ...doc, + nodeAt: vi.fn((pos: number) => { + if (pos === 0) return paragraph; + if (pos === 1) return textNode; + return null; + }), textBetween: vi.fn((from: number, to: number) => { const start = Math.max(0, from - 1); const end = Math.max(start, to - 1); @@ -555,6 +577,103 @@ const mutationVectors: Partial> = { ); }, }, + 'format.fontSize': { + throwCase: () => { + const { editor } = makeTextEditor(); + return formatFontSizeWrapper( + editor, + { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, value: '14pt' }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const { editor } = makeTextEditor(); + return formatFontSizeWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } }, value: '14pt' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const { editor } = makeTextEditor(); + return formatFontSizeWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, value: '14pt' }, + { changeMode: 'direct' }, + ); + }, + }, + 'format.fontFamily': { + throwCase: () => { + const { editor } = makeTextEditor(); + return formatFontFamilyWrapper( + editor, + { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, value: 'Arial' }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const { editor } = makeTextEditor(); + return formatFontFamilyWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } }, value: 'Arial' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const { editor } = makeTextEditor(); + return formatFontFamilyWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, value: 'Arial' }, + { changeMode: 'direct' }, + ); + }, + }, + 'format.color': { + throwCase: () => { + const { editor } = makeTextEditor(); + return formatColorWrapper( + editor, + { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, value: '#ff0000' }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const { editor } = makeTextEditor(); + return formatColorWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } }, value: '#ff0000' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const { editor } = makeTextEditor(); + return formatColorWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, value: '#ff0000' }, + { changeMode: 'direct' }, + ); + }, + }, + 'format.align': { + throwCase: () => { + const { editor } = makeTextEditor(); + return formatAlignWrapper( + editor, + { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, alignment: 'center' }, + { changeMode: 'direct' }, + ); + }, + // No failureCase — align allows collapsed ranges (paragraph-level operation) + applyCase: () => { + const { editor } = makeTextEditor(); + return formatAlignWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, alignment: 'center' }, + { changeMode: 'direct' }, + ); + }, + }, 'create.paragraph': { throwCase: () => { const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt: undefined } }); @@ -874,6 +993,46 @@ const dryRunVectors: Partial unknown>> = { expect(tr.addMark).not.toHaveBeenCalled(); return result; }, + 'format.fontSize': () => { + const { editor, dispatch } = makeTextEditor(); + const result = formatFontSizeWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, value: '14pt' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'format.fontFamily': () => { + const { editor, dispatch } = makeTextEditor(); + const result = formatFontFamilyWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, value: 'Arial' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'format.color': () => { + const { editor, dispatch } = makeTextEditor(); + const result = formatColorWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, value: '#ff0000' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'format.align': () => { + const { editor, dispatch } = makeTextEditor(); + const result = formatAlignWrapper( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, alignment: 'center' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, 'create.paragraph': () => { const insertParagraphAt = vi.fn(() => true); const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt } }); 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 fac366fecb..800679e499 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 @@ -24,6 +24,10 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('comments'); expect(adapters).toHaveProperty('write.write'); expect(adapters).toHaveProperty('format.apply'); + expect(adapters).toHaveProperty('format.fontSize'); + expect(adapters).toHaveProperty('format.fontFamily'); + expect(adapters).toHaveProperty('format.color'); + expect(adapters).toHaveProperty('format.align'); expect(adapters).toHaveProperty('trackChanges.list'); expect(adapters).toHaveProperty('trackChanges.get'); expect(adapters).toHaveProperty('trackChanges.accept'); @@ -48,6 +52,10 @@ describe('assembleDocumentApiAdapters', () => { expect(typeof adapters.find.find).toBe('function'); expect(typeof adapters.write.write).toBe('function'); expect(typeof adapters.format.apply).toBe('function'); + expect(typeof adapters.format.fontSize).toBe('function'); + expect(typeof adapters.format.fontFamily).toBe('function'); + expect(typeof adapters.format.color).toBe('function'); + expect(typeof adapters.format.align).toBe('function'); expect(typeof adapters.create.paragraph).toBe('function'); expect(typeof adapters.create.heading).toBe('function'); expect(typeof adapters.lists.insert).toBe('function'); 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 ff4af6732c..f828279e93 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -7,6 +7,12 @@ import { infoAdapter } from './info-adapter.js'; import { getDocumentApiCapabilities } from './capabilities-adapter.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { writeWrapper, styleApplyWrapper } from './plan-engine/plan-wrappers.js'; +import { + formatFontSizeWrapper, + formatFontFamilyWrapper, + formatColorWrapper, + formatAlignWrapper, +} from './plan-engine/format-value-wrappers.js'; import { trackChangesListWrapper, trackChangesGetWrapper, @@ -66,6 +72,10 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters }, format: { apply: (input, options) => styleApplyWrapper(editor, input, options), + fontSize: (input, options) => formatFontSizeWrapper(editor, input, options), + fontFamily: (input, options) => formatFontFamilyWrapper(editor, input, options), + color: (input, options) => formatColorWrapper(editor, input, options), + align: (input, options) => formatAlignWrapper(editor, input, options), }, trackChanges: { list: (input) => trackChangesListWrapper(editor, input), 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 160c79960e..28c2b24558 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 @@ -225,4 +225,96 @@ describe('getDocumentApiCapabilities', () => { expect(styleReasons).toContain('OPERATION_UNAVAILABLE'); expect(styleReasons).not.toContain('COMMAND_UNAVAILABLE'); }); + + // --------------------------------------------------------------------------- + // format.fontSize / fontFamily / color / align capability reporting + // --------------------------------------------------------------------------- + + describe('format value operations', () => { + function makeFormatEditor(overrides: { commands?: Record; marks?: Record } = {}) { + return makeEditor({ + commands: { + setFontSize: vi.fn(() => true), + unsetFontSize: vi.fn(() => true), + setFontFamily: vi.fn(() => true), + unsetFontFamily: vi.fn(() => true), + setColor: vi.fn(() => true), + unsetColor: vi.fn(() => true), + setTextAlign: vi.fn(() => true), + unsetTextAlign: vi.fn(() => true), + ...overrides.commands, + } as unknown as Editor['commands'], + schema: { + marks: { + textStyle: { create: vi.fn(() => ({ type: 'textStyle' })) }, + ...overrides.marks, + }, + } as unknown as Editor['schema'], + }); + } + + it('reports inline format ops as available when commands and textStyle mark are present', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor()); + + expect(capabilities.operations['format.fontSize'].available).toBe(true); + expect(capabilities.operations['format.fontFamily'].available).toBe(true); + expect(capabilities.operations['format.color'].available).toBe(true); + }); + + it('reports format.align as available when set and unset commands are present', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor()); + + expect(capabilities.operations['format.align'].available).toBe(true); + }); + + it('reports inline format ops as unavailable when textStyle mark is missing', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor({ marks: { textStyle: undefined } })); + + expect(capabilities.operations['format.fontSize'].available).toBe(false); + expect(capabilities.operations['format.fontFamily'].available).toBe(false); + expect(capabilities.operations['format.color'].available).toBe(false); + // align is paragraph-level — it does not require the textStyle mark + expect(capabilities.operations['format.align'].available).toBe(true); + }); + + it('reports format.fontSize as unavailable when unsetFontSize command is missing', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor({ commands: { unsetFontSize: undefined } })); + + expect(capabilities.operations['format.fontSize'].available).toBe(false); + expect(capabilities.operations['format.fontSize'].reasons).toContain('OPERATION_UNAVAILABLE'); + }); + + it('reports format.align as unavailable when unsetTextAlign command is missing', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor({ commands: { unsetTextAlign: undefined } })); + + expect(capabilities.operations['format.align'].available).toBe(false); + expect(capabilities.operations['format.align'].reasons).toContain('COMMAND_UNAVAILABLE'); + }); + + it('uses OPERATION_UNAVAILABLE without COMMAND_UNAVAILABLE for inline format ops missing textStyle mark', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor({ marks: { textStyle: undefined } })); + + const fontSizeReasons = capabilities.operations['format.fontSize'].reasons ?? []; + expect(fontSizeReasons).toContain('OPERATION_UNAVAILABLE'); + expect(fontSizeReasons).not.toContain('COMMAND_UNAVAILABLE'); + }); + + it('reports all format value ops with dryRun support', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor()); + + expect(capabilities.operations['format.fontSize'].dryRun).toBe(true); + expect(capabilities.operations['format.fontFamily'].dryRun).toBe(true); + expect(capabilities.operations['format.color'].dryRun).toBe(true); + expect(capabilities.operations['format.align'].dryRun).toBe(true); + }); + + it('reports all format value ops as direct-only (tracked = false)', () => { + const capabilities = getDocumentApiCapabilities(makeFormatEditor()); + + expect(capabilities.operations['format.fontSize'].tracked).toBe(false); + expect(capabilities.operations['format.fontFamily'].tracked).toBe(false); + expect(capabilities.operations['format.color'].tracked).toBe(false); + expect(capabilities.operations['format.align'].tracked).toBe(false); + }); + }); }); 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 1829fab049..521c0eaeee 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -18,6 +18,10 @@ type EditorCommandName = string; // they are backed by writeAdapter which is always available when the editor exists. // Read-only operations (find, getNode, getText, info, etc.) similarly need no commands. const REQUIRED_COMMANDS: Partial> = { + 'format.fontSize': ['setTextSelection', 'setFontSize', 'unsetFontSize'], + 'format.fontFamily': ['setTextSelection', 'setFontFamily', 'unsetFontFamily'], + 'format.color': ['setTextSelection', 'setColor', 'unsetColor'], + 'format.align': ['setTextSelection', 'setTextAlign', 'unsetTextAlign'], 'create.paragraph': ['insertParagraphAt'], 'create.heading': ['insertHeadingAt'], 'lists.insert': ['insertListItemAt'], @@ -67,6 +71,12 @@ function isMarkBackedOperation(operationId: OperationId): boolean { return operationId === 'format.apply'; } +/** + * Inline value-format operations (fontSize, fontFamily, color) require the 'textStyle' + * mark in the schema — they apply values via `setMark('textStyle', ...)`. + */ +const INLINE_FORMAT_OPERATIONS = new Set(['format.fontSize', 'format.fontFamily', 'format.color']); + function hasTrackedModeCapability(editor: Editor, operationId: OperationId): boolean { if (!hasCommand(editor, 'insertTrackedChange')) return false; // ensureTrackedCapability (mutation-helpers.ts) requires editor.options.user; @@ -116,11 +126,16 @@ function isOperationAvailable(editor: Editor, operationId: OperationId): boolean return MARK_KEYS.some((key) => hasMarkCapability(editor, STYLE_MARK_SCHEMA_NAMES[key] ?? key)); } + // Inline format ops (fontSize, fontFamily, color) require the textStyle mark in the schema + if (INLINE_FORMAT_OPERATIONS.has(operationId)) { + return hasAllCommands(editor, operationId) && hasMarkCapability(editor, 'textStyle'); + } + return hasAllCommands(editor, operationId); } function isCommandBackedAvailability(operationId: OperationId): boolean { - return !isMarkBackedOperation(operationId); + return !isMarkBackedOperation(operationId) && !INLINE_FORMAT_OPERATIONS.has(operationId); } function buildOperationCapabilities(editor: Editor): DocumentApiCapabilities['operations'] { diff --git a/packages/super-editor/src/document-api-adapters/index.ts b/packages/super-editor/src/document-api-adapters/index.ts index 172338d3f8..a24faebe6a 100644 --- a/packages/super-editor/src/document-api-adapters/index.ts +++ b/packages/super-editor/src/document-api-adapters/index.ts @@ -12,6 +12,12 @@ import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { createParagraphWrapper, createHeadingWrapper } from './plan-engine/create-wrappers.js'; import { findAdapter } from './find-adapter.js'; import { writeWrapper, styleApplyWrapper } from './plan-engine/plan-wrappers.js'; +import { + formatFontSizeWrapper, + formatFontFamilyWrapper, + formatColorWrapper, + formatAlignWrapper, +} from './plan-engine/format-value-wrappers.js'; import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; import { getTextAdapter } from './get-text-adapter.js'; import { infoAdapter } from './info-adapter.js'; @@ -75,6 +81,10 @@ export function getDocumentApiAdapters(editor: Editor): DocumentApiAdapters { }, format: { apply: (input, options) => styleApplyWrapper(editor, input, options), + fontSize: (input, options) => formatFontSizeWrapper(editor, input, options), + fontFamily: (input, options) => formatFontFamilyWrapper(editor, input, options), + color: (input, options) => formatColorWrapper(editor, input, options), + align: (input, options) => formatAlignWrapper(editor, input, options), }, trackChanges: { list: (query) => trackChangesListWrapper(editor, query), diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/format-value-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/format-value-wrappers.ts new file mode 100644 index 0000000000..7ddfe93961 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/format-value-wrappers.ts @@ -0,0 +1,224 @@ +/** + * Wrappers for value-based format operations: fontSize, fontFamily, color, align. + * + * fontSize, fontFamily, and color are inline text-style marks applied via `setMark('textStyle', ...)`. + * align is a paragraph-level attribute applied via `updateAttributes('paragraph', ...)`. + * + * All four are direct-only in v1 (tracked mode rejected with CAPABILITY_UNAVAILABLE). + * They route through `executeDomainCommand` — no plan-engine step executors are registered. + */ + +import type { + FormatFontSizeInput, + FormatFontFamilyInput, + FormatColorInput, + FormatAlignInput, + MutationOptions, + TextAddress, + TextMutationReceipt, +} from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { resolveTextTarget } from '../helpers/adapter-utils.js'; +import { buildTextMutationResolution, readTextAtResolvedRange } from '../helpers/text-mutation-resolution.js'; +import { requireEditorCommand, requireSchemaMark, rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { executeDomainCommand } from './plan-wrappers.js'; + +// --------------------------------------------------------------------------- +// Shared: resolve target and build resolution +// --------------------------------------------------------------------------- + +interface ResolvedFormat { + target: TextAddress; + from: number; + to: number; + resolution: ReturnType; +} + +function resolveFormatTarget(editor: Editor, target: TextAddress, operation: string): ResolvedFormat { + const range = resolveTextTarget(editor, target); + if (!range) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `${operation} target could not be resolved.`, { target }); + } + const text = readTextAtResolvedRange(editor, range); + const resolution = buildTextMutationResolution({ requestedTarget: target, target, range, text }); + return { target, from: range.from, to: range.to, resolution }; +} + +function collapsedTargetFailure(resolution: ResolvedFormat['resolution'], operation: string): TextMutationReceipt { + return { + success: false, + resolution, + failure: { code: 'INVALID_TARGET', message: `${operation} requires a non-collapsed target range.` }, + }; +} + +function noOpFailure(resolution: ResolvedFormat['resolution'], operation: string): TextMutationReceipt { + return { + success: false, + resolution, + failure: { code: 'NO_OP', message: `${operation} produced no change.` }, + }; +} + +// --------------------------------------------------------------------------- +// Shared: inline value format wrapper (fontSize, fontFamily, color) +// --------------------------------------------------------------------------- + +interface InlineFormatConfig { + operation: string; + setCommand: string; + unsetCommand: string; +} + +function inlineValueFormatWrapper( + editor: Editor, + target: TextAddress, + value: string | number | null, + options: MutationOptions | undefined, + config: InlineFormatConfig, +): TextMutationReceipt { + rejectTrackedMode(config.operation, options); + + const resolved = resolveFormatTarget(editor, target, config.operation); + if (resolved.from === resolved.to) { + return collapsedTargetFailure(resolved.resolution, config.operation); + } + + requireSchemaMark(editor, 'textStyle', config.operation); + + const setTextSelection = requireEditorCommand( + editor.commands?.setTextSelection as ((range: { from: number; to: number }) => boolean) | undefined, + `${config.operation} (setTextSelection)`, + ); + + const activeCommand = value !== null ? config.setCommand : config.unsetCommand; + requireEditorCommand( + (editor.commands as Record)?.[activeCommand] as ((...args: unknown[]) => boolean) | undefined, + `${config.operation} (${activeCommand})`, + ); + + if (options?.dryRun) { + return { success: true, resolution: resolved.resolution }; + } + + const receipt = executeDomainCommand( + editor, + () => { + setTextSelection({ from: resolved.from, to: resolved.to }); + const cmd = (editor.commands as Record boolean>)[activeCommand]; + return value !== null ? cmd(value) : cmd(); + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return noOpFailure(resolved.resolution, config.operation); + } + + return { success: true, resolution: resolved.resolution }; +} + +// --------------------------------------------------------------------------- +// format.fontSize +// --------------------------------------------------------------------------- + +export function formatFontSizeWrapper( + editor: Editor, + input: FormatFontSizeInput, + options?: MutationOptions, +): TextMutationReceipt { + return inlineValueFormatWrapper(editor, input.target, input.value, options, { + operation: 'format.fontSize', + setCommand: 'setFontSize', + unsetCommand: 'unsetFontSize', + }); +} + +// --------------------------------------------------------------------------- +// format.fontFamily +// --------------------------------------------------------------------------- + +export function formatFontFamilyWrapper( + editor: Editor, + input: FormatFontFamilyInput, + options?: MutationOptions, +): TextMutationReceipt { + return inlineValueFormatWrapper(editor, input.target, input.value, options, { + operation: 'format.fontFamily', + setCommand: 'setFontFamily', + unsetCommand: 'unsetFontFamily', + }); +} + +// --------------------------------------------------------------------------- +// format.color +// --------------------------------------------------------------------------- + +export function formatColorWrapper( + editor: Editor, + input: FormatColorInput, + options?: MutationOptions, +): TextMutationReceipt { + return inlineValueFormatWrapper(editor, input.target, input.value, options, { + operation: 'format.color', + setCommand: 'setColor', + unsetCommand: 'unsetColor', + }); +} + +// --------------------------------------------------------------------------- +// format.align (paragraph-level — different execution path) +// --------------------------------------------------------------------------- + +export function formatAlignWrapper( + editor: Editor, + input: FormatAlignInput, + options?: MutationOptions, +): TextMutationReceipt { + const operation = 'format.align'; + rejectTrackedMode(operation, options); + + const resolved = resolveFormatTarget(editor, input.target, operation); + // Align allows collapsed targets — a cursor identifies the containing paragraph. + + const setTextSelection = requireEditorCommand( + editor.commands?.setTextSelection as ((range: { from: number; to: number }) => boolean) | undefined, + `${operation} (setTextSelection)`, + ); + + if (input.alignment !== null) { + requireEditorCommand( + editor.commands?.setTextAlign as ((alignment: string) => boolean) | undefined, + `${operation} (setTextAlign)`, + ); + } else { + requireEditorCommand( + editor.commands?.unsetTextAlign as (() => boolean) | undefined, + `${operation} (unsetTextAlign)`, + ); + } + + if (options?.dryRun) { + return { success: true, resolution: resolved.resolution }; + } + + const receipt = executeDomainCommand( + editor, + () => { + setTextSelection({ from: resolved.from, to: resolved.to }); + + if (input.alignment !== null) { + return (editor.commands as Record boolean>).setTextAlign(input.alignment); + } + return (editor.commands as Record boolean>).unsetTextAlign(); + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return noOpFailure(resolved.resolution, operation); + } + + return { success: true, resolution: resolved.resolution }; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts index 55f7de36dd..5bc6c37613 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts @@ -1,140 +1,157 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { captureRunsInRange } from './style-resolver.js'; import type { Editor } from '../../core/Editor.js'; - -// --------------------------------------------------------------------------- -// captureRunsInRange — block content offset tests -// --------------------------------------------------------------------------- - -describe('captureRunsInRange: block content offset (blockPos + 1)', () => { - /** - * ProseMirror block content starts at blockPos + 1 (the +1 skips the - * block node's opening token). This test verifies that captureRunsInRange - * correctly uses contentStart = blockPos + 1 when computing absolute - * positions for doc.nodesBetween. - */ - it('walks document starting at blockPos + 1, not blockPos', () => { - const nodesBetween = vi.fn(); - const editor = { - state: { - doc: { nodesBetween }, +import type { Node as ProseMirrorNode } from 'prosemirror-model'; + +type MockMark = { + type: { name: string }; + attrs: Record; + eq: (other: MockMark) => boolean; +}; + +type MockNodeOptions = { + text?: string; + marks?: MockMark[]; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function mockMark(name: string, attrs: Record = {}): MockMark { + return { + type: { name }, + attrs, + eq(other: MockMark) { + if (other.type.name !== name) return false; + const keys = new Set([...Object.keys(attrs), ...Object.keys(other.attrs ?? {})]); + for (const key of keys) { + if ((attrs as Record)[key] !== other.attrs[key]) return false; + } + return true; + }, + }; +} + +function createNode( + typeName: string, + children: ProseMirrorNode[] = [], + options: MockNodeOptions = {}, +): ProseMirrorNode { + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + text: isText ? text : undefined, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + marks: options.marks ?? [], + childCount: children.length, + child(index: number) { + return children[index]!; + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(blockPos: number, blockNode: ProseMirrorNode | null): Editor { + return { + state: { + doc: { + nodeAt(pos: number) { + return pos === blockPos ? blockNode : null; + }, }, - } as unknown as Editor; + }, + } as unknown as Editor; +} + +describe('captureRunsInRange', () => { + it('uses wrapper-transparent text offsets so adjacent runs stay contiguous', () => { + const bold = mockMark('bold'); + const textStyle = mockMark('textStyle'); + + const runA = createNode('run', [createNode('text', [], { text: 'Hello', marks: [bold, textStyle] })], { + isInline: true, + isBlock: false, + isLeaf: false, + }); + const runB = createNode('run', [createNode('text', [], { text: ' world', marks: [textStyle] })], { + isInline: true, + isBlock: false, + isLeaf: false, + }); + const paragraph = createNode('paragraph', [runA, runB], { isBlock: true, inlineContent: true }); + const editor = makeEditor(10, paragraph); - // Block at position 10, text offsets [2, 5) - // Content starts at 11 (blockPos + 1) - // So absolute range should be [13, 16) not [12, 15) - captureRunsInRange(editor, 10, 2, 5); + const result = captureRunsInRange(editor, 10, 0, 11); - expect(nodesBetween).toHaveBeenCalledTimes(1); - const [absFrom, absTo] = nodesBetween.mock.calls[0]; - expect(absFrom).toBe(13); // 10 + 1 + 2 - expect(absTo).toBe(16); // 10 + 1 + 5 + expect(result.runs).toHaveLength(2); + expect(result.runs[0]).toMatchObject({ from: 0, to: 5, charCount: 5 }); + expect(result.runs[1]).toMatchObject({ from: 5, to: 11, charCount: 6 }); + expect(result.runs[0].marks.map((m) => m.type.name)).toEqual(['bold', 'textStyle']); + expect(result.runs[1].marks.map((m) => m.type.name)).toEqual(['textStyle']); }); - it('computes correct absolute positions for blockPos=0', () => { - const nodesBetween = vi.fn(); - const editor = { - state: { - doc: { nodesBetween }, - }, - } as unknown as Editor; - - // Block at position 0, text offsets [0, 3) - // Content starts at 1 (0 + 1) - captureRunsInRange(editor, 0, 0, 3); + it('clamps runs to the requested offset subrange across wrappers', () => { + const bold = mockMark('bold'); - const [absFrom, absTo] = nodesBetween.mock.calls[0]; - expect(absFrom).toBe(1); // 0 + 1 + 0 - expect(absTo).toBe(4); // 0 + 1 + 3 - }); - - it('returns content-relative offsets in captured runs', () => { - // Simulate a text node at absolute position 11, size 5 - const textNode = { - isText: true, - nodeSize: 5, - marks: [], - }; - - const nodesBetween = vi.fn((from: number, to: number, cb: Function) => { - // Call back with a text node starting at absolute position 11 - cb(textNode, 11); + const runA = createNode('run', [createNode('text', [], { text: 'Hello', marks: [bold] })], { + isInline: true, + isBlock: false, + isLeaf: false, }); + const runB = createNode('run', [createNode('text', [], { text: ' world', marks: [] })], { + isInline: true, + isBlock: false, + isLeaf: false, + }); + const paragraph = createNode('paragraph', [runA, runB], { isBlock: true, inlineContent: true }); + const editor = makeEditor(20, paragraph); - const editor = { - state: { - doc: { nodesBetween }, - }, - } as unknown as Editor; - - // Block at position 10, text offsets [0, 5) - // contentStart = 11, absFrom = 11, absTo = 16 - const result = captureRunsInRange(editor, 10, 0, 5); + const result = captureRunsInRange(editor, 20, 2, 8); - expect(result.runs).toHaveLength(1); - // Run offsets should be relative to contentStart (blockPos + 1) - // nodeStart = max(11, 11) = 11, relFrom = 11 - 11 = 0 - // nodeEnd = min(11 + 5, 16) = 16, relTo = 16 - 11 = 5 - expect(result.runs[0].from).toBe(0); - expect(result.runs[0].to).toBe(5); - expect(result.runs[0].charCount).toBe(5); + expect(result.runs).toHaveLength(2); + expect(result.runs[0]).toMatchObject({ from: 2, to: 5, charCount: 3 }); + expect(result.runs[1]).toMatchObject({ from: 5, to: 8, charCount: 3 }); }); - it('clamps run offsets to the requested range', () => { - // Text node extends beyond the requested range - const textNode = { - isText: true, - nodeSize: 10, - marks: [], - }; - - const nodesBetween = vi.fn((from: number, to: number, cb: Function) => { - // Text node starts at contentStart (11), extends to 21 - cb(textNode, 11); - }); + it('filters metadata marks from captured runs', () => { + const boldMark = mockMark('bold'); + const trackInsert = mockMark('trackInsert'); + const commentMark = mockMark('commentMark'); - const editor = { - state: { - doc: { nodesBetween }, - }, - } as unknown as Editor; + const paragraph = createNode( + 'paragraph', + [createNode('text', [], { text: 'Hello', marks: [boldMark, trackInsert, commentMark] })], + { isBlock: true, inlineContent: true }, + ); + const editor = makeEditor(0, paragraph); - // Block at position 10, requesting only text offsets [2, 5) - // contentStart = 11, absFrom = 13, absTo = 16 - const result = captureRunsInRange(editor, 10, 2, 5); + const result = captureRunsInRange(editor, 0, 0, 5); expect(result.runs).toHaveLength(1); - // nodeStart = max(11, 13) = 13, relFrom = 13 - 11 = 2 - // nodeEnd = min(21, 16) = 16, relTo = 16 - 11 = 5 - expect(result.runs[0].from).toBe(2); - expect(result.runs[0].to).toBe(5); - expect(result.runs[0].charCount).toBe(3); + expect(result.runs[0].marks.map((m) => m.type.name)).toEqual(['bold']); }); - it('filters out metadata marks (trackInsert, commentMark, etc.)', () => { - const boldMark = { type: { name: 'bold' }, attrs: {}, eq: () => true }; - const trackMark = { type: { name: 'trackInsert' }, attrs: {}, eq: () => true }; - - const textNode = { - isText: true, - nodeSize: 5, - marks: [boldMark, trackMark], - }; - - const nodesBetween = vi.fn((_from: number, _to: number, cb: Function) => { - cb(textNode, 1); - }); - - const editor = { - state: { doc: { nodesBetween } }, - } as unknown as Editor; - + it('returns empty runs when the block node cannot be resolved', () => { + const editor = makeEditor(0, null); const result = captureRunsInRange(editor, 0, 0, 5); - expect(result.runs).toHaveLength(1); - // Only bold should be present, trackInsert filtered out - expect(result.runs[0].marks).toHaveLength(1); - expect(result.runs[0].marks[0].type.name).toBe('bold'); + expect(result.runs).toEqual([]); + expect(result.isUniform).toBe(true); }); }); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts index 95f70d1d28..083d0bf676 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts @@ -69,37 +69,63 @@ const METADATA_MARK_NAMES = new Set([ */ export function captureRunsInRange(editor: Editor, blockPos: number, from: number, to: number): CapturedStyle { const doc = editor.state.doc; - // Block content starts at blockPos + 1 (the +1 skips the block node's opening token) - const contentStart = blockPos + 1; - const absFrom = contentStart + from; - const absTo = contentStart + to; + const blockNode = doc.nodeAt(blockPos); + if (!blockNode || from < 0 || to < from || from === to) { + return { runs: [], isUniform: true }; + } const runs: CapturedRun[] = []; + let offset = 0; - // Walk inline content between absFrom and absTo - doc.nodesBetween(absFrom, absTo, (node, pos) => { - if (!node.isText) return true; + const maybePushRun = (start: number, end: number, marks: readonly PmMark[]) => { + const overlapStart = Math.max(start, from); + const overlapEnd = Math.min(end, to); + if (overlapStart >= overlapEnd) return; - // Clamp to the matched range - const nodeStart = Math.max(pos, absFrom); - const nodeEnd = Math.min(pos + node.nodeSize, absTo); - if (nodeStart >= nodeEnd) return true; + runs.push({ + from: overlapStart, + to: overlapEnd, + charCount: overlapEnd - overlapStart, + marks: marks.filter((m) => !METADATA_MARK_NAMES.has(m.type.name)), + }); + }; + + const walkNode = (node: import('prosemirror-model').Node): void => { + if (node.isText) { + const text = node.text ?? ''; + if (text.length > 0) { + const start = offset; + const end = offset + text.length; + const marks = Array.isArray((node as { marks?: unknown }).marks) + ? ((node as unknown as { marks: PmMark[] }).marks as readonly PmMark[]) + : []; + maybePushRun(start, end, marks); + offset = end; + } + return; + } - const relFrom = nodeStart - contentStart; - const relTo = nodeEnd - contentStart; + if (node.isLeaf) { + // Keep offset math aligned with resolveTextRangeInBlock's flattened model. + offset += 1; + return; + } - // Filter out metadata marks - const formattingMarks = (node.marks as unknown as PmMark[]).filter((m) => !METADATA_MARK_NAMES.has(m.type.name)); + let isFirstChild = true; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); - runs.push({ - from: relFrom, - to: relTo, - charCount: relTo - relFrom, - marks: formattingMarks, - }); + // Block separators contribute one offset slot in the flattened model. + if (child.isBlock && !isFirstChild) { + offset += 1; + } + + walkNode(child); + isFirstChild = false; + } + }; - return true; - }); + walkNode(blockNode); const isUniform = checkUniformity(runs); diff --git a/tests/doc-api-stories/package.json b/tests/doc-api-stories/package.json index e7d8effe91..3a47a256e5 100644 --- a/tests/doc-api-stories/package.json +++ b/tests/doc-api-stories/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "pretest": "pnpm --silent --dir ../.. corpus:pull", + "pretest": "pnpm --silent --dir ../.. corpus:pull && rm -rf results", "test": "pnpm --silent --prefix ../../apps/cli run build && pnpm --silent --prefix ../../packages/sdk/langs/node run build && vitest run --config ./vitest.config.ts" }, "dependencies": { diff --git a/tests/doc-api-stories/tests/ex2-style-apply.ts b/tests/doc-api-stories/tests/ex2-style-apply.ts deleted file mode 100644 index 3a4dfd6605..0000000000 --- a/tests/doc-api-stories/tests/ex2-style-apply.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { corpusDoc, unwrap, useStoryHarness } from './harness'; - -/** - * Story tests for the canonical `format.apply` operation and format helpers. - * - * These tests require a built CLI (`apps/cli/dist/index.js`) that includes - * the `format.apply` operation. If the CLI dist is stale, rebuild with: - * pnpm run --filter cli build - * - * Note: `client.doc.format.bold()` is NOT available on the generated SDK - * client — format helpers are standalone functions in `helpers/format.ts`. - * Story tests use the canonical `client.doc.format.apply()` directly. - */ -describe('document-api story: ex2 format.apply', () => { - const { client, copyDoc, outPath } = useStoryHarness('ex2-style-apply'); - - it('canonical format.apply: bold a matched range', async () => { - const sourceDocPath = await copyDoc(corpusDoc('basic/longer-header.docx')); - const sessionId = `ex2-seed-${Date.now()}`; - - await client.doc.open({ doc: sourceDocPath, sessionId }); - - // 1. Find a text range to format - const matchResult = unwrap( - await client.doc.query.match({ - sessionId, - select: { type: 'text', pattern: 'Term and Termination', caseSensitive: true }, - require: 'first', - }), - ); - - expect(matchResult.items).toBeDefined(); - expect(matchResult.items.length).toBeGreaterThan(0); - expect(matchResult.total).toBeGreaterThanOrEqual(1); - expect(matchResult.page).toBeDefined(); - - const match = matchResult.items[0]; - const block = match.blocks?.[0]; - if (!block) throw new Error('Expected query.match to return at least one block.'); - - // 2. Apply bold using canonical format.apply with the matched block range - const boldResult = unwrap( - await client.doc.format.apply({ - sessionId, - target: { kind: 'text', blockId: block.blockId, range: block.range }, - inline: { bold: true }, - }), - ); - - expect(boldResult).toBeDefined(); - expect(boldResult.receipt?.success).toBe(true); - }); - - it('multi-mark atomic: apply bold + italic in one call', async () => { - const sourceDocPath = await copyDoc(corpusDoc('basic/longer-header.docx'), 'source-multi.docx'); - const sessionId = `ex2-multi-${Date.now()}`; - - await client.doc.open({ doc: sourceDocPath, sessionId }); - - // 1. Find a text range - const matchResult = unwrap( - await client.doc.query.match({ - sessionId, - select: { type: 'text', pattern: 'Term and Termination', caseSensitive: true }, - require: 'first', - }), - ); - - expect(matchResult.items).toBeDefined(); - expect(matchResult.items.length).toBeGreaterThan(0); - const match = matchResult.items[0]; - const block = match.blocks?.[0]; - if (!block) throw new Error('Expected query.match to return at least one block.'); - - // 2. Apply bold + italic in a single atomic call - const result = unwrap( - await client.doc.format.apply({ - sessionId, - target: { kind: 'text', blockId: block.blockId, range: block.range }, - inline: { bold: true, italic: true }, - }), - ); - - expect(result).toBeDefined(); - expect(result.receipt?.success).toBe(true); - }); - - it('mark removal: set bold false on a range', async () => { - const sourceDocPath = await copyDoc(corpusDoc('basic/longer-header.docx'), 'source-remove.docx'); - const sessionId = `ex2-remove-${Date.now()}`; - - await client.doc.open({ doc: sourceDocPath, sessionId }); - - // 1. Find a text range - const matchResult = unwrap( - await client.doc.query.match({ - sessionId, - select: { type: 'text', pattern: 'Term and Termination', caseSensitive: true }, - require: 'first', - }), - ); - - expect(matchResult.items).toBeDefined(); - expect(matchResult.items.length).toBeGreaterThan(0); - const match = matchResult.items[0]; - const block = match.blocks?.[0]; - if (!block) throw new Error('Expected query.match to return at least one block.'); - - // 2. Remove bold (false = unset) - const result = unwrap( - await client.doc.format.apply({ - sessionId, - target: { kind: 'text', blockId: block.blockId, range: block.range }, - inline: { bold: false }, - }), - ); - - expect(result).toBeDefined(); - expect(result.receipt?.success).toBe(true); - }); - - it('mixed patch: set bold true + remove italic false in one call', async () => { - const sourceDocPath = await copyDoc(corpusDoc('basic/longer-header.docx'), 'source-mixed.docx'); - const sessionId = `ex2-mixed-${Date.now()}`; - - await client.doc.open({ doc: sourceDocPath, sessionId }); - - const matchResult = unwrap( - await client.doc.query.match({ - sessionId, - select: { type: 'text', pattern: 'Term and Termination', caseSensitive: true }, - require: 'first', - }), - ); - - expect(matchResult.items).toBeDefined(); - expect(matchResult.items.length).toBeGreaterThan(0); - const match = matchResult.items[0]; - const block = match.blocks?.[0]; - if (!block) throw new Error('Expected query.match to return at least one block.'); - - // Mixed patch: bold on, italic off - const result = unwrap( - await client.doc.format.apply({ - sessionId, - target: { kind: 'text', blockId: block.blockId, range: block.range }, - inline: { bold: true, italic: false }, - }), - ); - - expect(result).toBeDefined(); - expect(result.receipt?.success).toBe(true); - }); -}); diff --git a/tests/doc-api-stories/tests/formatting/general-formatting.ts b/tests/doc-api-stories/tests/formatting/general-formatting.ts deleted file mode 100644 index 0248e2b8cc..0000000000 --- a/tests/doc-api-stories/tests/formatting/general-formatting.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { access } from 'node:fs/promises'; -import { describe, expect, it } from 'vitest'; -import { corpusDoc, unwrap, useStoryHarness } from '../harness'; - -describe('document-api story: basic formatting', () => { - const { client, copyDoc, outPath } = useStoryHarness('formatting/general-formatting', { - preserveResults: true, - }); - const TARGET_TEXT = 'This Agreement shall commence on the date first written above'; - - async function queryFirstTextMatch(sessionId: string) { - const result = unwrap( - await client.doc.query.match({ - sessionId, - select: { type: 'text', pattern: TARGET_TEXT, caseSensitive: true }, - require: 'first', - includeStyle: true, - }), - ); - return result.matches[0]; - } - - async function applyFormat( - command: 'bold' | 'italic' | 'underline' | 'strikethrough', - sessionId: string, - target: any, - ) { - if (command === 'bold') return client.doc.format.bold({ sessionId, target }); - if (command === 'italic') return client.doc.format.italic({ sessionId, target }); - if (command === 'underline') return client.doc.format.underline({ sessionId, target }); - return client.doc.format.strikethrough({ sessionId, target }); - } - - async function runFormattingCase( - command: 'bold' | 'italic' | 'underline' | 'strikethrough', - expectedMark: 'bold' | 'italic' | 'underline' | 'strike', - ) { - const sourceDocPath = await copyDoc(corpusDoc('basic/longer-header.docx')); - const outputDocPath = outPath(`format-${command}-result.docx`); - - const sessionId = `basic-formatting-${command}-${Date.now()}`; - await client.doc.open({ doc: sourceDocPath, sessionId }); - - const matchBefore = await queryFirstTextMatch(sessionId); - const target = matchBefore?.textRanges?.[0]; - expect(target).toBeDefined(); - - const mutation = unwrap(await applyFormat(command, sessionId, target)); - expect(mutation.receipt?.success).toBe(true); - expect(mutation.resolvedRange).toBeDefined(); - - const matchAfter = await queryFirstTextMatch(sessionId); - expect(matchAfter?.style?.marks?.[expectedMark]).toBe(true); - - await client.doc.save({ sessionId, out: outputDocPath, force: true }); - await expect(access(outputDocPath)).resolves.toBeUndefined(); - } - - it('tests formatting.bold', async () => { - await runFormattingCase('bold', 'bold'); - }); - - it('tests formatting.italic', async () => { - await runFormattingCase('italic', 'italic'); - }); - - it('tests formatting.underline', async () => { - await runFormattingCase('underline', 'underline'); - }); - - it('tests formatting.strikethrough', async () => { - await runFormattingCase('strikethrough', 'strike'); - }); -}); diff --git a/tests/doc-api-stories/tests/formatting/inline-formatting.ts b/tests/doc-api-stories/tests/formatting/inline-formatting.ts new file mode 100644 index 0000000000..62f1a932e9 --- /dev/null +++ b/tests/doc-api-stories/tests/formatting/inline-formatting.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +/** + * End-to-end story tests for all inline formatting operations. + * + * Each test opens a blank document, inserts descriptive text, then applies + * the corresponding format operation. Starting from a blank doc proves the + * full pipeline: SDK → CLI → document-api → adapter → editor, without + * depending on any pre-existing corpus document. + * + * The blank DOCX template contains a single empty paragraph with a stable + * `w14:paraId` attribute that survives DOCX export/reimport cycles, so the + * blockId returned by `insert` remains valid for subsequent operations. + * + * Covered operations: + * format.apply — bold, italic, underline, strike (boolean mark patches) + * format.fontSize, format.fontFamily, format.color (value-based inline marks) + * format.align — paragraph-level alignment (center, right, justify) + */ +describe('document-api story: inline formatting', () => { + const { client, outPath } = useStoryHarness('formatting/inline-formatting', { + preserveResults: true, + }); + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** + * Opens a blank doc, inserts the given descriptive text, and returns a + * target spanning the full inserted range. + * + * Each test gets its own session (and thus its own working doc on disk). + */ + async function setupFormattableText(sessionId: string, text: string) { + // Open a blank document (no doc path → uses built-in blank DOCX template) + await client.doc.open({ sessionId }); + + // Insert text into the blank doc's single paragraph. + // Without an explicit target, insert uses the first paragraph. + const insertResult = unwrap(await client.doc.insert({ sessionId, text })); + expect(insertResult.receipt?.success).toBe(true); + + // The receipt's hoisted target contains the paragraph's stable blockId. + const blockId = insertResult.target?.blockId; + if (!blockId) throw new Error('Insert did not return a target blockId.'); + + // Build a target spanning the full inserted text + return { + kind: 'text' as const, + blockId, + range: { start: 0, end: text.length }, + }; + } + + /** Export the session's working doc to the results directory. */ + async function saveResult(sessionId: string, docName: string) { + await client.doc.save({ sessionId, out: outPath(docName) }); + } + + // --------------------------------------------------------------------------- + // format.apply — boolean mark patches + // --------------------------------------------------------------------------- + + it('bold: applies bold to inserted text', async () => { + const sid = `bold-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be bold'); + + const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { bold: true } })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'bold.docx'); + }); + + it('italic: applies italic to inserted text', async () => { + const sid = `italic-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be italic'); + + const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { italic: true } })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'italic.docx'); + }); + + it('underline: applies underline to inserted text', async () => { + const sid = `underline-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be underlined'); + + const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { underline: true } })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'underline.docx'); + }); + + it('strikethrough: applies strike to inserted text', async () => { + const sid = `strike-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be struck through'); + + const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { strike: true } })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'strike.docx'); + }); + + it('multi-mark: applies bold + italic in a single call', async () => { + const sid = `multi-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be bold and italic'); + + const result = unwrap( + await client.doc.format.apply({ + sessionId: sid, + target, + inline: { bold: true, italic: true }, + }), + ); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'multi-mark.docx'); + }); + + // --------------------------------------------------------------------------- + // format.fontSize + // --------------------------------------------------------------------------- + + it('fontSize: sets a numeric point size', async () => { + const sid = `fontSize-num-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be 24pt'); + + const result = unwrap(await client.doc.format.fontSize({ sessionId: sid, target, value: 24 })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'fontSize-num.docx'); + }); + + it('fontSize: sets a string size value', async () => { + const sid = `fontSize-str-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be 14pt'); + + const result = unwrap(await client.doc.format.fontSize({ sessionId: sid, target, value: '14pt' })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'fontSize-str.docx'); + }); + + // --------------------------------------------------------------------------- + // format.fontFamily + // --------------------------------------------------------------------------- + + it('fontFamily: sets a font family', async () => { + const sid = `fontFamily-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be Courier New'); + + const result = unwrap(await client.doc.format.fontFamily({ sessionId: sid, target, value: 'Courier New' })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'fontFamily.docx'); + }); + + // --------------------------------------------------------------------------- + // format.color + // --------------------------------------------------------------------------- + + it('color: sets a hex color', async () => { + const sid = `color-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be red'); + + const result = unwrap(await client.doc.format.color({ sessionId: sid, target, value: '#FF0000' })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'color.docx'); + }); + + // --------------------------------------------------------------------------- + // format.align (paragraph-level) + // --------------------------------------------------------------------------- + + it('align center: centers the paragraph', async () => { + const sid = `align-center-${Date.now()}`; + const target = await setupFormattableText(sid, 'This paragraph should be centered'); + + const result = unwrap(await client.doc.format.align({ sessionId: sid, target, alignment: 'center' })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'align-center.docx'); + }); + + it('align right: right-aligns the paragraph', async () => { + const sid = `align-right-${Date.now()}`; + const target = await setupFormattableText(sid, 'This paragraph should be right-aligned'); + + const result = unwrap(await client.doc.format.align({ sessionId: sid, target, alignment: 'right' })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'align-right.docx'); + }); + + it('align justify: justifies the paragraph', async () => { + const sid = `align-justify-${Date.now()}`; + const target = await setupFormattableText( + sid, + 'This paragraph should be fully justified so that both the left and right edges align neatly. When the text is long enough to wrap across several lines, justified alignment becomes visually obvious because each line stretches to fill the full width of the page, distributing extra space evenly between words.', + ); + + const result = unwrap(await client.doc.format.align({ sessionId: sid, target, alignment: 'justify' })); + expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'align-justify.docx'); + }); + + // --------------------------------------------------------------------------- + // Combined: multiple value formats on the same range + // --------------------------------------------------------------------------- + + it('combined: fontSize + fontFamily + color on the same text', async () => { + const sid = `combined-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should be 18pt Georgia in blue'); + + const sizeResult = unwrap(await client.doc.format.fontSize({ sessionId: sid, target, value: 18 })); + expect(sizeResult.receipt?.success).toBe(true); + + const familyResult = unwrap(await client.doc.format.fontFamily({ sessionId: sid, target, value: 'Georgia' })); + expect(familyResult.receipt?.success).toBe(true); + + const colorResult = unwrap(await client.doc.format.color({ sessionId: sid, target, value: '#0000FF' })); + expect(colorResult.receipt?.success).toBe(true); + await saveResult(sid, 'combined.docx'); + }); + + // --------------------------------------------------------------------------- + // dryRun: verify no mutation occurs + // --------------------------------------------------------------------------- + + it('dryRun: format.apply returns success without mutating', async () => { + const sid = `dryRun-${Date.now()}`; + const target = await setupFormattableText(sid, 'This text should not actually change'); + + const result = unwrap( + await client.doc.format.apply({ + sessionId: sid, + target, + inline: { bold: true }, + dryRun: true, + }), + ); + expect(result.receipt?.success).toBe(true); + }); +});