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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -459,5 +459,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "2910a3842f07d66807ab56e69be0695f6c4b47c4f1661913dd8c93ad8c6469d5"
"sourceHash": "7f83c34ee8c4c0f0cf87345069ee739a8c35aedf4769728c8354f14cb465e4ca"
}
2 changes: 1 addition & 1 deletion apps/docs/document-api/reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The tables below are grouped by namespace.
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/get-node-by-id"><code>getNodeById</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getNodeById(...)</code></span> | Retrieve a single node by its unique ID. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/get-text"><code>getText</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getText(...)</code></span> | Extract the plain-text content of the document. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/info"><code>info</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.info(...)</code></span> | Return document metadata including revision, node count, and capabilities. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/insert"><code>insert</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.insert(...)</code></span> | Insert content at a target position. Supports text (default), markdown, and html content types via the `type` field. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/insert"><code>insert</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.insert(...)</code></span> | 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. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/replace"><code>replace</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.replace(...)</code></span> | Replace content at a target position with new text or inline content. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/delete"><code>delete</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.delete(...)</code></span> | Delete content at a target position. |

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/document-api/reference/insert.mdx
Original file line number Diff line number Diff line change
@@ -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`. */}
Expand All @@ -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(...)`
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/document-engine/sdks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion packages/document-api/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/document-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/document-api/src/insert/insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/document-api/src/write/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<number> {
const positions = new Set<number>();
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 }),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});
Loading
Loading