diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 377f3ead8f..df8dea2555 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -459,5 +459,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "2910a3842f07d66807ab56e69be0695f6c4b47c4f1661913dd8c93ad8c6469d5" + "sourceHash": "7f83c34ee8c4c0f0cf87345069ee739a8c35aedf4769728c8354f14cb465e4ca" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index f214538503..fe73dc5a39 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -51,7 +51,7 @@ The tables below are grouped by namespace. | getNodeById | editor.doc.getNodeById(...) | Retrieve a single node by its unique ID. | | getText | editor.doc.getText(...) | Extract the plain-text content of the document. | | info | editor.doc.info(...) | Return document metadata including revision, node count, and capabilities. | -| insert | editor.doc.insert(...) | Insert content at a target position. Supports text (default), markdown, and html content types via the `type` field. | +| insert | editor.doc.insert(...) | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | | replace | editor.doc.replace(...) | Replace content at a target position with new text or inline content. | | delete | editor.doc.delete(...) | Delete content at a target position. | diff --git a/apps/docs/document-api/reference/insert.mdx b/apps/docs/document-api/reference/insert.mdx index 7486bc23ff..2044039c8a 100644 --- a/apps/docs/document-api/reference/insert.mdx +++ b/apps/docs/document-api/reference/insert.mdx @@ -1,7 +1,7 @@ --- title: insert sidebarTitle: insert -description: "Insert content at a target position. Supports text (default), markdown, and html content types via the `type` field." +description: "Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: "Insert content at a target position. Supports text (default), mark ## Summary -Insert content at a target position. Supports text (default), markdown, and html content types via the `type` field. +Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. - Operation ID: `insert` - API member path: `editor.doc.insert(...)` diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 04a2058607..139dfea4c6 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -284,7 +284,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | -| `doc.insert` | `insert` | Insert content at a target position. Supports text (default), markdown, and html content types via the `type` field. | +| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | | `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | | `doc.delete` | `delete` | Delete content at a target position. | | `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index 0139fe9e65..7d783ec607 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -210,7 +210,7 @@ Return document summary metadata (block count, word count, character count). ### `insert` -Insert text at a target location. When `target` is provided, inserts at that `TextAddress`. When omitted, the adapter resolves to the default insertion point (first paragraph start). +Insert content at a target location. When `target` is provided, inserts at that `TextAddress`. When omitted, inserts at the end of the document. Supports dry-run and tracked mode. diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index ef7bbe0c6d..1e8a0bb144 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -269,7 +269,7 @@ export const OPERATION_DEFINITIONS = { insert: { memberPath: 'insert', description: - 'Insert content at a target position. Supports text (default), markdown, and html content types via the `type` field.', + 'Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field.', expectedResult: 'Returns a TextMutationReceipt with applied status; receipt reports NO_OP if the insertion point is invalid or content is empty.', requiresDocumentContext: true, diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index d8d2f90225..7e50aec709 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -666,8 +666,8 @@ export interface DocumentApi { */ comments: CommentsApi; /** - * Insert text at a target location. - * If target is omitted, adapters resolve a deterministic default insertion point. + * Insert content at a target location. + * If target is omitted, inserts at the end of the document. */ insert(input: InsertInput, options?: MutationOptions): TextMutationReceipt; /** diff --git a/packages/document-api/src/insert/insert.ts b/packages/document-api/src/insert/insert.ts index 994dc7da02..6d920a3522 100644 --- a/packages/document-api/src/insert/insert.ts +++ b/packages/document-api/src/insert/insert.ts @@ -8,7 +8,7 @@ export type InsertContentType = 'text' | 'markdown' | 'html'; /** Input payload for the `doc.insert` operation. */ export interface InsertInput { - /** Optional insertion target. When omitted, adapters resolve a default insertion point. */ + /** Optional insertion target. When omitted, inserts at the end of the document. */ target?: TextAddress; /** The content to insert. Interpreted according to {@link InsertInput.type}. */ value: string; diff --git a/packages/document-api/src/write/write.ts b/packages/document-api/src/write/write.ts index 0399a3d5fb..ff093baa07 100644 --- a/packages/document-api/src/write/write.ts +++ b/packages/document-api/src/write/write.ts @@ -28,7 +28,7 @@ export type InsertWriteRequest = { kind: 'insert'; /** * Optional insertion target. - * When omitted, adapters may resolve a deterministic default insertion point. + * When omitted, inserts at the end of the document. */ target?: TextAddress; text: string; diff --git a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts index 4f68e74d2d..3531f4f8a3 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts @@ -1,8 +1,16 @@ -import type { Query, TextAddress, UnknownNodeDiagnostic } from '@superdoc/document-api'; +import type { + Query, + TextAddress, + TextMutationResolution, + UnknownNodeDiagnostic, + WriteRequest, +} from '@superdoc/document-api'; import { DocumentApiValidationError } from '@superdoc/document-api'; import { getBlockIndex } from './index-cache.js'; import { findBlockById, isTextBlockCandidate, type BlockCandidate, type BlockIndex } from './node-address-resolver.js'; -import { resolveTextRangeInBlock } from './text-offset-resolver.js'; +import { computeTextContentLength, resolveTextRangeInBlock } from './text-offset-resolver.js'; +import { buildTextMutationResolution, readTextAtResolvedRange } from './text-mutation-resolution.js'; +import type { Transaction } from 'prosemirror-state'; import type { Editor } from '../../core/Editor.js'; import { DocumentApiAdapterError } from '../errors.js'; @@ -49,31 +57,192 @@ export function resolveTextTarget(editor: Editor, target: TextAddress): Resolved return resolveTextRangeInBlock(block.node, block.pos, target.range); } +/** + * Collects the absolute positions of all direct children of the doc node. + * Used to distinguish top-level blocks from nested blocks (e.g. paragraphs + * inside table cells) when resolving the default insertion target. + */ +function collectTopLevelPositions(doc: { + childCount: number; + child(index: number): { nodeSize: number }; +}): Set { + const positions = new Set(); + let offset = 0; + for (let i = 0; i < doc.childCount; i++) { + positions.add(offset); + offset += doc.child(i).nodeSize; + } + return positions; +} + +/** + * Result of resolving the default insertion target. + * + * - `text-block`: The last top-level text block was found; insert at its content end. + * - `structural-end`: No top-level text block exists at or after the desired + * insertion point. The caller must create a writable host (e.g. a paragraph) + * at `insertPos` before inserting content. + */ +export type DefaultInsertTarget = + | { kind: 'text-block'; target: TextAddress; range: ResolvedTextTarget } + | { kind: 'structural-end'; insertPos: number }; + /** * Resolves the deterministic default insertion target for insert-without-target calls. * - * Priority: - * 1) First paragraph block in document order. - * 2) First editable text block in document order. + * Targets the **end** of the last top-level writable text block in document + * order, so that target-less inserts behave as "append to document end." + * + * Only top-level blocks (direct children of the doc node) are considered. + * Nested text blocks inside tables, SDTs, or other containers are excluded + * so that a document ending in a table resolves to the last top-level + * paragraph before it, not to a cell paragraph inside it. + * + * When no top-level text block exists, returns `structural-end` with the + * position at the end of the document content (`doc.content.size`), signaling + * that the caller must create a writable host before insertion. */ -export function resolveDefaultInsertTarget(editor: Editor): { target: TextAddress; range: ResolvedTextTarget } | null { +export function resolveDefaultInsertTarget(editor: Editor): DefaultInsertTarget | null { const index = getBlockIndex(editor); - const firstParagraph = index.candidates.find( - (candidate) => candidate.nodeType === 'paragraph' && isTextBlockCandidate(candidate), - ); - const firstTextBlock = firstParagraph ?? index.candidates.find((candidate) => isTextBlockCandidate(candidate)); - if (!firstTextBlock) return null; + const doc = editor.state.doc; + const topLevelPositions = collectTopLevelPositions(doc); + + // Walk candidates in reverse to find the last top-level text block. + for (let i = index.candidates.length - 1; i >= 0; i--) { + const candidate = index.candidates[i]; + if (topLevelPositions.has(candidate.pos) && isTextBlockCandidate(candidate)) { + const textLength = computeTextContentLength(candidate.node); + const range = resolveTextRangeInBlock(candidate.node, candidate.pos, { start: textLength, end: textLength }); + if (!range) continue; + + return { + kind: 'text-block', + target: { + kind: 'text', + blockId: candidate.nodeId, + range: { start: textLength, end: textLength }, + }, + range, + }; + } + } + + // No top-level text block found. If the document has any content, + // signal structural-end so the caller can create a writable host. + if (doc.content.size > 0) { + return { kind: 'structural-end', insertPos: doc.content.size }; + } + + return null; +} + +/** Resolved write target with the effective address, absolute range, and resolution snapshot. */ +export type ResolvedWrite = { + requestedTarget?: TextAddress; + /** + * The resolved target address used for the mutation. + * + * When {@link structuralEnd} is `true`, this is a synthetic placeholder + * (`blockId: ''`) that should not be used for block lookup or display. + */ + effectiveTarget: TextAddress; + range: ResolvedTextTarget; + resolution: TextMutationResolution; + /** + * When `true`, the resolved position is at the structural end of the + * document where no text block exists. The caller must create a writable + * host (paragraph) at `range.from` before inserting content. + */ + structuralEnd?: true; +}; + +/** + * Creates a new paragraph containing the given text and inserts it at the + * specified position using the editor's transaction pipeline. + * + * Used by structural-end handlers when the document ends with non-text blocks + * and a writable host must be created before inserting content. + * + * @param applyMeta - Optional callback to annotate the transaction before + * dispatch (e.g. `applyTrackedMutationMeta` for tracked-mode inserts). + */ +export function insertParagraphAtEnd( + editor: Editor, + pos: number, + text: string, + applyMeta?: (tr: Transaction) => Transaction, +): void { + const schema = editor.state.schema; + const textNode = schema.text(text); + const paragraph = schema.nodes.paragraph.create(null, textNode); + const tr = editor.state.tr; + tr.insert(pos, paragraph); + if (applyMeta) applyMeta(tr); + editor.dispatch(tr); +} + +/** + * Resolves the write target for a mutation request. + * + * When the request is a target-less insert, falls back to the document-end + * insertion point via {@link resolveDefaultInsertTarget}. Otherwise resolves + * the explicit target address. + * + * For structural-end resolutions (doc ends in non-text blocks), the returned + * `ResolvedWrite` has `structuralEnd: true` and the caller is responsible for + * creating a writable host before insertion. + */ +export function resolveWriteTarget(editor: Editor, request: WriteRequest): ResolvedWrite | null { + const requestedTarget = request.target; + + if (request.kind === 'insert' && !request.target) { + const fallback = resolveDefaultInsertTarget(editor); + if (!fallback) return null; + + if (fallback.kind === 'structural-end') { + const pos = fallback.insertPos; + const syntheticRange: ResolvedTextTarget = { from: pos, to: pos }; + const syntheticTarget: TextAddress = { kind: 'text', blockId: '', range: { start: 0, end: 0 } }; + return { + requestedTarget, + effectiveTarget: syntheticTarget, + range: syntheticRange, + resolution: buildTextMutationResolution({ + requestedTarget, + target: syntheticTarget, + range: syntheticRange, + text: '', + }), + structuralEnd: true, + }; + } + + const text = readTextAtResolvedRange(editor, fallback.range); + return { + requestedTarget, + effectiveTarget: fallback.target, + range: fallback.range, + resolution: buildTextMutationResolution({ + requestedTarget, + target: fallback.target, + range: fallback.range, + text, + }), + }; + } + + const target = request.target; + if (!target) return null; - const range = resolveTextRangeInBlock(firstTextBlock.node, firstTextBlock.pos, { start: 0, end: 0 }); + const range = resolveTextTarget(editor, target); if (!range) return null; + const text = readTextAtResolvedRange(editor, range); return { - target: { - kind: 'text', - blockId: firstTextBlock.nodeId, - range: { start: 0, end: 0 }, - }, + requestedTarget, + effectiveTarget: target, range, + resolution: buildTextMutationResolution({ requestedTarget, target, range, text }), }; } diff --git a/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts index 17f3dcaa40..39ac287d9c 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts @@ -1,5 +1,5 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import { resolveTextRangeInBlock } from './text-offset-resolver.js'; +import { computeTextContentLength, resolveTextRangeInBlock } from './text-offset-resolver.js'; type NodeOptions = { text?: string; @@ -101,3 +101,58 @@ describe('resolveTextRangeInBlock', () => { expect(result).toEqual({ from: 5, to: 6 }); }); }); + +describe('computeTextContentLength', () => { + it('returns 0 for an empty block', () => { + const paragraph = createNode('paragraph', [], { isBlock: true, inlineContent: true }); + + expect(computeTextContentLength(paragraph)).toBe(0); + }); + + it('returns the text length for a block with a single text node', () => { + const textNode = createNode('text', [], { text: 'Hello' }); + const paragraph = createNode('paragraph', [textNode], { isBlock: true, inlineContent: true }); + + expect(computeTextContentLength(paragraph)).toBe(5); + }); + + it('sums text lengths across multiple inline children', () => { + const textA = createNode('text', [], { text: 'AB' }); + const textB = createNode('text', [], { text: 'CD' }); + const paragraph = createNode('paragraph', [textA, textB], { isBlock: true, inlineContent: true }); + + expect(computeTextContentLength(paragraph)).toBe(4); + }); + + it('counts inline leaf atoms as 1', () => { + const textNode = createNode('text', [], { text: 'A' }); + const imageNode = createNode('image', [], { isInline: true, isLeaf: true, nodeSize: 3 }); + const paragraph = createNode('paragraph', [textNode, imageNode], { isBlock: true, inlineContent: true }); + + // "A" (1) + image atom (1) = 2 + expect(computeTextContentLength(paragraph)).toBe(2); + }); + + it('counts block separators between nested block children', () => { + const paraA = createNode('paragraph', [createNode('text', [], { text: 'A' })], { + isBlock: true, + inlineContent: true, + }); + const paraB = createNode('paragraph', [createNode('text', [], { text: 'B' })], { + isBlock: true, + inlineContent: true, + }); + const cell = createNode('tableCell', [paraA, paraB], { isBlock: true, inlineContent: false }); + + // "A" (1) + block separator (1) + "B" (1) = 3 + expect(computeTextContentLength(cell)).toBe(3); + }); + + it('treats inline wrappers as transparent', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const runNode = createNode('run', [textNode], { isInline: true, isLeaf: false }); + const paragraph = createNode('paragraph', [runNode], { isBlock: true, inlineContent: true }); + + expect(computeTextContentLength(paragraph)).toBe(2); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts index 1d677981cc..25d7561e59 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts @@ -23,6 +23,44 @@ function resolveSegmentPosition( return docFrom + (targetOffset - segmentStart); } +/** + * Computes the total flattened text length of a block node using the same + * offset model as {@link resolveTextRangeInBlock}: text contributes its + * length, leaf atoms contribute 1, block separators contribute 1. + */ +export function computeTextContentLength(blockNode: ProseMirrorNode): number { + let length = 0; + + const walk = (node: ProseMirrorNode): void => { + if (node.isText) { + length += (node.text ?? '').length; + return; + } + if (node.isLeaf) { + length += 1; + return; + } + // Non-leaf, non-text: walk children + let first = true; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child.isBlock && !first) length += 1; // block separator + walk(child); + first = false; + } + }; + + let first = true; + for (let i = 0; i < blockNode.childCount; i++) { + const child = blockNode.child(i); + if (child.isBlock && !first) length += 1; + walk(child); + first = false; + } + + return length; +} + /** * Resolves block-relative text offsets into absolute ProseMirror positions. * diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index b86aec2c20..6f8dfed446 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -27,7 +27,14 @@ import type { CompiledTarget } from './executor-registry.types.js'; import { executeCompiledPlan } from './executor.js'; import { getRevision } from './revision-tracker.js'; import { DocumentApiAdapterError } from '../errors.js'; -import { resolveDefaultInsertTarget, resolveTextTarget, type ResolvedTextTarget } from '../helpers/adapter-utils.js'; +import { + insertParagraphAtEnd, + resolveDefaultInsertTarget, + resolveTextTarget, + resolveWriteTarget, + type ResolvedTextTarget, + type ResolvedWrite, +} from '../helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from '../helpers/text-mutation-resolution.js'; import { ensureTrackedCapability, @@ -36,6 +43,7 @@ import { rejectTrackedMode, } from '../helpers/mutation-helpers.js'; import { TrackFormatMarkName } from '../../extensions/track-changes/constants.js'; +import { applyDirectMutationMeta, applyTrackedMutationMeta } from '../helpers/transaction-meta.js'; import { markdownToPmFragment } from '../../core/helpers/markdown/markdownToPmContent.js'; import { processContent } from '../../core/helpers/contentProcessor.js'; @@ -146,52 +154,6 @@ function normalizeFormatLocator(input: FormatOperationInput): FormatOperationInp return { target }; } -// --------------------------------------------------------------------------- -// Resolution helpers -// --------------------------------------------------------------------------- - -interface ResolvedWrite { - requestedTarget?: TextAddress; - effectiveTarget: TextAddress; - range: ResolvedTextTarget; - resolution: TextMutationResolution; -} - -function resolveWriteTarget(editor: Editor, request: WriteRequest): ResolvedWrite | null { - const requestedTarget = request.target; - - if (request.kind === 'insert' && !request.target) { - const fallback = resolveDefaultInsertTarget(editor); - if (!fallback) return null; - const text = readTextAtResolvedRange(editor, fallback.range); - return { - requestedTarget, - effectiveTarget: fallback.target, - range: fallback.range, - resolution: buildTextMutationResolution({ - requestedTarget, - target: fallback.target, - range: fallback.range, - text, - }), - }; - } - - const target = request.target; - if (!target) return null; - - const range = resolveTextTarget(editor, target); - if (!range) return null; - - const text = readTextAtResolvedRange(editor, range); - return { - requestedTarget, - effectiveTarget: target, - range, - resolution: buildTextMutationResolution({ requestedTarget, target, range, text }), - }; -} - // --------------------------------------------------------------------------- // Receipt mapping: PlanReceipt → TextMutationReceipt // --------------------------------------------------------------------------- @@ -314,6 +276,24 @@ export function writeWrapper(editor: Editor, request: WriteRequest, options?: Mu return { success: true, resolution: resolved.resolution }; } + // Structural-end: the doc ends with non-text blocks. Create a paragraph + // containing the text at the structural document end via a domain command, + // since raw `tr.insert(pos, textNode)` cannot place text between blocks. + if (resolved.structuralEnd && normalizedRequest.kind === 'insert') { + const insertPos = resolved.range.from; + const text = normalizedRequest.text ?? ''; + const receipt = executeDomainCommand( + editor, + (): boolean => { + const meta = mode === 'tracked' ? applyTrackedMutationMeta : applyDirectMutationMeta; + insertParagraphAtEnd(editor, insertPos, text, meta); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + return mapPlanReceiptToTextReceipt(receipt, resolved.resolution); + } + // Build single-step compiled plan with pre-resolved target. // The step's `where` clause is a structural stub — it is never evaluated // because targets are already resolved. @@ -552,8 +532,17 @@ export function insertStructuredWrapper( if (!fallback) { throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'No default insertion point available.'); } - resolvedRange = fallback.range; - effectiveTarget = fallback.target; + if (fallback.kind === 'structural-end') { + // Doc ends with non-text blocks — insert structured content at the + // structural document end. Structured content (markdown/html) produces + // block-level nodes that ProseMirror can place between blocks. + const pos = fallback.insertPos; + resolvedRange = { from: pos, to: pos }; + effectiveTarget = { kind: 'text', blockId: '', range: { start: 0, end: 0 } }; + } else { + resolvedRange = fallback.range; + effectiveTarget = fallback.target; + } } const resolution = buildTextMutationResolution({ diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts index b439263263..c72c4b2362 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts @@ -31,6 +31,7 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: attrs, text: isText ? text : undefined, nodeSize, + content: { size: contentSize }, isText, isInline, isBlock, @@ -172,8 +173,27 @@ function makeEditorWithDuplicateBlockIds(): { return { editor, dispatch, tr }; } +/** + * Creates a doc containing only a table (no top-level text blocks). + * + * Layout: + * - doc: pos 0 + * - table: pos 0..2 (nodeSize 2, no children) + * - doc.content.size = 2 + * + * The structural-end path creates a paragraph at doc.content.size via + * schema.nodes.paragraph.create() + schema.text(). + */ function makeEditorWithoutEditableTextBlock(): { editor: Editor; + dispatch: ReturnType; + tr: { + insertText: ReturnType; + insert: ReturnType; + delete: ReturnType; + setMeta: ReturnType; + addMark: ReturnType; + }; } { const table = createNode('table', [], { attrs: { sdBlockId: 't1' }, @@ -184,15 +204,30 @@ function makeEditorWithoutEditableTextBlock(): { const tr = { insertText: vi.fn(), + insert: vi.fn(), delete: vi.fn(), setMeta: vi.fn(), addMark: vi.fn(), }; tr.insertText.mockReturnValue(tr); + tr.insert.mockReturnValue(tr); tr.delete.mockReturnValue(tr); tr.setMeta.mockReturnValue(tr); tr.addMark.mockReturnValue(tr); + const mockTextNode = { isText: true, text: 'X' }; + const mockParagraph = { type: { name: 'paragraph' }, content: [mockTextNode] }; + const schema = { + text: vi.fn(() => mockTextNode), + nodes: { + paragraph: { + create: vi.fn(() => mockParagraph), + }, + }, + }; + + const dispatch = vi.fn(); + const editor = { state: { doc: { @@ -200,14 +235,18 @@ function makeEditorWithoutEditableTextBlock(): { textBetween: vi.fn(() => ''), }, tr, + schema, }, commands: { insertTrackedChange: vi.fn(() => true), }, - dispatch: vi.fn(), + options: { + user: { name: 'Test User' }, + }, + dispatch, } as unknown as Editor; - return { editor }; + return { editor, dispatch, tr }; } function makeEditorWithBlankParagraph(): { @@ -259,6 +298,170 @@ function makeEditorWithBlankParagraph(): { return { editor, dispatch, insertTrackedChange, tr }; } +/** + * Creates a doc with two paragraphs: "Hello" (p1) and "World" (p2). + * + * Layout: + * - doc: pos 0 + * - p1 "Hello": pos 0..7 (content 1..6) + * - p2 "World": pos 7..14 (content 8..13) + */ +function makeEditorWithTwoParagraphs(): { + editor: Editor; + dispatch: ReturnType; + tr: { + insertText: ReturnType; + delete: ReturnType; + setMeta: ReturnType; + addMark: ReturnType; + }; +} { + const firstTextNode = createNode('text', [], { text: 'Hello' }); + const secondTextNode = createNode('text', [], { text: 'World' }); + const firstParagraph = createNode('paragraph', [firstTextNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const secondParagraph = createNode('paragraph', [secondTextNode], { + attrs: { sdBlockId: 'p2' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [firstParagraph, secondParagraph], { isBlock: false }); + + const tr = { + insertText: vi.fn(), + delete: vi.fn(), + setMeta: vi.fn(), + addMark: vi.fn(), + }; + tr.insertText.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + + const dispatch = vi.fn(); + + const editor = { + state: { + doc: { + ...doc, + textBetween: vi.fn((from: number, to: number) => { + const fullText = 'Hello\nWorld'; + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return fullText.slice(start, end); + }), + }, + tr, + }, + commands: { + insertTrackedChange: vi.fn(() => true), + }, + options: { + user: { name: 'Test User' }, + }, + dispatch, + } as unknown as Editor; + + return { editor, dispatch, tr }; +} + +/** + * Creates a doc with a paragraph "Hello" (p1) followed by a table containing + * a cell with a nested paragraph "Cell" (cellP). + * + * Layout: + * - doc: pos 0 + * - p1 "Hello": pos 0..7 (content 1..6) + * - table: pos 7..20 + * - tableRow: pos 8..19 + * - tableCell: pos 9..18 + * - paragraph "Cell": pos 10..16 (content 11..15) + * + * The resolver must target p1 (top-level), NOT the cell paragraph. + */ +function makeEditorWithTrailingTable(): { + editor: Editor; + dispatch: ReturnType; + tr: { + insertText: ReturnType; + delete: ReturnType; + setMeta: ReturnType; + addMark: ReturnType; + }; +} { + const textNode = createNode('text', [], { text: 'Hello' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + + const cellTextNode = createNode('text', [], { text: 'Cell' }); + const cellParagraph = createNode('paragraph', [cellTextNode], { + attrs: { sdBlockId: 'cellP' }, + isBlock: true, + inlineContent: true, + }); + const tableCell = createNode('tableCell', [cellParagraph], { + attrs: { sdBlockId: 'tc1' }, + isBlock: true, + inlineContent: false, + }); + const tableRow = createNode('tableRow', [tableCell], { + attrs: { sdBlockId: 'tr1' }, + isBlock: true, + inlineContent: false, + }); + const table = createNode('table', [tableRow], { + attrs: { sdBlockId: 't1' }, + isBlock: true, + inlineContent: false, + }); + + const doc = createNode('doc', [paragraph, table], { isBlock: false, inlineContent: false }); + + const tr = { + insertText: vi.fn(), + delete: vi.fn(), + setMeta: vi.fn(), + addMark: vi.fn(), + }; + tr.insertText.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + + const dispatch = vi.fn(); + + const editor = { + state: { + doc: { + ...doc, + textBetween: vi.fn((from: number, to: number) => { + // p1 content at 1..6 = "Hello", cell content at 11..15 = "Cell" + const text = 'Hello'; + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return text.slice(start, end); + }), + }, + tr, + }, + commands: { + insertTrackedChange: vi.fn(() => true), + }, + options: { + user: { name: 'Test User' }, + }, + dispatch, + } as unknown as Editor; + + return { editor, dispatch, tr }; +} + describe('writeAdapter', () => { it('applies direct replace mutations', () => { const { editor, dispatch, tr } = makeEditor('Hello'); @@ -425,7 +628,7 @@ describe('writeAdapter', () => { }); }); - it('defaults insert-without-target to the first paragraph at offset 0', () => { + it('defaults insert-without-target to the end of the last paragraph', () => { const { editor, dispatch, tr } = makeEditor('Hello'); const receipt = writeAdapter( @@ -438,9 +641,9 @@ describe('writeAdapter', () => { ); expect(receipt.success).toBe(true); - expect(receipt.resolution.target.range).toEqual({ start: 0, end: 0 }); - expect(receipt.resolution.range).toEqual({ from: 1, to: 1 }); - expect(tr.insertText).toHaveBeenCalledWith('X', 1, 1); + expect(receipt.resolution.target.range).toEqual({ start: 5, end: 5 }); + expect(receipt.resolution.range).toEqual({ from: 6, to: 6 }); + expect(tr.insertText).toHaveBeenCalledWith('X', 6, 6); expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); expect(dispatch).toHaveBeenCalledTimes(1); }); @@ -469,7 +672,7 @@ describe('writeAdapter', () => { expect(dispatch).toHaveBeenCalledTimes(1); }); - it('supports tracked insert-without-target using the default insertion point', () => { + it('supports tracked insert-without-target at the document end', () => { const { editor, insertTrackedChange } = makeEditor('Hello'); const receipt = writeAdapter( @@ -484,26 +687,49 @@ describe('writeAdapter', () => { expect(receipt.success).toBe(true); expect(insertTrackedChange).toHaveBeenCalledTimes(1); expect(insertTrackedChange.mock.calls[0]?.[0]).toMatchObject({ - from: 1, - to: 1, + from: 6, + to: 6, text: 'X', }); expect(typeof insertTrackedChange.mock.calls[0]?.[0]?.id).toBe('string'); }); - it('throws TARGET_NOT_FOUND for insert-without-target when no editable text block exists', () => { - const { editor } = makeEditorWithoutEditableTextBlock(); + it('creates a paragraph at document end for insert-without-target when no editable text block exists', () => { + const { editor, dispatch, tr } = makeEditorWithoutEditableTextBlock(); - expect(() => - writeAdapter( - editor, - { - kind: 'insert', - text: 'X', - }, - { changeMode: 'direct' }, - ), - ).toThrow('Mutation target could not be resolved.'); + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + // Structural-end: creates a paragraph at doc.content.size (2) with direct meta + expect(tr.insert).toHaveBeenCalledWith(2, expect.anything()); + expect(tr.setMeta).toHaveBeenCalledWith('skipTrackChanges', true); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('creates a tracked paragraph at document end for tracked structural-end insert', () => { + const { editor, dispatch, tr } = makeEditorWithoutEditableTextBlock(); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + // Structural-end with tracked mode: paragraph created with tracked meta + expect(tr.insert).toHaveBeenCalledWith(2, expect.anything()); + expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); + expect(dispatch).toHaveBeenCalledTimes(1); }); it('throws CAPABILITY_UNAVAILABLE when tracked writes are unavailable', () => { @@ -844,4 +1070,89 @@ describe('writeAdapter', () => { text: 'X', }); }); + + // -- insert-without-target: document-end semantics -- + + it('targets the last paragraph when multiple paragraphs exist', () => { + const { editor, tr } = makeEditorWithTwoParagraphs(); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + // Should target p2 at offset 5 (end of "World"), PM pos 13 + expect(receipt.resolution.target).toEqual({ + kind: 'text', + blockId: 'p2', + range: { start: 5, end: 5 }, + }); + expect(receipt.resolution.range).toEqual({ from: 13, to: 13 }); + expect(tr.insertText).toHaveBeenCalledWith('X', 13, 13); + }); + + it('dry-run resolves to document end without mutating', () => { + const { editor, dispatch, tr } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct', dryRun: true }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution.target.range).toEqual({ start: 5, end: 5 }); + expect(receipt.resolution.range).toEqual({ from: 6, to: 6 }); + expect(tr.insertText).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('targets the top-level paragraph when doc ends with a table', () => { + const { editor, tr } = makeEditorWithTrailingTable(); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + // Must target p1 (top-level), not the nested cell paragraph + expect(receipt.resolution.target).toEqual({ + kind: 'text', + blockId: 'p1', + range: { start: 5, end: 5 }, + }); + expect(receipt.resolution.range).toEqual({ from: 6, to: 6 }); + expect(tr.insertText).toHaveBeenCalledWith('X', 6, 6); + }); + + it('creates a paragraph at document end when doc has only non-text top-level blocks', () => { + const { editor, dispatch, tr } = makeEditorWithoutEditableTextBlock(); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + // Structural-end: creates a paragraph via tr.insert at doc.content.size (2) + expect(tr.insert).toHaveBeenCalledWith(2, expect.anything()); + expect(dispatch).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index dad06ce293..447c6ea6dd 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -9,13 +9,12 @@ import type { } from '@superdoc/document-api'; import { DocumentApiAdapterError } from './errors.js'; import { ensureTrackedCapability } from './helpers/mutation-helpers.js'; -import { applyDirectMutationMeta } from './helpers/transaction-meta.js'; +import { applyDirectMutationMeta, applyTrackedMutationMeta } from './helpers/transaction-meta.js'; import { checkRevision } from './plan-engine/revision-tracker.js'; -import { resolveDefaultInsertTarget, resolveTextTarget, type ResolvedTextTarget } from './helpers/adapter-utils.js'; -import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; +import { insertParagraphAtEnd, resolveWriteTarget, type ResolvedWrite } from './helpers/adapter-utils.js'; import { toCanonicalTrackedChangeId } from './helpers/tracked-change-resolver.js'; -function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWriteTarget): ReceiptFailure | null { +function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWrite): ReceiptFailure | null { if (request.kind === 'insert') { if (!request.text) { return { @@ -62,13 +61,6 @@ function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWri return null; } -type ResolvedWriteTarget = { - requestedTarget?: TextAddress; - effectiveTarget: TextAddress; - range: ResolvedTextTarget; - resolution: ReturnType; -}; - /** * Normalize block-relative locator fields into a canonical TextAddress. * This runs inside the adapter layer so that the resolution uses engine-specific block lookup. @@ -165,58 +157,20 @@ function normalizeWriteLocator(request: WriteRequest): WriteRequest { return request; } -function resolveWriteTarget(editor: Editor, request: WriteRequest): ResolvedWriteTarget | null { - const requestedTarget = request.target; - - if (request.kind === 'insert' && !request.target) { - const fallback = resolveDefaultInsertTarget(editor); - if (!fallback) return null; - - const text = readTextAtResolvedRange(editor, fallback.range); - return { - requestedTarget, - effectiveTarget: fallback.target, - range: fallback.range, - resolution: buildTextMutationResolution({ - requestedTarget, - target: fallback.target, - range: fallback.range, - text, - }), - }; - } - - const target = request.target; - if (!target) return null; - - const range = resolveTextTarget(editor, target); - if (!range) return null; - - const text = readTextAtResolvedRange(editor, range); - return { - requestedTarget, - effectiveTarget: target, - range, - resolution: buildTextMutationResolution({ - requestedTarget, - target, - range, - text, - }), - }; -} - -function applyDirectWrite( - editor: Editor, - request: WriteRequest, - resolvedTarget: ResolvedWriteTarget, -): TextMutationReceipt { +function applyDirectWrite(editor: Editor, request: WriteRequest, resolvedTarget: ResolvedWrite): TextMutationReceipt { if (request.kind === 'delete') { const tr = applyDirectMutationMeta(editor.state.tr.delete(resolvedTarget.range.from, resolvedTarget.range.to)); editor.dispatch(tr); return { success: true, resolution: resolvedTarget.resolution }; } + // Structural-end: create a paragraph at the document end, since raw + // insertText cannot place text between block nodes. + if (resolvedTarget.structuralEnd) { + insertParagraphAtEnd(editor, resolvedTarget.range.from, request.text ?? '', applyDirectMutationMeta); + return { success: true, resolution: resolvedTarget.resolution }; + } + // text is guaranteed non-empty for insert/replace after validateWriteRequest const tr = applyDirectMutationMeta( editor.state.tr.insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to), @@ -225,12 +179,17 @@ function applyDirectWrite( return { success: true, resolution: resolvedTarget.resolution }; } -function applyTrackedWrite( - editor: Editor, - request: WriteRequest, - resolvedTarget: ResolvedWriteTarget, -): TextMutationReceipt { +function applyTrackedWrite(editor: Editor, request: WriteRequest, resolvedTarget: ResolvedWrite): TextMutationReceipt { ensureTrackedCapability(editor, { operation: 'write' }); + + // Structural-end: create a tracked paragraph at the document end. + // insertTrackedChange cannot operate between block nodes, so we use + // a direct tr.insert with tracked mutation meta instead. + if (resolvedTarget.structuralEnd) { + insertParagraphAtEnd(editor, resolvedTarget.range.from, request.text ?? '', applyTrackedMutationMeta); + return { success: true, resolution: resolvedTarget.resolution }; + } + // insertTrackedChange is guaranteed to exist after ensureTrackedCapability. const insertTrackedChange = editor.commands!.insertTrackedChange!; const text = request.kind === 'delete' ? '' : (request.text ?? ''); @@ -272,7 +231,7 @@ function applyTrackedWrite( }; } -function toFailureReceipt(failure: ReceiptFailure, resolvedTarget: ResolvedWriteTarget): TextMutationReceipt { +function toFailureReceipt(failure: ReceiptFailure, resolvedTarget: ResolvedWrite): TextMutationReceipt { return { success: false, resolution: resolvedTarget.resolution, diff --git a/tests/doc-api-stories/tests/formatting/inline-formatting.ts b/tests/doc-api-stories/tests/formatting/inline-formatting.ts index 90576979f2..b9d5a30388 100644 --- a/tests/doc-api-stories/tests/formatting/inline-formatting.ts +++ b/tests/doc-api-stories/tests/formatting/inline-formatting.ts @@ -37,7 +37,7 @@ describe('document-api story: inline formatting', () => { await client.doc.open({ sessionId }); // Insert text into the blank doc's single paragraph. - // Without an explicit target, insert uses the first paragraph. + // Without an explicit target, insert appends at the document end. const insertResult = unwrap(await client.doc.insert({ sessionId, value: text })); expect(insertResult.receipt?.success).toBe(true);