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);