# Overview > Route to the right surface for editing DOCX files from code, a shell, or an agent. SuperDoc changes DOCX files without an editor UI. Some of that work is deterministic: a script knows the operation before it runs. Some of it is model-driven: an agent decides which operation to run from an instruction. Both use the same document engine and the same Document API contract. Headless describes how these workflows run. It does not describe what you are trying to build, so start from the goal instead. ## Start from your goal [#start-from-your-goal] | Goal | Start here | | ------------------------------------------- | ------------------------------------------------------------------ | | Let a coding agent edit DOCX files | [MCP quickstart](/agents/quickstart) | | Add document tools to your product's agent | [Build an agent](/agents/build/build-an-agent) | | Run a known operation from application code | [Node.js SDK](/agents/automation/node-sdk) | | Run a known operation from Python | [Python SDK](/agents/automation/python-sdk) | | Keep a person in the approval loop | [Review tracked changes](/agents/workflows/review-tracked-changes) | | Understand the operation contract itself | [Document API mental model](/document-api/mental-model) | The MCP server is the fastest way to see agent-driven editing work, because an existing agent supplies the model loop. Reach for the SDK toolkit when the agent lives inside your own product. ## The workflow underneath [#the-workflow-underneath] Every headless workflow follows the same sequence, whether a script or a model chooses the operations: ```text open → inspect state → mutate → check receipt → save → close ``` A model-driven workflow adds one step in front of it: ```text instruction → model selects a tool → SuperDoc runs the operation → receipt ``` The split matters. The model decides what to do. SuperDoc runs the operation deterministically and reports a structured result. A model that picks the wrong operation produces a valid receipt for the wrong edit, so treat model mistakes and operation failures as separate problems. ## Which package to install [#which-package-to-install] | Surface | Use it when | Package | | ----------- | --------------------------------------------------------- | ------------------- | | MCP server | A coding agent should open and edit files on your machine | `@superdoc-dev/mcp` | | Node.js SDK | Node.js owns the workflow, errors, and output files | `@superdoc-dev/sdk` | | Python SDK | Python owns the workflow, errors, and output files | `superdoc-sdk` | | CLI | A shell script or CI job runs the workflow | `@superdoc-dev/cli` | The Node.js SDK also provides the agent toolkit, so a Node.js product that embeds its own agent needs only `@superdoc-dev/sdk`. Do not import `@superdoc/headless` or `@superdoc/document-api-v2-adapter`. Those are implementation details rather than integration surfaces. ## What these surfaces share with the Editor [#what-these-surfaces-share-with-the-editor] * Queries return explicit matches, targets, handles, and revisions. * Mutations return receipts or structured failures. * Direct and tracked changes use the same operation contract. * Comments, lists, tables, images, and sections are reached through Document API operations. * DOCX remains the input and output format. Capability availability varies by runtime and document state. Check `doc.capabilities()` before relying on an optional operation. ## What they do not provide [#what-they-do-not-provide] These hosts never mount the Editor UI, so they have no toolbar, document canvas, viewport, browser selection, or pointer editing. There is no visual surface for accepting or rejecting a suggestion. When a person must decide, write a separate DOCX and open it in the Editor or Microsoft Word. The [tracked-change review workflow](/agents/workflows/review-tracked-changes) shows that handoff end to end. ## Before you ship [#before-you-ship] Your application owns the file and process boundary around the engine. Validate input paths, write results to a separate output until verification succeeds, bound retries, attribute changes to an explicit identity, and close sessions on success and failure alike. [Safety](/agents/operate/safety) covers those responsibilities in full, and matters most once a model is choosing the operations. --- # Quickstart > Connect a coding agent to SuperDoc through MCP and produce a reviewable tracked edit. The MCP server lets a coding agent open, read, edit, and save `.docx` files on your machine. It is the fastest way to see agent-driven document editing, because the agent you already use supplies the model loop. This quickstart connects the server, inspects a document, proposes a tracked edit, and saves the result to a separate file you can review. ## 1. Connect the server [#1-connect-the-server] The server runs locally over stdio. Your MCP client spawns it as a subprocess, so you never start it yourself. For Claude Code: ```bash claude mcp add superdoc -- pnpm dlx @superdoc-dev/mcp ``` For Claude Desktop, Cursor, or Windsurf, add the same command to the client's MCP configuration file: ```json { "mcpServers": { "superdoc": { "command": "pnpm", "args": ["dlx", "@superdoc-dev/mcp"] } } } ``` Claude Desktop reads `~/Library/Application Support/Claude/claude_desktop_config.json`. Cursor reads `~/.cursor/mcp.json`. Windsurf reads `~/.codeium/windsurf/mcp_config.json`. Restart the client after editing the file. > **The agent gets filesystem reach (warning)** > > The server reads and writes any path the agent supplies, with no restriction to a particular directory. Point it at > copies, and see [Safety](/agents/operate/safety) for how to contain it properly. ## 2. Prepare a document [#2-prepare-a-document] Download the synthetic NDA and save it as `contract.docx`. It contains the word `termination` and one existing tracked change. [Download the tracked-changes fixture](/fixtures/tracked-changes.docx): Synthetic NDA with one tracked change · DOCX Note its absolute path, for example `/Users/you/documents/contract.docx`. The prompts below use it. > **Use absolute paths (warning)** > > A relative path resolves against the server process's working directory, which a GUI client like Claude Desktop sets > somewhere you did not choose. `superdoc_open` also opens a blank document when the path does not exist instead of > reporting an error, so a wrong path looks like an empty file rather than a mistake. ## 3. Inspect before editing [#3-inspect-before-editing] Ask the agent to read the document first, using the absolute path: ```text Open /Users/you/documents/contract.docx with SuperDoc and summarize the termination clause. ``` The agent calls `superdoc_open`, which returns a `session_id` that every later call reuses, then reads content through `superdoc_get_content`. Reading first matters: an agent that edits before inspecting is guessing at what the document contains. If the summary comes back empty or describes a document with no termination clause, the path was wrong and the agent is looking at a blank file. ## 4. Propose a tracked edit [#4-propose-a-tracked-edit] Ask for a change that a person should approve: ```text Rewrite the termination clause to allow 30-day notice. Make it a tracked change and save to /Users/you/documents/contract.reviewed.docx so the original stays untouched. ``` Tracked mode records the edit as a suggestion rather than overwriting text, and the separate output path keeps your source file intact. Both instructions are worth making explicit: the agent will otherwise apply a direct edit and save over the original. > **Saving overwrites without asking (warning)** > > `superdoc_save` has no overwrite guard. Whatever path the agent passes gets written, replacing any file already there. > Name an output that does not exist yet, and keep the source somewhere the agent is not writing. ## 5. Verify the output [#5-verify-the-output] Open `contract.reviewed.docx` in the editor below, or in Microsoft Word. The rewrite should appear as a tracked change you can accept or reject, and `contract.docx` should be unchanged. > **Interactive editor: Review the agent output** > > Sample: [open the fixture](/fixtures/tracked-changes.docx). > Preset: `tracked-review`. > Tracked-change review: accept or reject the sample change. > Local DOCX selection: enabled. Files remain in the browser. > **Verification target (success)** > > The output contains the rewritten clause as a pending tracked change. The source file is byte-for-byte unchanged. ## What the server exposes [#what-the-server-exposes] The tool count depends on the preset. Three lifecycle tools are always present; the editing surface comes from the preset. | `MCP_PRESET` | Tools | | ------------------ | ----------------------------------------------------------------- | | `legacy` (default) | 13: 3 lifecycle + 10 grouped intent tools | | `core` | 5: 3 lifecycle + `superdoc_inspect` and `superdoc_perform_action` | This guide uses the server default so the setup above works with no extra configuration. That default is `legacy`, which is why this page shows those tools while [Build an agent](/agents/build/build-an-agent) recommends `core` for SDK integrations. To run the core surface here too, add the environment variable to your client config: ```json { "mcpServers": { "superdoc": { "command": "pnpm", "args": ["dlx", "@superdoc-dev/mcp"], "env": { "MCP_PRESET": "core" } } } } ``` The lifecycle tools are identical either way: | Tool | Purpose | | ---------------- | ------------------------------------------------------------------- | | `superdoc_open` | Open a `.docx` file and return a `session_id` | | `superdoc_save` | Write to the original path, or to `out`. Overwrites without warning | | `superdoc_close` | Close the session and release memory | The default `legacy` preset adds ten grouped intent tools: | Tool | Purpose | | -------------------------------------------- | ---------------------------------------- | | `superdoc_get_content`, `superdoc_search` | Read content and find stable targets | | `superdoc_edit`, `superdoc_format` | Change text and apply formatting | | `superdoc_create`, `superdoc_list` | Add blocks and manipulate lists | | `superdoc_table` | Build and change tables | | `superdoc_comment`, `superdoc_track_changes` | Manage comments and resolve suggestions | | `superdoc_mutations` | Run multi-step edits as one atomic batch | Every tool except `superdoc_open` takes the `session_id`. Unsaved work is lost on close, so the agent must save before closing. ## Where to go next [#where-to-go-next] Building the agent yourself, rather than connecting an existing one, means embedding the tool loop in your product. [Build an agent](/agents/build/build-an-agent) covers that path with the SDK toolkit. Before putting an agent near documents that matter, read [Safety](/agents/operate/safety). --- # Create and resolve comment threads > Anchor a DOCX comment to document content, add a reply, and resolve the thread. Comments are document content. Use the Document API when code needs to create, inspect, update, or delete a thread without driving the built-in comments interface. This guide creates one anchored thread, adds a reply, and resolves it. The operations work against an open document in the Editor and through supported headless clients. ## Run the complete workflow [#run-the-complete-workflow] Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, then run this browser example: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: async ({ superdoc }) => { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); const match = await doc.query.match({ select: { type: 'text', pattern: 'Confidential Information', }, require: 'exactlyOne', }); const clause = match.items[0]; if (!clause || clause.matchKind !== 'text') { throw new Error('The clause was not found.'); } const createReceipt = await doc.comments.create( { target: clause.target, text: 'Confirm that this definition matches the current policy.', }, { expectedRevision: match.evaluatedRevision }, ); if (!createReceipt.success) { throw new Error(`Comment creation failed: ${createReceipt.failure.message}`); } const afterCreate = await doc.comments.list({ includeResolved: true }); const replyReceipt = await doc.comments.create( { parentCommentId: createReceipt.id, text: 'Confirmed against the policy dated July 2026.', }, { expectedRevision: afterCreate.evaluatedRevision }, ); if (!replyReceipt.success) { throw new Error(`Reply failed: ${replyReceipt.failure.message}`); } const afterReply = await doc.comments.list({ includeResolved: true }); const resolveReceipt = await doc.comments.patch( { commentId: createReceipt.id, status: 'resolved', }, { expectedRevision: afterReply.evaluatedRevision }, ); if (!resolveReceipt.success) { throw new Error(`Resolve failed: ${resolveReceipt.failure.message}`); } console.log('Resolved comment:', createReceipt.id); }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` The example deliberately re-lists comments between mutations. Each mutation advances the document revision, so the next operation uses a current `expectedRevision`. ## Anchor the root comment [#anchor-the-root-comment] A root comment needs text and a document target. A text query returns a `SelectionTarget` that can be passed directly to `comments.create()`. Do not derive the target from the rendered DOM or from `highlightRange`. DOM positions belong to layout. `highlightRange` describes the displayed snippet. The query target addresses document content. On success, the creation receipt includes the new thread ID. Keep that ID for replies and later lifecycle changes. ## Add replies without another target [#add-replies-without-another-target] A reply belongs to an existing thread. Pass `parentCommentId` and text. Do not pass the root target again. The reply is another document mutation. Inspect its receipt before resolving the thread or saving the file. ## Resolve or reopen the thread [#resolve-or-reopen-the-thread] `comments.patch()` changes exactly one comment field per call. Set `status: 'resolved'` to resolve the root thread. Set `status: 'active'` in a later call to reopen it. List with `includeResolved: true` when the application needs to show both states. A resolved thread remains in the DOCX unless it is explicitly deleted. Use [comments in the built-in UI](/editor/built-in-ui/comments) for the standard human workflow. Use [a custom comments UI](/editor/custom-ui/comments) when your application owns the thread list and navigation. --- # Document API mental model > Query a document, identify a target, apply a mutation, and inspect its receipt. The Document API is the operation contract for reading and changing a SuperDoc document. Browser and headless hosts expose the same operation names and data shapes. Most workflows follow four steps: 1. Query the document. 2. Keep the returned address or target. 3. Apply a mutation to that target. 4. Inspect the mutation receipt. > **Diagram:** A document operation moves from a query to a stable target, then through a mutation that returns a receipt. ## 1. Query [#1-query] Use `doc.query.match(...)` to find content by meaning or structure. A query returns document-native references for the next step. Follow [Query document content](/document-api/query-content) for a complete browser example and the result fields to keep. ### Browser ```ts const match = await editor.doc.query.match({ select: { type: 'text', pattern: 'termination' }, require: 'first', }); const result = match.items[0]; if (!result || result.matchKind !== 'text') { throw new Error('No matching text found.'); } const operation = await editor.doc.replace({ target: result.target, text: 'cancellation', }, { changeMode: 'tracked' }); ``` ### Headless ```ts const match = await doc.query.match({ select: { type: 'text', pattern: 'termination' }, require: 'first', }); const result = match.items[0]; if (!result || result.matchKind !== 'text') { throw new Error('No matching text found.'); } const operation = await doc.replace({ target: result.target, text: 'cancellation', changeMode: 'tracked', }); ``` Do not derive mutation locations from rendered DOM nodes or copied text offsets. The DOM can change when layout changes. A Document API result belongs to the document model. ## 2. Address or target [#2-address-or-target] An address identifies a document location or object. A target describes the content a mutation should affect. Some query results provide a mutation-ready target. Other workflows resolve an address into the target required by the operation. Keep the target returned for the current document revision. If the document changes first, query again instead of assuming that an old target still points to the same content. ## 3. Mutate [#3-mutate] Pass the target to the operation that makes the change. The target makes the scope explicit. It also lets each host apply the same operation contract without inspecting its UI state. Tracked-change review follows this shape. `doc.trackChanges.list()` discovers changes. `doc.trackChanges.get()` reads one change. `doc.trackChanges.decide({ decision, target })` applies a decision to an explicit target. ## 4. Receipt [#4-receipt] A successful mutation returns a receipt that records what the engine applied. Use it to confirm the result and continue from the resolved effects. Treat the receipt as part of the contract. Do not infer success from a repaint, a changed file size, or the absence of an error. **Receipt `replace`**: replacement recorded in tracked mode ## Runtime shape [#runtime-shape] Browser calls are Promise-shaped. Other clients expose their own synchronous or asynchronous form. The operation names, inputs, outputs, targets, errors, and receipt meaning stay the same. This separation is deliberate. The host decides how a call runs. The Document API decides what the call means. ## Use the contract in a workflow [#use-the-contract-in-a-workflow] - [Mount an editor](/editor/quickstart): Open a DOCX, make a tracked edit, and export the result in a browser application. - [Run a headless operation](/agents/automation/node-sdk): Query a DOCX, accept its tracked changes, and save a separate output from Node.js. - [Connect code to human review](/agents/workflows/review-tracked-changes): Create a tracked replacement, review the exact output in the editor, and export the decision. ## Reference [#reference] The [generated Document API reference](/document-api/reference) is derived from the canonical public contract and lists the current operations, inputs, outputs, and failure modes. --- # Preview and apply mutation plans > Validate several document edits, then apply them together against one document revision. Use a mutation plan when several edits belong to one logical change. A plan resolves every target, previews the combined work without changing the document, and applies valid steps as one atomic transaction. For one independent replacement or deletion, prefer the direct operations in [Replace and delete content](/document-api/replace-delete-content). > **Diagram:** Two query references become one atomic plan that is previewed before all steps apply together. ## Run a complete browser example [#run-a-complete-browser-example] Create a browser app with an `#editor` mount element and an explicit container height, as shown in the [Editor quickstart](/editor/quickstart). This example expects the [tracked-changes fixture](/fixtures/tracked-changes.docx) at `/contract.docx`. Its source lives in the docs app and is typechecked against the public v2 browser API: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: async ({ superdoc }) => { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); const [companyResult, liabilityResult] = await Promise.all([ doc.query.match({ select: { type: 'text', pattern: 'Amazing' }, require: 'exactlyOne', }), doc.query.match({ select: { type: 'text', pattern: '$500,000' }, require: 'exactlyOne', }), ]); const company = companyResult.items[0]; const liability = liabilityResult.items[0]; if (!company || company.matchKind !== 'text' || !liability || liability.matchKind !== 'text') { throw new Error('The expected contract text was not found.'); } if (companyResult.evaluatedRevision !== liabilityResult.evaluatedRevision) { throw new Error('The document changed while the plan targets were being collected.'); } const plan = { expectedRevision: companyResult.evaluatedRevision, atomic: true as const, changeMode: 'tracked' as const, steps: [ { id: 'rename-company', op: 'text.rewrite' as const, where: { by: 'ref' as const, ref: company.handle.ref }, args: { replacement: { text: 'Northstar' } }, }, { id: 'lower-liability-cap', op: 'text.rewrite' as const, where: { by: 'ref' as const, ref: liability.handle.ref }, args: { replacement: { text: '$250,000' } }, }, ], }; const preview = await doc.mutations.preview(plan); if (!preview.valid) { throw new Error(preview.failures?.map((failure) => failure.message).join('; ') ?? 'Plan preview failed.'); } const receipt = await doc.mutations.apply(plan); console.log('Applied steps:', receipt.steps); }, }); window.addEventListener('beforeunload', () => { superdoc.destroy(); }); ``` The example queries both targets before applying any change. It also confirms that both matches came from the same document revision before building the plan. ## Build the plan from references [#build-the-plan-from-references] Use `item.handle.ref` for plan steps. Each ref points to content resolved by `query.match()`, and `expectedRevision` binds the plan to the document state that produced those refs. Every plan requires: * `atomic: true` * `changeMode: 'direct'` or `'tracked'` * A stable, unique `id` for every step * A supported step `op` * A `where` target and operation-specific `args` Check `doc.capabilities().planEngine.supportedStepOps` before constructing plans dynamically. The generated reference remains authoritative for each step shape. ## Preview without changing the document [#preview-without-changing-the-document] `doc.mutations.preview(plan)` resolves targets and validates every step without applying document changes. Read: * `valid` before calling `apply()` * `failures` for the step ID, phase, code, and message * `steps` for the targets each step resolved * `evaluatedRevision` for the state used during preview A valid preview is still a snapshot. Another writer can change the document before apply, so keep `expectedRevision` on the plan. ## Apply atomically [#apply-atomically] `doc.mutations.apply(plan)` commits every step together. If compilation, target resolution, an assertion, or revision validation fails, the plan does not partially apply successful steps. The returned plan receipt includes the before/after revision, a result for every step, any tracked-change addresses, and timing metadata. Use step results for verification and diagnostics, not for inventing performance claims. > **Verification target (success)** > > The preview should report `valid: true`. Apply should return two changed steps, and the Editor should show `Northstar > Corp` plus a `$250,000` liability cap as tracked changes. The generated [`mutations.preview` reference](/document-api/reference/mutations/preview) and [`mutations.apply` reference](/document-api/reference/mutations/apply) list supported steps, limits, outputs, and failure codes. --- # Query document content > Find text in an open DOCX and keep mutation-ready targets. Use `doc.query.match()` when code needs to locate document content before reading or changing it. The query inspects the document model and returns explicit targets. It does not change the document. This guide starts with a mounted v2 editor. Complete the [Editor quickstart](/editor/quickstart) first if you do not yet have a working `SuperDoc` instance. ## 1. Query after the editor is ready [#1-query-after-the-editor-is-ready] Get the browser Document API from `superdoc.activeEditor.doc`. Query inside `onReady` so the document is available: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: async ({ superdoc }) => { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); const result = await doc.query.match({ select: { type: 'text', pattern: 'Confidential Information', }, require: 'all', }); console.log(`Found ${result.total} matches.`); for (const item of result.items) { if (item.matchKind !== 'text') continue; console.log(item.snippet, item.target, item.handle.ref); } }, }); window.addEventListener('beforeunload', () => { superdoc.destroy(); }); ``` This source is typechecked against the public v2 browser API. The default text selector performs a case-insensitive literal match. Use `mode: 'regex'` only when a literal pattern cannot express the search. > **Diagram:** Highlighted text in a DOCX becomes query result items with snippets, targets, and references. ## 2. Read the result [#2-read-the-result] The result separates human-readable context from mutation-ready locations: | Field | What it tells you | | --------------------- | ----------------------------------------------------------------- | | `total` | Number of matches before pagination | | `items` | Matches returned on this page | | `evaluatedRevision` | Document revision used to evaluate the query | | `item.snippet` | Matched text with nearby context | | `item.highlightRange` | Location of the match inside the snippet | | `item.target` | Direct target for one operation such as `replace()` or `delete()` | | `item.handle.ref` | Reference for a mutation plan tied to `evaluatedRevision` | Use `item.matchKind` before reading text-only fields. Node queries return a different item shape. ## 3. Choose the expected number of matches [#3-choose-the-expected-number-of-matches] Set `require` according to what makes the operation safe: | Value | Behavior | | -------------- | ------------------------------------------------- | | `'first'` | Return only the first match | | `'exactlyOne'` | Require one match and fail when the count differs | | `'all'` | Return all matches and fail when none exist | | `'any'` | Return all matches, including an empty result | Prefer `'exactlyOne'` when a later mutation must affect one unique clause. Prefer `'all'` when the task intentionally handles every occurrence. ## 4. Keep targets revision-safe [#4-keep-targets-revision-safe] A target or reference belongs to the document revision that produced it. If another operation changes the document first, run the query again before using an earlier target. For one direct operation, keep `item.target`. For a mutation plan, keep `item.handle.ref` together with `result.evaluatedRevision` so the plan can reject stale input instead of editing the wrong content. Pending tracked deletions are excluded from text queries by default. Set `includeDeletedText: true` only when the workflow explicitly needs to inspect deleted text. > **Verification target (success)** > > `result.total` should be greater than zero, each text item should include the search phrase in its snippet, and each > item should provide both a target and a reference. Next, [replace and delete content](/document-api/replace-delete-content) with fresh query targets, or review the [Document API mental model](/document-api/mental-model) for the full operation lifecycle. The [generated `query.match` reference](/document-api/reference/query/match) lists every selector and failure mode. --- # Receipts and errors > Check capabilities, inspect mutation results, and recover safely from stale document state. Treat every Document API mutation result as part of the operation contract. A successful receipt confirms what the engine applied. A failure receipt or rejected call explains why the workflow must stop, re-query, or change its request. ## 1. Check capabilities before acting [#1-check-capabilities-before-acting] Capabilities describe the current document runtime. Check the operation and the requested mutation mode before presenting or running a workflow: ```ts const capabilities = await doc.capabilities(); const replaceCapability = capabilities.operations.replace; if (!replaceCapability.available) { const reasons = replaceCapability.reasons?.join(', ') ?? 'No reason reported'; throw new Error(`Replace is unavailable: ${reasons}`); } if (!replaceCapability.tracked) { throw new Error('This document runtime cannot record replacements as tracked changes.'); } ``` Each operation capability reports `available`, `tracked`, and `dryRun`. Namespace-level flags such as `capabilities.global.trackChanges.enabled` describe broader runtime support. A capability check is a snapshot, not a guarantee. Document state, permissions, or targets can change before the mutation runs, so always inspect the eventual result too. ## 2. Handle both failure paths [#2-handle-both-failure-paths] Mutations can fail in two ways: 1. The call returns a receipt with `success: false` and `failure.code`. 2. The call rejects before applying anything, for example when input validation or a revision guard fails. These errors expose a machine-readable `code` and a message. ```ts function readErrorCode(error: unknown): string | undefined { if (typeof error !== 'object' || error === null || !('code' in error)) return; return typeof error.code === 'string' ? error.code : undefined; } const result = await doc.query.match({ select: { type: 'text', pattern: 'Amazing' }, require: 'exactlyOne', }); const match = result.items[0]; if (!match || match.matchKind !== 'text') { throw new Error('The company name was not found.'); } try { const receipt = await doc.replace( { target: match.target, text: 'Northstar' }, { changeMode: 'tracked', expectedRevision: result.evaluatedRevision }, ); if (!receipt.success) { console.error(receipt.failure?.code, receipt.failure?.message); return; } console.log('Mutation applied:', receipt); } catch (error) { console.error(readErrorCode(error), error); } ``` Do not treat the absence of an exception as success. Read `receipt.success` before saving, exporting, or starting a dependent operation. > **Diagram:** A successful mutation continues, while failures either re-query current state for one retry or stop so the request can change. ## 3. Choose recovery from the code [#3-choose-recovery-from-the-code] Use the code to select a bounded recovery path: | Code family | Meaning | Action | | -------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------- | | `REVISION_MISMATCH`, `STALE_REVISION`, `ADDRESS_STALE`, `TARGET_NOT_FOUND` | The document or target changed | Re-query current state, review the new match, and retry once | | `NO_OP` | The request would not change the document | Stop and verify whether the desired state already exists | | `CAPABILITY_UNAVAILABLE`, `CAPABILITY_UNSUPPORTED` | The current runtime cannot perform the request | Change the mode, operation, or runtime; do not retry unchanged | | `PERMISSION_DENIED` | Document policy prevents the mutation | Keep the document unchanged and resolve authorization or protection first | | `INVALID_INPUT`, `INVALID_TARGET` | The request shape or target is invalid | Fix the request; do not retry the same payload | | `INTERNAL_ERROR` | The runtime could not complete the operation safely | Record the code and context, then stop the workflow | Not every operation can return every code. Use the generated operation reference when implementing operation-specific recovery. ## 4. Retry state drift once [#4-retry-state-drift-once] When a revision or target is stale, run the original query again and review its current result before retrying. Limit automatic retries to one. A repeated state-drift failure usually means another writer is active or the workflow is targeting unstable content. Never remove `expectedRevision` just to make a retry pass. That guard prevents a valid operation from applying to an unintended document state. > **Keep failures observable (warning)** > > Log the operation name, failure code, and a safe correlation identifier. Do not log full document contents, private > clauses, or complete mutation payloads by default. Continue with [Replace and delete content](/document-api/replace-delete-content) for a complete revision-guarded example. The [generated Document API reference](/document-api/reference) lists each operation's receipt and failure codes. --- # Replace and delete content > Use fresh query targets to change an open DOCX and inspect each mutation receipt. Use `replace()` and `delete()` after a query has identified the exact content to change. Both operations accept a target from `query.match()` and return a receipt that says whether the mutation succeeded. This guide uses the [tracked-changes fixture](/fixtures/tracked-changes.docx). Serve it from `/contract.docx` in your app, or update the document URL in the example. ## 1. Run two targeted mutations [#1-run-two-targeted-mutations] Wait for the editor, replace one company name, then run a fresh query before deleting a sentence: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: async ({ superdoc }) => { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); const companyMatch = await doc.query.match({ select: { type: 'text', pattern: 'Amazing' }, require: 'exactlyOne', }); const company = companyMatch.items[0]; if (!company || company.matchKind !== 'text') { throw new Error('The company name was not found.'); } const replaceReceipt = await doc.replace( { target: company.target, text: 'Northstar', }, { changeMode: 'direct', expectedRevision: companyMatch.evaluatedRevision, }, ); if (!replaceReceipt.success) { throw new Error(`Replace failed: ${replaceReceipt.failure?.message ?? 'Unknown error'}`); } console.log('Replace receipt:', replaceReceipt); const liabilityMatch = await doc.query.match({ select: { type: 'text', pattern: 'The total liability under this section shall not exceed $500,000.', }, require: 'exactlyOne', }); const liability = liabilityMatch.items[0]; if (!liability || liability.matchKind !== 'text') { throw new Error('The liability sentence was not found.'); } const deleteReceipt = await doc.delete( { target: liability.target, behavior: 'exact', }, { changeMode: 'direct', expectedRevision: liabilityMatch.evaluatedRevision, }, ); if (!deleteReceipt.success) { throw new Error(`Delete failed: ${deleteReceipt.failure.message}`); } console.log('Delete receipt:', deleteReceipt); }, }); window.addEventListener('beforeunload', () => { superdoc.destroy(); }); ``` The displayed source is typechecked against the public v2 browser API. ## 2. Re-query after a mutation [#2-re-query-after-a-mutation] The replacement advances the document revision. The example runs a new query before deleting instead of reusing an earlier target. `expectedRevision` makes each operation reject stale query input. `changeMode: 'direct'` applies the change immediately. Use `'tracked'` when a change should remain a suggestion for human review. `behavior: 'exact'` removes only the resolved text range. The default `'selection'` behavior may expand to block edges when the query covers an entire boundary block. ## 3. Inspect receipts before continuing [#3-inspect-receipts-before-continuing] Check `success` before saving, exporting, or starting another dependent operation. A failed receipt includes a stable failure code and message. A successful receipt includes the resolved target, and may include revision and effect details depending on the operation. Common failures at this stage mean the target is stale, the text no longer matches, or the current editor mode does not allow the mutation. Re-query current document state before retrying. Do not guess offsets or silently ignore the receipt. > **Verification target (success)** > > The mounted document should show `Northstar Corp` instead of `Amazing Corp`, and the liability sentence should be > absent. Both receipts should report `success: true`. Next, learn how to handle [receipts and errors](/document-api/receipts-and-errors) without unsafe retry loops. For several edits that must succeed together, use a revision-guarded mutation plan instead of chaining independent calls. The generated [replace reference](/document-api/reference/replace) and [delete reference](/document-api/reference/delete) list the full input and receipt shapes. --- # Work with tracked changes > Create reviewable edits, inspect open changes, and accept or reject an explicit change. Tracked changes connect programmatic editing to human review. Operations that report tracked-mode support can request it. The review API then lists, inspects, accepts, or rejects the resulting logical changes. ## Create a reviewable edit [#create-a-reviewable-edit] Pass `changeMode: 'tracked'` as the mutation options argument. There is no separate operation for creating a tracked change: ```ts const result = await doc.query.match({ select: { type: 'text', pattern: 'Amazing' }, require: 'exactlyOne', }); const match = result.items[0]; if (!match || match.matchKind !== 'text') { throw new Error('The company name was not found.'); } const receipt = await doc.replace( { target: match.target, text: 'Northstar' }, { changeMode: 'tracked', expectedRevision: result.evaluatedRevision }, ); if (!receipt.success) { throw new Error(receipt.failure?.message ?? 'The tracked replacement failed.'); } ``` The replacement remains open for review. Saving the DOCX preserves that review state until a person or programmatic workflow accepts or rejects it. ## Review the result in the Editor [#review-the-result-in-the-editor] The Editor owns document rendering, selection, and human review controls. Follow [Review tracked changes](/editor/review/tracked-changes) to open the sample DOCX and accept or reject a proposal visually. The underlying tracked-change operations remain the same in Editor and Headless hosts. ## List and inspect changes [#list-and-inspect-changes] `trackChanges.list()` returns a compact, paginated result. Call `trackChanges.get()` when the workflow needs full before/after details for one logical change: ```ts const changes = await doc.trackChanges.list({ limit: 20, offset: 0 }); const change = changes.items[0]; if (!change) { throw new Error('The document has no open tracked changes.'); } const detail = await doc.trackChanges.get({ id: change.id }); console.log({ id: detail.id, type: detail.type, author: detail.author, before: detail.before, after: detail.after, }); ``` The list searches the document body by default. Pass `in: 'all'` when the workflow must include supported headers, footers, footnotes, and endnotes. ## Accept or reject one change [#accept-or-reject-one-change] Decide against the logical change ID, and guard the decision with the revision returned by the list operation: ```ts const decisionReceipt = await doc.trackChanges.decide( { decision: 'accept', target: { kind: 'id', id: detail.id }, }, { expectedRevision: changes.evaluatedRevision, }, ); if (!decisionReceipt.success) { throw new Error(`${decisionReceipt.failure.code}: ${decisionReceipt.failure.message}`); } console.log('Resolved change:', decisionReceipt.removed); ``` Use `decision: 'reject'` to restore the change's before-state instead. A review decision resolves an existing change, so `changeMode` and `dryRun` do not apply. Re-list changes after each decision. The receipt can invalidate or remap references, and partial range decisions can create successor fragments with new IDs. > **Verification target (success)** > > After the decision succeeds, a fresh `trackChanges.list()` result should no longer contain the resolved logical change > ID. The saved DOCX should preserve the accepted or rejected document state. For a complete code-to-human handoff, follow [Review tracked changes](/agents/workflows/review-tracked-changes). The generated [`trackChanges` reference](/document-api/reference/track-changes) covers range, side, bulk, and story-specific targets. --- # Document modes > Choose whether people can view, edit, or suggest changes to a DOCX. Document modes control how a person can interact with the open DOCX. Choose the mode that matches the task, then change it as the workflow moves from authoring to review. | Mode | What a person can do | Use it for | | ------------ | ------------------------------------------- | ------------------------------- | | `editing` | Change the document directly | Authoring and form-like editing | | `suggesting` | Make edits recorded as tracked changes | Review and approval workflows | | `viewing` | Read and select content without changing it | Previews and read-only review | `editing` is the default. ## Try the modes [#try-the-modes] Switch the same document between View, Edit, and Suggest. Editing changes the DOCX directly, suggesting records new edits for review, and viewing prevents changes. > **Interactive editor: Try document modes** > > Sample: [open the fixture](/fixtures/tracked-changes.docx). > Preset: `document-modes`. > Mode switching: viewing, editing, and suggesting. > Local DOCX selection: disabled. ## Set the initial mode [#set-the-initial-mode] Pass `documentMode` when you create the editor. This example opens the document in suggesting mode so every new edit is reviewable: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/documents/contract.docx', documentMode: 'suggesting', user: { name: 'Jordan Lee', email: 'jordan@example.com', }, }); ``` Provide the current `user` when edits need an author. SuperDoc preserves that author with the tracked changes in the exported DOCX. ## Change modes after the editor is ready [#change-modes-after-the-editor-is-ready] Use `setDocumentMode()` to move the same editor between authoring, review, and read-only states: ```ts const superdoc = new SuperDoc({ selector: '#editor', document: '/documents/contract.docx', onReady: ({ superdoc }) => { superdoc.setDocumentMode('suggesting'); }, }); superdoc.on('document-mode-change', ({ documentMode }) => { console.log(`Document mode: ${documentMode}`); }); ``` `setDocumentMode()` requires a ready editor. Use the initial `documentMode` option when the mode is known before loading, or call the method from `onReady` and later application events. ## Show tracked changes in viewing mode [#show-tracked-changes-in-viewing-mode] Viewing mode is read-only. By default, it shows the document before its tracked changes were applied. Enable tracked-change visibility when a read-only reviewer should still see the proposed edits: ```ts new SuperDoc({ selector: '#editor', document: '/documents/contract.docx', documentMode: 'viewing', modules: { trackChanges: { visible: true, }, }, }); ``` > **Modes are not access control (note)** > > Document modes control editor behavior in the browser. Your application still owns authentication, authorization, and > access to the DOCX file. Next, try the [Editor tracked-change review workflow](/editor/review/tracked-changes), build a [code-to-human review handoff](/agents/workflows/review-tracked-changes), or return to [loading and saving documents](/editor/load-and-save-documents). --- # Editor overview > Embed SuperDoc v2 in a web application and keep DOCX as the document format. Use the Editor when a person needs to read, edit, comment on, or review a DOCX file inside your application. SuperDoc v2 is selected by package version. Install `superdoc@2`. There is no runtime setting that switches an installed editor between v1 and v2. ## Choose how to mount the Editor [#choose-how-to-mount-the-editor] SuperDoc provides two public browser integrations: | Application | Start with | What it owns | | --------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------- | | Vanilla JavaScript or another framework | `SuperDoc` from `superdoc` | Editor creation, configuration, lifecycle, and export | | React | `SuperDocEditor` from `@superdoc-dev/react` | React mounting, updates, cleanup, and access to the SuperDoc instance | Both integrations run the same v2 editor. Choose the one that fits your application rather than wrapping one integration in the other. After mounting the Editor, decide who owns the controls around the document. [Choose your editor interface](/editor/ui/choose-an-interface) compares the built-in UI, a configured built-in UI, and application-owned controls. ## What v2 changes [#what-v2-changes] The v2 engine edits the DOCX package directly. It does not convert the document to HTML and back. For a browser integration: * Wait for editor readiness before calling methods that depend on the open document. * Use document modes to control whether a person views, edits, or suggests changes. * Use the Document API when application code needs to inspect or change document content. * Export or save through the `SuperDoc` instance. ## The editor lifecycle [#the-editor-lifecycle] A browser integration follows five steps: 1. Install the public package and its styles. 2. Give SuperDoc a DOCX file and a mount element. 3. Wait for `onReady` before changing modes or using document-dependent methods. 4. Let the person work in the editor or call the Document API against the open document. 5. Export the result and call `destroy()` when the editor is no longer needed. The [Editor quickstart](/editor/quickstart) implements this lifecycle with a real DOCX file and a direct edit. ## Where the Document API fits [#where-the-document-api-fits] The Editor is a browser host for the document engine. The Document API is the shared contract for querying content, identifying targets, applying mutations, and inspecting receipts. Use editor controls for direct human interaction. Use the Document API when application code needs to make an explicit document change. Both operate on the same open document state. Start with the [Document API mental model](/document-api/mental-model) when application code needs to edit the document. ## What to build next [#what-to-build-next] * [Mount the editor and export an edit](/editor/quickstart). * [Choose the built-in UI or custom controls](/editor/ui/choose-an-interface). * [Understand how SuperDoc preserves DOCX meaning](/start/how-superdoc-works). * [Learn the Document API operation lifecycle](/document-api/mental-model). --- # Load and save documents > Open a DOCX in the browser, download the edited file, or send its bytes to your backend. Load a DOCX through the public `SuperDoc` configuration, wait for the editor to be ready, and export the edited document through the same instance. Complete the [Editor quickstart](/editor/quickstart) first if you do not already have a mounted editor. ## Choose a document input [#choose-a-document-input] The `document` option accepts the browser inputs most applications already use: | Input | Use it when | | ---------- | ----------------------------------------------------------------------------- | | `File` | A person selected a local DOCX with a file input or drag and drop | | `Blob` | Your application already downloaded or generated the DOCX bytes | | URL string | The browser can fetch the DOCX from your application or a CORS-enabled origin | SuperDoc reads local `File` and `Blob` inputs in the browser. It does not upload them to a SuperDoc service. ## Load a selected DOCX [#load-a-selected-docx] Mount the editor after a person selects a file. Enable document-dependent actions only after `onReady` runs: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const input = document.querySelector('#docx-file'); const saveButton = document.querySelector('#save-docx'); if (!input || !saveButton) { throw new Error('Document controls are missing.'); } let superdoc: SuperDoc | null = null; input.addEventListener('change', () => { const file = input.files?.[0]; if (!file) return; superdoc?.destroy(); saveButton.disabled = true; superdoc = new SuperDoc({ selector: '#editor', document: file, onReady: () => { saveButton.disabled = false; }, }); }); ``` Use the same `document` option for a URL or an existing `Blob`: ```ts new SuperDoc({ selector: '#editor', document: '/documents/contract.docx', onReady: ({ superdoc }) => { console.log('Ready to edit', superdoc); }, }); ``` A URL is fetched by the reader's browser. The server must allow that request and return the DOCX bytes. ## Download the edited DOCX [#download-the-edited-docx] `export()` creates a DOCX download by default. Set `exportedName` without the extension: ```ts saveButton.addEventListener('click', async () => { if (!superdoc) return; try { saveButton.disabled = true; await superdoc.export({ exportType: ['docx'], exportedName: 'contract-reviewed', }); } finally { saveButton.disabled = false; } }); ``` This downloads `contract-reviewed.docx`. The export keeps comments and tracked changes unless you explicitly choose a different export policy. ## Send the DOCX to your backend [#send-the-docx-to-your-backend] Set `triggerDownload: false` when your application needs the exported bytes instead of an automatic download: ```ts const result = await superdoc.export({ exportType: ['docx'], triggerDownload: false, }); if (!(result instanceof Blob)) { throw new Error('SuperDoc did not return a DOCX blob.'); } const response = await fetch('/api/documents/contract', { method: 'PUT', headers: { 'content-type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }, body: result, }); if (!response.ok) { throw new Error(`Saving the document failed with ${response.status}.`); } ``` Your application decides where the file is stored and how the request is authenticated. SuperDoc produces the DOCX bytes but does not provide document storage. ## Verify the round trip [#verify-the-round-trip] Open the exported DOCX in Microsoft Word or another DOCX reader. Confirm that the edited content, surrounding formatting, comments, and tracked changes match the state shown in SuperDoc. Next, choose how people can interact with the document in [Document modes](/editor/document-modes), or use the [Document API mental model](/document-api/mental-model) to understand how application code changes it. --- # Editor quickstart > Open a real DOCX in the browser, edit it, and export the result. Use the browser editor when a person needs to read or change a DOCX file inside your application. By the end you will have opened a real DOCX, made an edit, exported it, and reopened the result in Word or SuperDoc. This guide uses a sample non-disclosure agreement, a small synthetic document with headings, body text, and a bulleted list so you can see how faithfully formatting survives the round trip. Any DOCX works. [Download the sample document](/fixtures/sample-nda.docx): Synthetic agreement with headings, paragraphs, and a list · DOCX Want to try SuperDoc first? Open the live [document modes demo](/editor/document-modes) in your browser. ## 1. Create a project and install the editor [#1-create-a-project-and-install-the-editor] Start from any bundler-based setup. A minimal one: ```bash pnpm create vite@latest superdoc-quickstart --template vanilla-ts cd superdoc-quickstart pnpm add superdoc@2 ``` SuperDoc v2 is the stable major release. For production, pin the exact version your lockfile resolved so upgrades stay deliberate. ## 2. Add the sample document [#2-add-the-sample-document] Save the sample above as `public/sample.docx`. Vite serves files in `public` from the root of the development server, so the editor can load it from `/sample.docx`. ## 3. Add the editor surface [#3-add-the-editor-surface] Replace the body of `index.html` with an export button and a mount point. Keep the `` tag, which loads your code: ```html
``` The editor grows to the height of the document it opens, and the page scrolls. You do not need to size the container. To keep the editor inside a fixed-height box and scroll the document internally instead, see [choosing a layout](#choose-a-layout) below. ## 4. Mount the document and wire up export [#4-mount-the-document-and-wire-up-export] Replace the contents of `src/main.ts`: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const exportButton = document.querySelector('#export-docx'); if (!exportButton) { throw new Error('The export button is missing.'); } const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', onReady: () => { exportButton.disabled = false; }, onException: ({ error }) => { console.error('SuperDoc could not open the document.', error); }, }); exportButton.addEventListener('click', async () => { exportButton.disabled = true; try { await superdoc.export({ exportType: ['docx'], exportedName: 'sample-edited', }); } catch (error) { console.error('SuperDoc could not export the document.', error); } finally { exportButton.disabled = false; } }); ``` The editor opens in editing mode by default, so edits apply directly to the document. `onReady` fires once the document is open. Wait for it before enabling anything that depends on the open document, which is why the export button starts disabled. `onException` reports failures during loading, including a missing file, a blocked cross-origin request, and a file whose bytes cannot be read as a DOCX. Without it, those failures look like an editor that never appears. ## 5. Make an edit and export [#5-make-an-edit-and-export] Run `pnpm dev` and open the printed URL. You should see the document with its heading, paragraphs, and list, and the export button should become enabled. Replace a word in the document, then click the button to download `sample-edited.docx`. The handler disables the button while the export runs and re-enables it afterwards, so a slow export cannot be started twice, and a failed one reports itself instead of surfacing as an unhandled rejection. > **Verification target (success)** > > Reopen the exported file in Word or SuperDoc. Your edit should be present, and the heading, paragraph, and list > formatting should be unchanged. ## Choose a layout [#choose-a-layout] SuperDoc supports two layouts, and the difference is where scrolling happens: * **Natural height**, the default used above. The editor grows to the full height of the document and the page scrolls. The container needs no height. * **Contained**, for embedding the editor in a fixed-height panel. Pass `contained: true` and give the container a definite height. The document then scrolls inside it: ```ts const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', contained: true, }); ``` ```css #editor { height: 400px; } ``` Setting a height without `contained: true` does not constrain the document or scroll it inside the box. The editor still grows to its full height and the page scrolls. ## Clean up [#clean-up] Call `destroy()` when your application removes or replaces the editor, such as when a route unmounts or a component tears down: ```ts superdoc.destroy(); ``` It unmounts the editor and releases its listeners and document resources. A page that keeps one editor for its lifetime does not need to call it. ## Continue building [#continue-building] * [Load and save documents](/editor/load-and-save-documents) covers letting people choose their own file, loading from a URL or `Blob`, and saving to a backend. * [Document modes](/editor/document-modes) explains editing, suggesting, and viewing, and how to record edits as tracked changes. * [Choose an interface](/editor/ui/choose-an-interface) compares the built-in toolbar with application-owned controls. --- # Choose your path > Start with the visual Editor or a DOCX automation workflow. Choose where the work runs. The Editor adds a visual browser interface. The SDK runs document operations from code. Both use the same document engine and Document API contract. - [Add a DOCX editor](/editor/quickstart): Let people open, edit, review, and export DOCX files in your web application. - [Automate DOCX files](/agents/automation/node-sdk): Inspect, change, and save DOCX files from application code. --- # Features and surfaces > Understand what belongs to the Editor, Headless workflows, Document API, and agent integrations. SuperDoc has one DOCX engine with several public ways to use it. Choose a surface for the caller, then use the Document API when code needs to inspect or change document content. > **Diagram:** People, services, CI, and agents use different SuperDoc surfaces that share the Document API and DOCX engine. ## Editor-specific features [#editor-specific-features] The Editor is the browser surface for a person working with a document. It owns behavior that depends on a visible interface: * Rendering paginated DOCX content in a web application * Keyboard, pointer, and text input * Viewing, editing, and suggesting modes * Selection, focus, viewport, zoom, and navigation * Built-in toolbar, comments, links, context menu, and review UI * Custom UI built with `superdoc/ui` or `superdoc/ui/react` * Browser file selection, export, fullscreen, and responsive layout * Visual collaboration presence and review interactions Use `SuperDoc` from `superdoc` for a framework-neutral integration. Use `SuperDocEditor` from `@superdoc-dev/react` in React applications. ## Headless-specific features [#headless-specific-features] Headless workflows run without mounting the Editor. They own operational concerns for code, scripts, and CI: * Opening, saving, and closing document sessions from Node.js * Processing files in backend jobs and pipelines * Running document operations from the command line * Handling batches, output paths, process failures, and retries * Comparing files or producing a DOCX for later human review * Managing runtime installation and deployment constraints Use `@superdoc-dev/sdk` from Node.js or `superdoc-sdk` from Python. Use `@superdoc-dev/cli` for shell and CI workflows. Headless code does not have a toolbar, viewport, DOM selection, or visual review surface. ## Shared Document API features [#shared-document-api-features] The Document API is an operation contract, not another runtime. The browser Editor and supported headless clients expose the same operation names and data shapes. The contract includes these feature families: | Feature family | Examples | | ------------------- | ------------------------------------------------------------------------------------------------------------ | | Read and discover | Text and node queries, extraction, document info, Markdown and HTML views | | Edit content | Insert, replace, delete, create blocks, and clear content | | Review | Comments, tracked changes, history, and document diffing | | Format | Inline formatting, paragraph formatting, styles, lists, and tables | | Page structure | Sections, columns, page setup, headers, footers, and page numbering | | Media | Images, positioning, wrapping, captions, and alternative text | | Word structures | Content controls, bookmarks, footnotes, fields, citations, cross-references, indexes, and tables of contents | | Governance and data | Protection, permission ranges, custom XML, and anchored metadata | | File output | DOCX export and template application | Support can vary by runtime, document state, mutation mode, and feature. Check `doc.capabilities()` before presenting an operation, then inspect the returned receipt or error. The generated Document API reference remains the exhaustive list of operations and fields. Guides in this site explain how to combine those operations into reliable workflows. ## Agent-specific features [#agent-specific-features] Agents use public SuperDoc surfaces rather than a separate agent-only document engine: * `@superdoc-dev/mcp` connects compatible coding agents to document tools. * `createAgentToolkit()` from `@superdoc-dev/sdk` provides matching tool definitions, system prompts, and dispatch for product integrations. * The same SDK runs document work without a visible editor. * The Editor gives a person a place to review tracked output. An agent workflow should still query current document state, target explicit content, inspect receipts, and preserve a human-review path for consequential changes. ## Choose by responsibility [#choose-by-responsibility] | Need | Start with | | ------------------------------------------------ | -------------------------------------------------------- | | A person edits or reviews a DOCX in your product | [Editor quickstart](/editor/quickstart) | | Backend code changes DOCX files | [Node.js SDK](/agents/automation/node-sdk) | | Code needs reliable document reads and mutations | [Document API mental model](/document-api/mental-model) | | Code must handle failures safely | [Receipts and errors](/document-api/receipts-and-errors) | Do not choose a surface by package count. Choose it by who or what is driving the document, then add only the capabilities that workflow needs. --- # How SuperDoc works > Follow a DOCX file from OOXML package to editable document state and back. A DOCX file is a package of OOXML parts. Those parts hold document content, styles, relationships, media, headers, footers, and other document data. SuperDoc opens that package directly. It does not turn the file into HTML before editing. ## Open [#open] The engine parses the OOXML parts and owns the editable document state. Source details that are not currently being edited stay attached to the document rather than being flattened into a web format. ## Render [#render] The document state is projected into layout data. The layout engine paginates that data. The browser painter then draws the resolved pages as DOM. The DOM is an output of the rendering pipeline. It is not the document format. ## Edit [#edit] Editor input and headless operations change the same document state. The Document API defines how callers query content, identify targets, apply mutations, and read receipts. ## Write [#write] When the document is saved or exported, the engine writes the changes back into the OOXML package. It does not reconstruct a DOCX from an HTML copy. This is the no-conversion boundary: OOXML comes in, OOXML remains the source of document meaning, and OOXML goes out. Layout and DOM exist to display the document. They do not replace it. Next, [choose your path](/start/choose-your-path). --- # What SuperDoc does > Edit DOCX files in a browser or automate them with the same document engine. SuperDoc reads, renders, edits, and writes DOCX files. It works with the OOXML inside the file instead of converting the document to HTML and back. ## One engine, in the browser or on the server [#one-engine-in-the-browser-or-on-the-server] Interact with a DOCX through the same document engine and Document API whether your code runs in the browser or on the server. Queries, targets, mutations, and receipts work the same way in both environments. In the browser, the Editor adds a visual surface for people to render and edit the document inside your application. On the server, the SDK runs document workflows without the Editor UI. Use it when application code, pipelines, or agents need to inspect or change a DOCX file. > **Preview:** Static document preview with a selection and tracked changes > > Selection highlight: enabled. > Tracked changes: shown. > **Presentational preview (info)** > > This frame shows the document surface used throughout these guides. It does not load a live SuperDoc editor. ## Licensing [#licensing] SuperDoc is dual-licensed under AGPLv3 and a commercial license for proprietary deployments and support. [See pricing and support](https://superdoc.dev/#pricing). ## Where to go next [#where-to-go-next] * [See how the engine works](/start/how-superdoc-works) * [Choose the Editor or automation path](/start/choose-your-path) * [Learn the Document API mental model](/document-api/mental-model) --- # Automate a DOCX with Node.js > Query a DOCX, accept its tracked changes, and save the result from Node.js. Use the SDK when application code knows the operation it needs. No model is involved: the script queries, mutates, and saves deterministically. ## 1. Install the Node.js SDK [#1-install-the-nodejs-sdk] ```bash pnpm add @superdoc-dev/sdk ``` The SDK embeds the SuperDoc runtime. You do not need a separate CLI installation to use `SuperDocClient`. ## 2. Prepare a document [#2-prepare-a-document] Download the [tracked-changes fixture](/fixtures/tracked-changes.docx). Save it as `contract.docx` in your working directory. It contains the word `termination` and one tracked change. Any document meeting those two conditions also works. [Download the tracked-changes fixture](/fixtures/tracked-changes.docx): Synthetic NDA with one tracked change · DOCX > **Diagram:** A DOCX file enters a headless SuperDoc workflow, the SDK queries and accepts its tracked changes, and a separate DOCX file is saved. > **Keep the source file (warning)** > > Write the first result to a separate path so you can compare the output with the original document. ## 3. Query, mutate, and save [#3-query-mutate-and-save] Create `accept-changes.mjs`: ```mjs import { SuperDocClient } from '@superdoc-dev/sdk'; /** @param {unknown} value */ function isSuccessfulReceipt(value) { return typeof value === 'object' && value !== null && 'success' in value && value.success === true; } const client = new SuperDocClient(); try { await client.connect(); const doc = await client.open({ doc: './contract.docx' }); try { const match = await doc.query.match({ select: { type: 'text', pattern: 'termination' }, require: 'first', }); console.log('Matched:', match.items[0]); const receipt = await doc.trackChanges.decide({ decision: 'accept', target: { kind: 'all' }, }); console.log('Mutation receipt:', receipt); if (!isSuccessfulReceipt(receipt)) throw new Error('Accepting tracked changes failed.'); await doc.save({ out: './contract.accepted.docx', force: true, }); } finally { await doc.close({ discard: true }); } } finally { await client.dispose(); } ``` Run it with Node.js: ```bash node accept-changes.mjs ``` ## 4. Verify the output [#4-verify-the-output] Open `contract.accepted.docx` in Word or the SuperDoc editor. The tracked changes should be accepted. The original `contract.docx` remains unchanged. Saving to a separate path leaves the source session dirty by design. After the output succeeds, `close({ discard: true })` closes that in-memory session without overwriting the source file. > **Verification target (success)** > > The output should contain the same document content and formatting, with every tracked change accepted. The source > file should remain unchanged. For the contract behind each step, read the [Document API mental model](/document-api/mental-model). Prefer Python? Follow the [Python SDK guide](/agents/automation/python-sdk) for the same deterministic workflow. --- # Automate a DOCX with Python > Accept tracked changes and save a separate DOCX from Python. Use the Python SDK when application code already knows which document operation to run. This guide accepts every tracked change in a DOCX and writes the result to a separate file. ## 1. Install the Python SDK [#1-install-the-python-sdk] SuperDoc supports Python 3.9 and newer: ```bash python -m pip install superdoc-sdk ``` The package installs the matching SuperDoc CLI companion automatically. You do not need Node.js or a separate CLI installation. Published companions support macOS on Apple Silicon and Intel, Linux on ARM64 and x64, and Windows on x64. ## 2. Prepare a document [#2-prepare-a-document] Download the [tracked-changes fixture](/fixtures/tracked-changes.docx) and save it as `contract.docx` in your working directory. It contains one tracked change. [Download the tracked-changes fixture](/fixtures/tracked-changes.docx): Synthetic NDA with one tracked change · DOCX > **Keep the source file (warning)** > > Write the first result to a separate path so you can compare the output with the original document. ## 3. Accept the changes and save [#3-accept-the-changes-and-save] Create `accept_changes.py`: ```py from superdoc import SuperDocClient def is_successful_receipt(value: object) -> bool: return isinstance(value, dict) and value.get("success") is True with SuperDocClient() as client: document = client.open({"doc": "./contract.docx"}) try: receipt = document.track_changes.decide( { "decision": "accept", "target": {"kind": "all"}, } ) if not is_successful_receipt(receipt): raise RuntimeError("Accepting tracked changes failed.") document.save( { "out": "./contract.accepted.docx", "force": True, } ) finally: document.close({"discard": True}) ``` Run it from the directory containing `contract.docx`: ```bash python accept_changes.py ``` `SuperDocClient` owns one persistent host process. The context manager starts it on entry and disposes it on exit. The document still has its own lifecycle, so the `finally` block closes the session even when a mutation or save fails. ## 4. Verify the output [#4-verify-the-output] Open `contract.accepted.docx` in Microsoft Word or the SuperDoc editor. Every tracked change should be accepted. The original `contract.docx` should remain unchanged. > **Verification target (success)** > > The output contains the same document content and formatting with no pending tracked changes. The source file remains > unchanged. ## Use the asynchronous client [#use-the-asynchronous-client] Use `AsyncSuperDocClient` when the surrounding application already runs an event loop. Its document methods mirror the synchronous client and are awaited: ```python import asyncio from superdoc import AsyncSuperDocClient async def main(): async with AsyncSuperDocClient() as client: document = await client.open({"doc": "./contract.docx"}) try: info = await document.info({}) print(info["counts"]) finally: await document.close({"discard": True}) asyncio.run(main()) ``` Use the [Node.js SDK](/agents/automation/node-sdk) for the same deterministic workflow from JavaScript or TypeScript. For the operation and receipt model shared by both SDKs, continue with the [Document API mental model](/document-api/mental-model). --- # Build an agent > Give your product's agent document tools with a bounded, verifiable loop. Use the SDK toolkit when the agent lives inside your product rather than in a coding tool. The toolkit returns the three pieces an agent loop needs, already agreeing with each other: tool definitions in your provider's shape, the matching system prompt, and a dispatcher bound to the same surface. ## Get the toolkit [#get-the-toolkit] ```bash pnpm add @superdoc-dev/sdk ``` One call produces all three pieces: ```ts import { createAgentToolkit } from '@superdoc-dev/sdk'; const { tools, systemPrompt, dispatch } = await createAgentToolkit({ provider: 'openai', preset: 'core', }); ``` > **Always pass a preset (warning)** > > `preset: 'core'` is the surface to build on. Omitting `preset` selects the older `legacy` tool surface for backward > compatibility, which advertises a different set of tools. `provider` accepts `openai`, `anthropic`, `vercel`, or `generic`. It changes the wire shape of the tool definitions, not the operations available. Assembling the three pieces separately is the most common integration mistake. A prompt that documents a tool the model cannot call teaches it to call that tool, and the failure looks like a model problem rather than a configuration one. The toolkit makes the pieces disagree impossible. ## The loop [#the-loop] An agent loop is four steps repeated under a turn limit: ask the model, dispatch any tool calls, feed each receipt back, and stop when the model stops calling tools. ```mjs import { resolve } from 'node:path'; import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk'; const MAX_TURNS = 16; // The tools this loop will dispatch. The core preset's dispatcher also accepts // names it never advertises (`superdoc_execute_code`, `agent_apply`, // `agent_verify`, `agent_operation`), which skip the tracked-mode guard below // and return shapes this loop does not understand. A model that hallucinates // one, or that is talked into it by content inside the document, must not reach // them — so dispatch only what was advertised. const ADVERTISED_TOOLS = new Set(['superdoc_inspect', 'superdoc_perform_action']); // Tools that change the document. `superdoc_inspect` is read-only, so it is // deliberately absent: it neither needs a change mode nor counts as a mutation. const MUTATING_TOOLS = new Set(['superdoc_perform_action']); // `superdoc_perform_action` advertises `changeMode` once for all forty actions, // but only some of them honor it — the rest ignore the argument and edit // directly. Passing `changeMode: 'tracked'` to one of those looks compliant and // silently produces an untracked edit, so this workflow allows only the actions // that actually record a suggestion. // // `move_range` declares `changeMode` but is direct-only today: its own action // hint says tracked mode fails without mutating, because a block-range deletion // cannot be tracked. Allowing it would guarantee a failed run, so it is out. const TRACKED_CAPABLE_ACTIONS = new Set([ 'add_list_items', 'append_list', 'apply_letter_spacing', 'attach_numbering', 'convert_list', 'create_table', 'delete_table', 'delete_table_column', 'delete_table_row', 'delete_text', 'fill_placeholders', 'format_paragraph', 'format_text', 'insert_heading', 'insert_paragraphs', 'insert_table_column', 'insert_table_row', 'insert_toc', 'move_text', 'normalize_body_font_size', 'replace_text', 'rewrite_block', 'set_font_family', 'split_table', ]); /** * One model turn, in OpenAI's Chat Completions shape. * * Return the assistant message unchanged from * `openai.chat.completions.create({ model, messages, tools })`. The loop reads * and writes that same shape, so nothing has to be translated in either * direction. For a provider with a different wire format, adapt it here and * where tool results are appended below. * * @typedef {{ id: string, type: 'function', function: { name: string, arguments: string } }} ToolCall * @typedef {{ role?: string, content?: string | null, tool_calls?: ToolCall[] }} AssistantMessage * @typedef {(input: { messages: unknown[], tools: unknown[] }) => Promise} CallModel */ /** * Receipts carry action-specific evidence, so narrow the fields this loop reads. * * @param {unknown} value * @returns {{ * status?: string, * verificationPassed?: boolean, * preSnapshot?: { revision?: string }, * postSnapshot?: { revision?: string }, * }} */ function asReceipt(value) { return typeof value === 'object' && value !== null ? value : {}; } /** * Whether a receipt is evidence that the document actually changed. * * Some actions accept `dryRun: true` and report success while explicitly * applying nothing, so a successful receipt is not proof of a mutation. When * the action reports both revisions, require them to differ; fall back to the * argument only when the receipt does not say. * * @param {ReturnType} receipt * @param {Record} args */ function changedTheDocument(receipt, args) { const before = receipt.preSnapshot?.revision; const after = receipt.postSnapshot?.revision; if (before != null && after != null) return before !== after; return args.dryRun !== true; } /** * Edit a DOCX from a natural-language instruction, then save to a new file. * * Produces a tracked, reviewable draft. It does not prove the model completed * every part of the instruction — see the guide for why that needs an explicit * plan rather than receipt inspection. * * @param {{ * input: string, * output: string, * instruction: string, * callModel: CallModel, * author: { name: string, email?: string }, * }} options `author` names this integration in tracked changes. Give each * deployment its own, so a reviewer can tell them apart. */ export async function runAgent({ input, output, instruction, callModel, author }) { // Without a user, tracked changes are attributed to a generic "CLI" author // shared by every unattributed workflow. Naming the agent is what makes its // suggestions distinguishable, so the identity is a parameter rather than a // constant: copying this file should not copy someone else's author. const client = createSuperDocClient({ user: author }); /** @type {Awaited> | undefined} */ let doc; try { await client.connect(); doc = await client.open({ doc: input }); // One call keeps tools, prompt, and dispatch on the same preset. Assembling // them separately is how a tool surface and a system prompt drift apart. const { tools, systemPrompt, dispatch } = await createAgentToolkit({ provider: 'openai', preset: 'core', }); /** @type {unknown[]} */ const messages = [ { role: 'system', content: systemPrompt }, { role: 'user', content: `${instruction}\n\nMake every edit a tracked change so a reviewer can accept or reject it.`, }, ]; // A dispatched failure is terminal for the save: an edit that half-applied // leaves the document in a state no later receipt can prove was repaired. /** @type {string[]} */ const failures = []; let completed = false; let mutations = 0; // Bounded: a model that keeps calling tools must still terminate. for (let turn = 0; turn < MAX_TURNS; turn += 1) { const reply = await callModel({ messages, tools }); messages.push(reply); // No tool calls means the model considers the work finished. if (!reply.tool_calls?.length) { console.log(reply.content ?? '(no final message)'); completed = true; break; } for (const call of reply.tool_calls) { /** @type {unknown} */ let receipt; let label = call.function.name; let dispatched = false; try { const args = JSON.parse(call.function.arguments); if (typeof args.action === 'string') label = args.action; // Dispatch only what the toolkit advertised. The dispatcher itself is // more permissive, so this is the boundary that keeps a hallucinated // or injected tool name from reaching the document. if (!ADVERTISED_TOOLS.has(call.function.name)) { receipt = { status: 'failed', error: { code: 'TOOL_NOT_ADVERTISED', message: `"${call.function.name}" is not an available tool. Use one of: ${[...ADVERTISED_TOOLS].join(', ')}.`, }, }; console.error(`${call.function.name}: rejected, not an advertised tool`); // Unlike a correctable argument error, this is a request for // something that does not exist. Treat it as unfinished work so a // run cannot end on it and still save. failures.push(`${call.function.name} is not an available tool`); messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(receipt) }); continue; } // The instruction asks for tracked changes, but `changeMode` is // optional and defaults to a direct edit, so a model that omits it // silently rewrites the document. Refuse the call instead of letting // the run produce an untracked edit it promised would be reviewable. if (MUTATING_TOOLS.has(call.function.name)) { const refusal = args.changeMode !== 'tracked' ? 'Every edit must set changeMode: "tracked". Retry this action with that argument.' : !TRACKED_CAPABLE_ACTIONS.has(args.action) ? `The action "${args.action}" ignores changeMode and always edits directly. Use a tracked-capable action instead.` : undefined; if (refusal) { // The call never reached the document, so this is a correction // the model can act on rather than a failed edit. receipt = { status: 'failed', error: { code: 'CHANGE_MODE_REQUIRED', message: refusal } }; console.error(`${label}: rejected, ${refusal}`); messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(receipt) }); continue; } } dispatched = true; receipt = await dispatch(doc, call.function.name, args); const parsed = asReceipt(receipt); const { status, verificationPassed } = parsed; console.log(`${label}: ${status ?? 'ok'}`); // A returned receipt is not a successful one. `partial` means some // edits applied and some did not, which must never read as success. if (status != null && status !== 'ok') { failures.push(`${label} reported ${status}`); } else if (verificationPassed === false) { console.warn(' verification did not pass'); failures.push(`${label} failed verification`); } else if (call.function.name !== 'superdoc_inspect' && changedTheDocument(parsed, args)) { // superdoc_inspect reads without changing anything, and a dry run // reports success while applying nothing, so neither is evidence // that the edit happened. mutations += 1; } } catch (error) { // Hand the failure back to the model rather than throwing: a bad // argument is something it can correct on the next turn. const { code, message } = /** @type {{ code?: string, message: string }} */ (error); receipt = { status: 'failed', error: { code, message } }; console.error(`${label}: ${code ?? message}`); // A throw from dispatch may have applied part of the edit before // failing. A throw before it (malformed arguments) never reached the // document, so the model can still correct that one. if (dispatched) failures.push(`${label} threw ${code ?? message}`); } messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(receipt) }); } } // Only save work that finished cleanly. Running out of turns means the // model never signalled completion, a dispatched failure means the document // is in a state nobody asked for, and zero mutations means there is nothing // to write — saving any of them produces a plausible-looking file that no // receipt ever justified. if (!completed) { throw new Error(`Agent stopped after ${MAX_TURNS} turns without completing. Nothing was saved.`); } if (failures.length > 0) { throw new Error(`Agent finished with unresolved failures: ${failures.join('; ')}. Nothing was saved.`); } if (mutations === 0) { throw new Error('Agent completed without applying any change. Nothing was saved.'); } // Write to a separate path so the source survives a bad run. No `force`: // save refuses an existing output rather than overwriting it, which turns a // path typo into an error instead of destroying whatever was already there. if (resolve(output) === resolve(input)) { throw new Error(`Output path is the same file as the input (${input}). Nothing was saved.`); } await doc.save({ out: output }); console.log(`Saved: ${output}`); } finally { // Cleanup is best-effort so a close failure cannot mask the real outcome. if (doc) await doc.close({ discard: true }).catch((error) => console.warn(`close failed: ${error.message}`)); await client.dispose().catch((error) => console.warn(`dispose failed: ${error.message}`)); } } ``` The example uses OpenAI's Chat Completions message shape, so `callModel` can return the assistant message from `openai.chat.completions.create()` unchanged. The loop reads `tool_calls` and appends `role: 'tool'` results in that same shape, which keeps the conversation valid on the next request. A provider with a different wire format needs adapting in both directions: where tool calls are read, and where tool results are appended. Name the agent when you create the client. Without a `user`, tracked changes are attributed to a generic `CLI` author shared by every unattributed workflow, and a reviewer cannot tell which automation proposed what. The example takes the identity as a parameter so each deployment supplies its own: ```ts await runAgent({ input: 'contract.docx', output: 'contract.reviewed.docx', instruction: 'Rewrite the termination clause to allow 30-day notice.', author: { name: 'Contract bot', email: 'contract-bot@example.com' }, callModel, }); ``` ## What each piece does [#what-each-piece-does] | Piece | Role | | -------------- | ------------------------------------------------------------------------ | | `tools` | Tool definitions in your provider's shape. Pass through untouched. | | `systemPrompt` | Documents the actions the model may call. Send it as the system message. | | `dispatch` | Runs a named tool call against an open document and returns a receipt. | `dispatch` takes the document handle, so tool arguments never carry `doc` or `sessionId`. The handle injects session targeting, which means a model cannot address a document it was not given. ## Read the receipts [#read-the-receipts] Every dispatched *action* returns a receipt rather than throwing on a rejected edit. Check `status` before treating the work as done: | `status` | Meaning | | --------- | --------------------------------------------------------------- | | `ok` | The requested edits applied and any post-check agreed | | `partial` | Some applied and some did not. Never treat this as success. | | `failed` | The action did not complete. Some of it may still have applied. | | `aborted` | The action stopped before applying anything | Two dispatch outcomes are not receipts at all, and code that only reads `status` mishandles both. `superdoc_inspect` returns a document snapshot, not a receipt. It has no `status`, so testing for one treats every successful read as a failure. Branch on the tool before interpreting the result. Argument validation throws instead of returning. An unknown, missing, or excluded argument is rejected before an action runs, so the error arrives as an exception rather than a `failed` receipt. Wrap the dispatch in `try`/`catch` and hand that error back to the model as a tool result, or one malformed call ends the whole loop. `failed` does not mean nothing happened. An action can apply its operation and then fail its own post-check: `insert_table_row` inserts the row, compares the resulting table shape, and reports `failed` when the shape disagrees, with a `postSnapshot` revision showing the document already changed. So a failed receipt is not a safe retry. Compare `preSnapshot.revision` with `postSnapshot.revision` and read `executedOperations` before sending the same action again, or you may apply the edit twice. When an action runs verification the receipt also carries `verificationPassed`. A `false` value means at least one check disagreed with the intent, not that the edit landed: the standard `revision-changed` check fails just as readily when the mutation was a no-op and nothing applied. The snapshots are what separate those two cases. The example treats a dispatched failure as terminal for this reason: it stops rather than guessing whether a partly applied edit can be repeated safely. **Receipt `replace`**: replacement recorded in tracked mode ## Keep the loop bounded [#keep-the-loop-bounded] A model that keeps calling tools will keep calling tools. The turn cap in the example is not decoration: without it, a confused model can loop until it exhausts a token budget. The cap only helps if hitting it stops the save. The example refuses to save in three cases: the model ran out of turns without signalling completion, any action reported a failure, or no mutation ever succeeded. That last one matters because a model can decide the work is already done and stop without touching the document, which would otherwise write an output file identical to the input. A failure is terminal for the save rather than something a later success clears. Once an edit half-applies, receipts cannot prove that a subsequent call repaired that exact work: a `partial` on one clause followed by a clean edit to a different clause is two facts, not a fix. Recovering from a partially applied edit is a decision for a person looking at the document. Save to a path separate from the source, and close the session in `finally` so a mid-loop failure still releases the runtime. ## Dispatch only what you advertised [#dispatch-only-what-you-advertised] `dispatch` accepts more tool names than the toolkit advertises. Alongside `superdoc_inspect` and `superdoc_perform_action`, the core preset can route `superdoc_execute_code`, `agent_apply`, `agent_verify`, and `agent_operation` — surfaces meant for SDK callers, not for a model to select. They skip the tracked-mode check, and they return shapes a loop built around receipts will misread. Check the tool name against the list you handed the model before dispatching. Two things make that worth doing even though the model was never told those names exist: a model can hallucinate one, and document content can suggest one. An agent reads text somebody else wrote, so treat a tool name arriving from the model as input rather than as a decision already validated. ## Do not rely on the prompt for tracked mode [#do-not-rely-on-the-prompt-for-tracked-mode] `changeMode` is optional and defaults to a direct edit, so an instruction to work in tracked mode is a request the model can quietly skip. The example rejects any mutating call that omits `changeMode: 'tracked'` before it reaches the document, and returns that rejection as a receipt so the model can retry with the argument. The argument alone is not sufficient either. `superdoc_perform_action` advertises `changeMode` once for all forty actions, but only some of them honor it: `apply_style`, `set_paragraph_spacing`, and `insert_page_break` ignore it and always edit directly. `move_range` is a third case — it accepts the argument and then fails without mutating, because a block-range deletion cannot be tracked. Passing `changeMode: 'tracked'` to any of these looks compliant and does not produce a suggestion, so the example allows only the actions that actually record one and tells the model to pick a different action otherwise. A rejection is different from a dispatched failure. It never touched the document, so the model can correct it on the next turn and the run continues. A thrown call splits the same way: malformed arguments never reach the document, while a throw from `dispatch` itself may have applied part of the edit before failing, so that one is terminal. Enforce the constraint in code whenever the output is supposed to be reviewable. A prompt describes intent, and a permissive schema describes what is accepted rather than what is honored. Only the dispatch boundary decides what reaches the document. ## What this loop does not prove [#what-this-loop-does-not-prove] The example produces a tracked, reviewable draft. It does not prove the model carried out every part of your instruction. Nothing in the receipts carries that information. If the model asks for two edits, has one refused, corrects it, and later stops, the loop sees a sequence of independent calls: no field links a retry to the request it repairs. Guessing at the link by action name is worse than not guessing, because the same action is used for unrelated edits — a refused `replace_text` on one clause looks identical to a successful `replace_text` on another. Two things close that gap, and both belong in your application rather than in the loop: * **Human review.** The output is a tracked draft precisely so a person decides what landed. This is the default and it is usually enough. * **An explicit plan.** Derive the list of required edits before execution, give each a stable id, and check them off as receipts arrive. That makes completeness a property of your plan, not an inference from tool traffic. Reach for the second only when a workflow has to assert completeness without a reviewer. A successful receipt is not proof of a change either. Some actions accept `dryRun: true` and report success while explicitly applying nothing, so the example counts a mutation only when the receipt's before and after revisions differ. Returning a failed dispatch to the model as a receipt, instead of throwing, is what lets it correct a malformed argument on the next turn. ## Next [#next] [Tools](/agents/build/tools) explains the action surface, how to narrow it, and how to replay a tool call deterministically without the model. [Safety](/agents/operate/safety) covers what to settle before an agent touches documents that matter. --- # Legacy tools > What the legacy preset advertises, why it is still the default, and how to move to core. The `legacy` preset is the original LLM tool surface: ten grouped intent tools generated from the Document API contract. Most take an `action` argument selecting the operation within the group; `superdoc_search` does not, because it exposes a single query surface driven by `select`. It is still the default. A `createAgentToolkit()` call that omits `preset`, and an MCP server started without `MCP_PRESET`, both get this surface. Changing that default would break integrations built before presets existed, so it stays until a coordinated SDK release moves it. ## What it advertises [#what-it-advertises] | Tool | Covers | | ------------------------ | -------------------------------------------------------------------- | | `superdoc_get_content` | Read the document as text, markdown, HTML, blocks, or metadata | | `superdoc_search` | Find content and return stable targets. Takes `select`, not `action` | | `superdoc_edit` | Insert, replace, and delete text, plus undo and redo | | `superdoc_format` | Inline and paragraph formatting, styles, direction, and flow | | `superdoc_create` | Create paragraphs, headings, and tables | | `superdoc_list` | Create, convert, nest, split, merge, and renumber lists | | `superdoc_table` | Table structure, content, and styling | | `superdoc_comment` | Create, update, resolve, and read comment threads | | `superdoc_track_changes` | List tracked changes and decide on them | | `superdoc_mutations` | Preview and apply multi-step edits as one atomic batch | Each tool's exact action list comes from the generated catalog and changes with the contract, so read the advertised schema rather than a copy of it: ```ts import { getToolCatalog } from '@superdoc-dev/sdk'; const catalog = await getToolCatalog('legacy'); for (const tool of catalog.tools) { console.log( tool.toolName, tool.operations.map((operation) => operation.intentAction), ); } ``` The [Document API reference](/document-api/reference) documents the operations these tools dispatch to. ## Choosing between them [#choosing-between-them] | | `legacy` | `core` | | -------------------------- | ----------------------------------- | ------------------------------------------------- | | Advertised tools | 10 grouped intent tools | 2 (`superdoc_inspect`, `superdoc_perform_action`) | | Edit selection | Tool plus `action` within its group | One `action` from a 40-verb registry | | `excludeActions` | Ignored, no action surface | Supported | | Receipts with verification | No | Yes | | Default | Yes | No, pass `preset: 'core'` | Build new integrations on `core`. It narrows the advertised surface, supports exclusions, and returns receipts carrying verification results. ## Moving to core [#moving-to-core] Set the preset explicitly and re-check the tool names your dispatch layer expects: ```ts import { createAgentToolkit } from '@superdoc-dev/sdk'; const { tools, systemPrompt, dispatch } = await createAgentToolkit({ provider: 'openai', preset: 'core', }); ``` Three things change. Tool names collapse to two, so any code branching on a tool name needs updating. Edits move to `action` verbs on `superdoc_perform_action`, so a prompt naming legacy tools must be replaced by the preset's own `systemPrompt`. Receipts gain `verificationPassed` when an action runs a post-check. For MCP, set `MCP_PRESET=core` in the server's environment. The lifecycle tools stay the same either way. Presets are not versioned. A new tool *shape* ships as a new preset id rather than a new version of an existing one, so `legacy` will not silently turn into an actions-style surface. That is not a stability guarantee for the contents. Both catalogs are generated from the Document API contract and the action registry, so a preset can gain, lose, or change operations across SDK releases without changing its id. Pin the SDK version if your integration needs the advertised surface to hold still, and re-read the catalog after upgrading. --- # Tools and presets > Understand the action surface a model sees, and narrow it when a workflow should not reach every operation. A preset is the tool surface a model sees: the tool definitions, the system prompt describing them, and the dispatcher that runs them. The SDK ships two. ## The core preset [#the-core-preset] `preset: 'core'` advertises two tools: | Tool | Purpose | | ------------------------- | ------------------------------------------------------------------ | | `superdoc_inspect` | Read-only snapshots of current document state | | `superdoc_perform_action` | One named, statically validated edit verb from the action registry | Every edit runs through the second tool with an `action` argument naming what to do. Forty actions are available, grouped by what they touch: text and structure, lists and numbering, comments, tracked-change review, formatting, tables, and history. Two tools instead of forty keeps the advertised surface small while the action enum stays explicit. The model picks a verb by name rather than assembling an operation, and an unknown verb is rejected before anything touches the document. ## Narrow the surface [#narrow-the-surface] Pass `excludeActions` when a workflow should not reach part of the surface: ```ts import { createAgentToolkit } from '@superdoc-dev/sdk'; const { tools, systemPrompt, dispatch } = await createAgentToolkit({ provider: 'anthropic', preset: 'core', excludeActions: ['accept_tracked_changes', 'reject_tracked_changes'], }); ``` An agent that proposes edits but never resolves them is a common shape: it can suggest, and a person still decides. Exclusions apply to all three pieces at once. The action leaves the tool enum, its documentation leaves the system prompt, and `dispatch` refuses it even if the model guesses the name. An unknown action name throws immediately rather than silently doing nothing, so a typo in an exclusion list surfaces at startup instead of in production. ## Prompt caching [#prompt-caching] Tool definitions and system prompts are stable across requests, so they cache well: ```ts import { createAgentToolkit } from '@superdoc-dev/sdk'; const { tools, meta } = await createAgentToolkit({ provider: 'anthropic', preset: 'core', cache: true, }); ``` `meta.cacheStrategy` reports what actually happened. Anthropic marks the tool block explicitly and reports `explicit`. OpenAI caches long prompts on its own and reports `automatic`. Vercel and generic providers pass through and report `unsupported`, because caching depends on the model underneath. ## The legacy preset [#the-legacy-preset] `preset: 'legacy'` is the older surface: ten grouped intent tools, each taking an `action` argument, generated from the Document API contract. It remains the default when `preset` is omitted, so code written before presets existed keeps working. Two consequences are worth knowing: * A `createAgentToolkit()` call with no `preset` gets legacy, not core. * The MCP server also defaults to legacy. Set `MCP_PRESET=core` to switch it. Build new integrations on `core`. Use `legacy` when you already have one running on it, and see [Legacy tools](/agents/build/legacy-tools) for what it advertises and how to move. ## Replay a call without the model [#replay-a-call-without-the-model] Any tool call the model makes is an ordinary SDK operation underneath. When output looks wrong, run the same operation directly: ```ts const receipt = await dispatch(doc, 'superdoc_perform_action', { action: 'replace_text', find: 'termination', replaceWith: 'cancellation', changeMode: 'tracked', }); ``` If the direct call produces the right result, the operation is fine and the model chose or parameterized it badly. If it reproduces the problem, the operation is where to look. Separating those two cases first is the fastest way to stop debugging the wrong layer. ## Next [#next] [Safety](/agents/operate/safety) covers the operational boundary around an agent. [Receipts and errors](/document-api/receipts-and-errors) explains the result contract a dispatched action returns. Reads and validation failures do not follow it: `superdoc_inspect` returns a snapshot with no `status`, and an invalid argument throws before any receipt exists. --- # Safety > Operational boundaries to settle before an agent edits documents that matter. An agent makes non-deterministic choices against real files. The engine runs each operation deterministically and reports what happened, but nothing stops a model from confidently choosing the wrong operation. These boundaries keep that failure recoverable. ## Prefer tracked changes for consequential edits [#prefer-tracked-changes-for-consequential-edits] Set `changeMode: 'tracked'` when an edit changes meaning rather than formatting. The edit becomes a suggestion a person can accept or reject, so a wrong call costs a rejection instead of a restore. The argument is optional and defaults to a direct edit, so asking for tracked mode in the prompt is not enough. When a model chooses the arguments, check for `changeMode: 'tracked'` before dispatching and refuse the call without it. Check the action too. Not every action honors `changeMode` — `apply_style`, `set_paragraph_spacing`, and `insert_page_break` ignore it and always edit directly, while `move_range` accepts it and then fails without mutating. The tool schema accepts the argument in every case. An allowlist of tracked-capable actions is the only reliable guard; without one, a run that promised reviewable suggestions can quietly rewrite the document. Treat a successful receipt as a claim, not proof. Actions that support `dryRun` report success while applying nothing, so compare the receipt's before and after revisions when you need to know a change actually landed. Direct edits are appropriate for mechanical work: normalizing fonts, fixing spacing, filling known placeholders. Reserve them for cases where being wrong is cheap. ## Write to a separate output [#write-to-a-separate-output] Save to a new path and keep the source until the result is verified: ```ts await doc.save({ out: 'contract.reviewed.docx' }); await doc.close({ discard: true }); ``` `discard: true` closes the in-memory session without writing back to the source. Overwriting the input removes the only copy of what the document looked like before the agent ran. Leave `force` off. Without it, the SDK's `save` refuses a path that already exists, so a typo in the output name fails instead of destroying whatever was there. Add it only where overwriting a known artifact is the intended behavior, such as a scheduled job that rewrites its own output each run. When the path is model-supplied or assembled from input, check that it does not resolve to the source before saving. > **MCP has no such guard (warning)** > > `superdoc_save` takes no `force` argument. It resolves `out` and writes it directly, so an existing file is > overwritten with no error and no confirmation — including the source document. A model-supplied path typo destroys > whatever it names. That makes the containment advice below the real protection for MCP, not an optional extra. Run the server against copies, and if a workflow needs the guarantee, check the destination yourself before asking the agent to save, or put the server behind a wrapper that refuses to overwrite. ## Bound the loop [#bound-the-loop] Cap turns explicitly. A model that misreads a receipt can retry the same failing action indefinitely, and an uncapped loop turns that into an exhausted token budget rather than an error. Bound retries after a stale revision failure too, and re-query before retrying. A target resolved against an older revision may no longer point where the model thinks it does. ## Inspect receipts instead of assuming success [#inspect-receipts-instead-of-assuming-success] A dispatch that returns is not a dispatch that worked. Check `status` on every receipt, and treat `partial` as failure that needs handling: some edits applied and some did not, which is the state most likely to be silently shipped. `failed` needs the same care. An action can apply its operation and then fail its own post-check, so a failed receipt can sit on top of a document that already changed. Compare `preSnapshot.revision` with `postSnapshot.revision` before retrying, or the retry may apply the edit a second time. When `verificationPassed` is `false`, at least one post-check disagreed with the intent. That does not tell you whether anything changed: it covers an edit that landed wrongly and an edit that never applied at all, because the standard `revision-changed` check also fails when a mutation turns out to be a no-op. Read `preSnapshot.revision` against `postSnapshot.revision`, and `executedOperations`, to find out which happened. Surface it either way rather than swallowing it. > **Two different failures (warning)** > > A receipt reporting `failed` is an operation problem. A successful receipt for an edit nobody wanted is a model > problem. They need different fixes, so separate them before debugging. Receipts also cannot tell you whether the agent finished the job. They report what each call did, not whether the set of calls covered your instruction, and nothing links a retry to the request it repairs. A run where every receipt is clean can still have dropped an edit the model asked for and then abandoned. When completeness has to be asserted without a reviewer, decide the required edits before the run, give each one a stable id, and check them off as receipts arrive. Otherwise treat a clean run as a draft worth reviewing, not as proof the instruction was carried out. ## Keep a human in the loop [#keep-a-human-in-the-loop] Decide up front which operations an agent may complete alone. Excluding the review actions is a practical default: the agent proposes, a person decides. ```ts import { createAgentToolkit } from '@superdoc-dev/sdk'; const toolkit = await createAgentToolkit({ provider: 'openai', preset: 'core', excludeActions: ['accept_tracked_changes', 'reject_tracked_changes'], }); ``` Exclusions apply to the tools, the prompt, and the dispatcher together, so an excluded action is refused even if the model guesses its name. ## Do not log document contents [#do-not-log-document-contents] Documents carry contract terms, personal data, and unreleased material. Log operation ids, action names, receipt statuses, and error codes. Do not log document text, full mutation payloads, or complete receipts, and be deliberate about what reaches a model provider: content sent for a decision leaves your infrastructure. ## Attribute every change [#attribute-every-change] Give the client an explicit user identity so tracked changes and comments carry a real author: ```ts import { createSuperDocClient } from '@superdoc-dev/sdk'; const client = createSuperDocClient({ user: { name: 'Contract bot' } }); ``` Omitting it does not leave the author blank. Edits are attributed to a generic `CLI` author, which every unattributed workflow shares, so a reviewer opening a document touched by two automations cannot tell which one proposed what. Author-based accept and reject operations cannot separate them either. Give each automation its own name for the same reason. A shared identity copied between deployments recreates the problem the identity was meant to solve. A reviewer needs to see that a machine proposed an edit, and which machine it was. > **MCP cannot be attributed (warning)** > > This applies to the SDK. The MCP server hardcodes every session author to `MCP Server` with no way to configure it, so > two MCP deployments editing the same document produce tracked changes a reviewer cannot tell apart, and author-based > accept or reject cannot separate them. Where attribution matters, drive the edit through the SDK or distinguish the > runs some other way, such as writing to separate output files. ## Handle files the engine cannot open [#handle-files-the-engine-cannot-open] Validate input paths and file types before opening a session. Encrypted or password-protected documents fail at open, so handle that case explicitly rather than letting it surface as an unexplained crash mid-run. ## Close sessions on every path [#close-sessions-on-every-path] Sessions hold a managed runtime. Close the document and dispose the client in `finally` so success, failure, and cancellation all release it: ```ts try { // open, mutate, save } finally { if (doc) await doc.close({ discard: true }).catch(() => {}); await client.dispose().catch(() => {}); } ``` Make cleanup best-effort. A close failure that throws will mask the error that actually ended the run. ## Treat tool calls as untrusted input [#treat-tool-calls-as-untrusted-input] A model chooses tool names from what it was shown, but nothing guarantees it stays there. It can invent a name, and it can be persuaded toward one by text inside the document it is editing. The SDK dispatcher accepts several tool names the toolkit never advertises, including `superdoc_execute_code` and the `agent_*` surfaces. Those exist for SDK callers and bypass the checks a model-facing loop relies on. Validate the tool name against the list you advertised before dispatching, and reject anything else rather than passing it through. ## Restrict what an agent can reach [#restrict-what-an-agent-can-reach] The MCP server opens and writes files at whatever path the agent supplies. It resolves that path and reads or writes it directly, with no check that the result sits under any particular directory. A working directory is therefore not a boundary. It decides where a relative path lands, nothing more: an absolute path reaches anywhere the server's user account can, including the originals you meant to protect. Treat the server as having exactly the filesystem reach of the account it runs under. To actually contain it, constrain the process rather than its arguments: * Run the server in a container or VM with only the intended files mounted. * Give it a dedicated user account with read access limited to those files. * Apply an OS sandbox profile where one is available. Keep copies rather than originals in whatever you mount, so a mistake inside the boundary is still recoverable. > **A missing file is not an error (warning)** > > `superdoc_open` opens a blank document when the path does not exist, rather than failing. A typo produces an empty > session that later operations report as succeeding against nothing. --- # Review tracked changes > Create a tracked DOCX edit in code, review it in the embedded editor, and export the decision. Use tracked changes when code or an agent should propose an edit without making the final decision. This workflow creates a reviewable DOCX with the Node.js SDK, opens that exact file in the editor, and keeps the final decision with a person. > **Diagram:** Code creates a tracked edit in a DOCX file, a person reviews it in the embedded editor, and the editor exports the final DOCX. ## 1. Prepare the source document [#1-prepare-the-source-document] Download the synthetic NDA and save it as `contract.docx`. It contains the word `termination` and one existing tracked change. [Download the tracked-changes fixture](/fixtures/tracked-changes.docx): Synthetic NDA with one tracked change · DOCX Install the SDK in an empty Node.js project: ```bash pnpm add @superdoc-dev/sdk ``` ## 2. Create a tracked replacement [#2-create-a-tracked-replacement] Create `propose-change.mjs` next to `contract.docx`: ```mjs import { SuperDocClient } from '@superdoc-dev/sdk'; /** @param {unknown} value */ function isSuccessfulReceipt(value) { return typeof value === 'object' && value !== null && 'success' in value && value.success === true; } const client = new SuperDocClient({ user: { name: 'Contract assistant', email: 'assistant@example.com' }, }); try { await client.connect(); const doc = await client.open({ doc: './contract.docx' }); try { const match = await doc.query.match({ select: { type: 'text', pattern: 'termination' }, require: 'first', }); const result = match.items[0]; if (!result || result.matchKind !== 'text') { throw new Error('The source document does not contain “termination”.'); } const operation = await doc.replace({ target: result.target, text: 'cancellation', changeMode: 'tracked', }); console.log(operation); const receipt = 'receipt' in operation ? operation.receipt : operation; if (!isSuccessfulReceipt(receipt)) throw new Error('Creating the tracked change failed.'); await doc.save({ out: './contract.suggested.docx', force: true }); } finally { await doc.close({ discard: true }); } } finally { await client.dispose(); } ``` The query returns a document-native target. Passing that target to `replace()` avoids deriving an edit location from rendered HTML or copied character offsets. `changeMode: 'tracked'` records the replacement as a suggestion. The resolved operation result contains receipt information for inspection. Check it before saving in production workflows. **Receipt `replace`**: replacement recorded in tracked mode ## 3. Produce the review file [#3-produce-the-review-file] Run the script: ```bash node propose-change.mjs ``` The script writes `contract.suggested.docx` and leaves `contract.docx` unchanged. Saving to a separate path does not overwrite the open source session, so the final close explicitly discards the in-memory working state after the output is safely written. > **Automation checkpoint (success)** > > Open `contract.suggested.docx` and confirm that the heading shows a tracked replacement from `Termin` to `cancell`. > Accepting that change should make the complete word read `cancellation`. ## 4. Review the exact output [#4-review-the-exact-output] Select **Open your DOCX** below and choose `contract.suggested.docx`. The file opens locally in the browser. The documentation site does not upload it. > **Interactive editor: Review the suggested DOCX** > > Sample: [open the fixture](/fixtures/tracked-changes.docx). > Preset: `tracked-review`. > Tracked-change review: accept or reject the sample change. > Local DOCX selection: enabled. Files remain in the browser. Use the editor review controls to accept or reject each tracked change. The sample document is always available if you want to inspect the review experience before running the script. ## 5. Export the decision [#5-export-the-decision] Export the reviewed document as DOCX. Reopen it in Word or SuperDoc and verify that the accepted text remains, rejected text is restored, and the surrounding formatting is intact. > **Verification target (success)** > > The final DOCX should reflect the reviewer’s decisions. The original source and the proposed review file should remain > available as separate artifacts. The same query, target, mutation, and receipt contract works in both hosts. Read the [Document API mental model](/document-api/mental-model) for the boundary behind this workflow. --- # Comments in the built-in UI > Let people create, reply to, and resolve DOCX comment threads in the Editor. The built-in UI imports Word comments with the DOCX and keeps new threads in the document. Your application provides the current user, document access, and persistence. This guide enables the standard comments experience. A person can select text, create a thread, reply, resolve it, and export the result without a separate comments implementation. ## Mount the Editor [#mount-the-editor] Give the toolbar and document canvas separate mount elements: ```html
``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX. ## Attribute comments to a user [#attribute-comments-to-a-user] Pass a stable name and email. SuperDoc writes that identity into comments created during the session. ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', toolbar: '#toolbar', user: { name: 'Alex Rivera', email: 'alex@example.com', }, modules: { comments: { allowResolve: true, displayMode: 'auto', }, }, onCommentsUpdate: ({ type, comment }) => { console.log('Comment update:', type, comment?.commentId); }, }); ``` Comments are enabled by default. The explicit configuration above makes two workflow choices: * `allowResolve: true` shows resolve and reopen actions. * `displayMode: 'auto'` switches between the sidebar and compact inline presentation as the Editor narrows. The `comments-update` callback can update surrounding application state. The comments themselves remain document content and are preserved when you export the DOCX. ## Keep the interaction policy explicit [#keep-the-interaction-policy-explicit] `modules.comments: false` turns off SuperDoc's own comments interface. It is a presentation switch, not a data or permission switch, so it helps to keep four things apart: * **Comment data.** Threads in the DOCX are always parsed. They stay in the document and survive export whether or not the built-in interface is mounted. * **The built-in interface.** This is what `modules.comments: false` removes: the sidebar, the floating threads, and the comment dialog. * **Application-owned controls.** `editor.ui.comments` keeps reading the parsed threads with the module off, and `resolve` and `reopen` still commit. Build your own panel on it when you want to own the presentation. * **Authorization.** None of these settings grant or deny access. Use `readOnly: true` instead when comments should remain visible but the built-in controls should not mutate them. These settings control browser behavior. They do not authorize access to the document or enforce permissions on a server. Your application still owns document access, trusted identity, persistence, and collaboration authorization. ## Verify the workflow [#verify-the-workflow] 1. Select a phrase in the document. 2. Create a comment from the built-in control. 3. Reply to the thread. 4. Resolve and reopen it. 5. Export the DOCX and open it again. The thread, replies, author identity, and final status should survive the round trip. Use the [custom comments UI](/editor/custom-ui/comments) when your application needs to own the thread list or composer. Use [Document API comments](/document-api/comments) when code needs to create or resolve threads directly. --- # Configure the built-in toolbar > Keep the built-in command behavior while showing only the controls your workflow needs. The built-in toolbar can stay complete or be reduced to a focused set of controls. Configure its layout when SuperDoc's interaction behavior fits your product but the default control set is broader than the task. This guide creates a compact editing toolbar with history, basic formatting, links, document mode, and zoom. ## Add a toolbar mount [#add-a-toolbar-mount] Give the toolbar and document canvas separate mount elements. The editor uses natural height here, so the page scrolls with the document. ```html
``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or update the document URL. ## Choose the controls [#choose-the-controls] Use `groups` as an ordered allowlist. An item appears only when its name is included. The group keys become the toolbar regions. ```ts import { SuperDoc, type Config } from 'superdoc'; import 'superdoc/style.css'; type ToolbarConfig = Exclude['toolbar']>, boolean>; const toolbar: ToolbarConfig = { groups: { left: ['undo', 'redo'], center: ['bold', 'italic', 'underline', 'link'], right: ['documentMode', 'zoom'], }, hideButtons: true, responsiveToContainer: true, }; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', toolbar: '#toolbar', modules: { toolbar }, }); ``` This example keeps eight controls: * `undo` and `redo` for history. * `bold`, `italic`, `underline`, and `link` for focused editing. * `documentMode` and `zoom` for session and viewport control. Use `excludeItems` instead when the default toolbar already fits and you only need to remove one or two controls. Do not combine a long allowlist with a long exclusion list. One clear strategy is easier to maintain. ## Keep responsive behavior enabled [#keep-responsive-behavior-enabled] `responsiveToContainer: true` measures the toolbar's mount instead of the browser window. This matters when the editor sits beside navigation or another panel. `hideButtons: true` moves controls out of the visible row when space is limited. Keep the controls required for the primary task in the earliest groups, then test the actual product width rather than assuming a desktop viewport. ## Let command state come from SuperDoc [#let-command-state-come-from-superdoc] The built-in toolbar reads the same public controller state as a custom UI. Controls enable, disable, and become active based on the current document, selection, and mode. Do not add DOM listeners to force a built-in button into a state. If your application must present state differently, use the [custom UI controller](/editor/custom-ui/overview) instead. The toolbar is configuration, not authorization. Enforce permissions in your application and use document modes to control Editor behavior. Verify the result at your narrowest supported width. The document should remain usable, required controls should stay reachable, and the toolbar should not create horizontal page overflow. Continue with [Document modes](/editor/document-modes), or return to the [Built-in UI overview](/editor/built-in-ui/overview). --- # Links and context menus > Configure link behavior and add application actions to the built-in Editor context menu. The built-in UI can create and edit hyperlinks, show a link popover, and combine SuperDoc's default context menu with application-owned actions. ## Configure both interaction surfaces [#configure-both-interaction-surfaces] This example keeps the default menu, adds one selection-aware action, and replaces the clicked-link popover with framework-neutral DOM: ```ts import { SuperDoc, type ContextMenuConfig, type LinkPopoverResolver } from 'superdoc'; import 'superdoc/style.css'; const resolveLinkPopover: LinkPopoverResolver = ({ href }) => ({ type: 'external', render: ({ container, closePopover }) => { const link = document.createElement('a'); link.href = href; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = 'Open link'; const close = document.createElement('button'); close.type = 'button'; close.textContent = 'Close'; close.addEventListener('click', closePopover); container.append(link, close); return { destroy: () => close.removeEventListener('click', closePopover) }; }, }); const contextMenu = { includeDefaultItems: true, customItems: [ { id: 'application-actions', items: [ { id: 'copy-selection-to-workflow', label: 'Copy selection to workflow', showWhen: ({ hasSelection }) => hasSelection, action: (_editor, { selectedText }) => void navigator.clipboard.writeText(selectedText), }, ], }, ], } satisfies ContextMenuConfig; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', modules: { toolbar: { groups: { center: ['link'] } }, links: { popoverResolver: resolveLinkPopover }, contextMenu, }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` `modules.links.popoverResolver` runs synchronously when a link is clicked. Return `default` to use SuperDoc's popover, `none` to suppress it, or `external` to render with your own framework or DOM. The external renderer must return cleanup when it installs listeners or mounts a framework root. `modules.contextMenu.customItems` adds sections to the built-in right-click and slash menu. `showWhen()` should decide whether an action is relevant from the supplied context. Keep application actions narrow and avoid reconstructing document positions from `selectionStart` or `selectionEnd`; use the Document API and public selection targets for document work. ## Separate navigation from mutation [#separate-navigation-from-mutation] Opening a link is browser navigation. Creating, changing, and removing hyperlinks are document mutations. The built-in toolbar handles those mutations against the live selection and respects viewing mode. For programmatic hyperlink changes, use the generated Document API hyperlink operations instead of simulating toolbar clicks. Context-menu visibility is not authorization. Server-side access and collaboration policy remain the application's responsibility. --- # Built-in UI overview > Use SuperDoc's complete editing interface and keep your application focused on the surrounding workflow. The built-in UI is the fastest path from a DOCX file to a complete editing and review experience. SuperDoc renders the document and provides the controls around it while your application owns document access, users, workflow, and persistence. > **Diagram:** The built-in editor interface contains an application header, responsive toolbar, document canvas, and contextual review surfaces. ## What the built-in UI provides [#what-the-built-in-ui-provides] The interface coordinates capabilities that otherwise require separate custom controls: * A responsive formatting toolbar with command state. * Document modes for editing, suggesting, and viewing. * Comments and tracked-change review surfaces. * Links, context menus, search, and navigation. * Document-aware controls for content such as tables and images. * Zoom and viewport behavior around the DOCX canvas. The exact controls depend on the active document, selection, mode, and enabled modules. A disabled or hidden command can reflect document context rather than a missing feature. ## What your application still owns [#what-your-application-still-owns] The built-in UI does not provide document storage, authentication, or product workflow. Your application remains responsible for: * Choosing which DOCX a person can open. * Providing the current user and enforcing authorization. * Deciding whether the session starts in editing, suggesting, or viewing mode. * Saving or exporting the resulting DOCX. * Placing the editor inside the product layout. * Destroying the editor when that surface is removed. Document modes control browser interaction. They are not an authorization boundary. ## Start with the complete lifecycle [#start-with-the-complete-lifecycle] Start with the [Editor quickstart](/editor/quickstart) to open, edit, and export a real DOCX. It uses the document canvas without a toolbar so the file lifecycle stays clear. Then add the [built-in toolbar](/editor/built-in-ui/configure-the-toolbar). Comments, search, and review controls build on the same editor instance. ## Configure before replacing [#configure-before-replacing] If the overall experience fits, configure the built-in interface before choosing a custom UI. Common reasons include focusing the toolbar, selecting modules, setting document mode, fitting the editor into a responsive layout, or matching product colors. Choose a custom UI when your application needs to own control layout, state presentation, or a workflow that is intentionally narrower than a general document editor. Continue with [Configure the built-in toolbar](/editor/built-in-ui/configure-the-toolbar), or learn how the [custom UI controller](/editor/custom-ui/overview) exposes the same editor state without the built-in controls. --- # Build a responsive Editor layout > Fit the document to its container, adapt built-in chrome, and refit after fullscreen changes. Responsive Editor layouts have three independent concerns: the document scale, the available toolbar width, and whether the document scrolls inside a fixed-height host. Configure each explicitly. ## Build the shell [#build-the-shell] Give fullscreen to an element that contains the toolbar, control, and Editor: ```html
``` ## Fit to the container [#fit-to-the-container] Configure fit-to-width and container-aware chrome, then refit after the browser enters or exits fullscreen: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const shell = document.querySelector('#editor-shell'); const fullscreen = document.querySelector('#fullscreen'); if (!shell || !fullscreen) throw new Error('The responsive editor shell is incomplete.'); const superdoc = new SuperDoc({ selector: '#editor', toolbar: '#toolbar', document: '/contract.docx', contained: true, zoom: { mode: 'fit-width', fitWidth: { min: 40, max: 100, padding: 24 }, }, modules: { toolbar: { responsiveToContainer: true, hideButtons: true, }, comments: { displayMode: 'auto' }, }, }); const toggleFullscreen = async () => { if (document.fullscreenElement) await document.exitFullscreen(); else await shell.requestFullscreen(); }; const refit = () => superdoc.setZoomMode('fit-width'); fullscreen.addEventListener('click', toggleFullscreen); document.addEventListener('fullscreenchange', refit); window.addEventListener('beforeunload', () => { fullscreen.removeEventListener('click', toggleFullscreen); document.removeEventListener('fullscreenchange', refit); superdoc.destroy(); }); ``` `zoom.mode: 'fit-width'` continuously follows the available document width. The `min`, `max`, and `padding` values constrain that policy. Calling `setZoom()` switches to manual mode; call `setZoomMode('fit-width')` to resume automatic fitting. `responsiveToContainer` measures the toolbar's container rather than the browser window. `hideButtons` lets lower-priority controls leave the visible row when space becomes tight. `comments.displayMode: 'auto'` lets review UI move between sidebar and inline presentation. Set `contained: true` only when the host has a deliberate fixed height and should own an internal scroll region. Leave it off when the document should expand with the page. Avoid nesting the Editor inside another horizontal scroller. The Fullscreen API is browser-owned, so the application must provide the button and handle rejected fullscreen requests where required by its product. The explicit `fullscreenchange` refit prevents the document from keeping dimensions calculated for the previous viewport. --- # Find and replace in the built-in UI > Enable SuperDoc's find surface, navigate visible matches, and replace document text. The built-in find surface searches the open document, highlights visible matches, and moves the Editor to the active result. In editable modes it can also replace the current match or every match in the session. ## Enable the find surface [#enable-the-find-surface] Add toolbar and Editor containers: ```html
``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX. Enable the surface explicitly: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', toolbar: '#toolbar', modules: { surfaces: { findReplace: true, }, }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` `modules.surfaces.findReplace: true` connects the toolbar Search button and `Ctrl+F` or `Command+F` to SuperDoc's document-aware find surface. Without that setting, the browser keeps its native page search shortcut. ## Find and navigate matches [#find-and-navigate-matches] Enter a query, then use the previous and next controls to move through the document. The surface keeps the active result visible across paginated and virtualized pages. Match-case and regular-expression controls appear when the active v2 search host supports them. An invalid regular expression produces an inline error instead of running a partial search. ## Replace only when the document can mutate [#replace-only-when-the-document-can-mutate] Replace controls are available in editing and suggesting modes when the current search session can enumerate and change its matches. They are hidden in viewing or read-only sessions. Replacing text changes the DOCX. Export or save the document after the workflow. Search highlights and the active match are temporary Editor state and are not written into the file. ## Verify the workflow [#verify-the-workflow] 1. Open the find surface from the toolbar and from the keyboard shortcut. 2. Search for text that appears more than once. 3. Navigate across matches and pages. 4. Replace one match, then search again. 5. Switch to viewing mode and confirm that find remains available while replace does not. Use [custom search controls](/editor/custom-ui/search) when the product needs to own the search layout. Use [Document API queries](/document-api/query-content) when code needs mutation-ready document targets instead of a visual search session. --- # Work with structured content > Expose tables, images, links, and content-control chrome in the built-in Editor interface. Use the built-in interface when people need familiar controls for tables, images, links, and form-like content controls. The toolbar provides creation actions, while the document canvas provides contextual editing and field chrome. ## Configure focused controls [#configure-focused-controls] This example keeps only the structured-content tools and enables the default content-control chrome: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const temporaryImageUrls: string[] = []; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', modules: { toolbar: { groups: { center: ['link', 'image', 'table', 'tableActions'], }, }, contentControls: { chrome: 'default' }, }, handleImageUpload: async (file) => { const temporaryUrl = URL.createObjectURL(file); temporaryImageUrls.push(temporaryUrl); return temporaryUrl; }, }); window.addEventListener('beforeunload', () => { for (const url of temporaryImageUrls) URL.revokeObjectURL(url); superdoc.destroy(); }); ``` The example uses object URLs so it runs without an upload backend. They last only for the browser session. In production, `handleImageUpload` should upload the file to storage and return a URL that remains available when another user or process opens the DOCX. ## Follow the document context [#follow-the-document-context] Table actions become relevant when the selection is inside a table. Image and link actions operate on the current selection or insertion point. Content controls are document content, so their type and locking behavior come from the DOCX rather than from the toolbar configuration. Do not expose a control merely because the command ID exists. Start with the tasks people must perform, then add the smallest relevant toolbar group. Viewing mode and locked content controls can disable mutations even when the control remains visible. For application-owned table and field panels, use [Custom table controls](/editor/custom-ui/tables) and [Custom content controls](/editor/custom-ui/content-controls). For programmatic creation and mutation, use the Document API task guides and generated operation reference. --- # Migrate from v1 > Move a browser editor from SuperDoc v1 to the v2 DOCX engine. If your v1 application mounts `SuperDoc` from the package root, waits for readiness, and exports through the same instance, most of that integration remains valid. The migration is mainly about selecting the v2 package and removing dependencies on v1 internals. > **This guide covers local browser editing (note)** > > V2 collaboration rooms use a different document format and require a separate migration. Do not connect a v2 editor > directly to an existing v1 collaboration room. **Migrating with an AI coding agent?** Use this prompt: ```text Help me migrate this project from SuperDoc v1 to v2. First, read these sources of truth: /md/editor/migrate-from-v1/overview.md /migration/v1-to-v2.json Inspect the project and report: 1. Removed imports and package subpaths 2. Any direct editor.* access, including commands, state, view, chain(), helpers, comments, presentationEditor, and on() 3. Legacy configuration and collaboration usage 4. Custom UI, extensions, and DOM selectors that require redesign 5. Synchronous Document API reads such as doc.extract(), doc.getMarkdown(), and doc.selection.current(), which the browser resolves as Promises Do not change code yet. Classify each finding using the migration catalog, then propose the smallest safe migration sequence and a verification plan. ``` ## 1. Install the v2 package [#1-install-the-v2-package] SuperDoc v2 is selected by the installed package version: ```bash pnpm add superdoc@2 ``` Keep the package name `superdoc`. Do not add `editorVersion`, `v2`, or `v2Integration` to the editor configuration. The v2 package always runs the v2 DOCX engine and has no runtime fallback to v1. ## 2. Keep the supported root integration [#2-keep-the-supported-root-integration] These browser integration points remain the same: | Integration point | V2 path | | ----------------- | ---------------------------------------- | | Editor class | `SuperDoc` from `superdoc` | | Styles | `superdoc/style.css` | | Document input | `document` with a `File`, `Blob`, or URL | | Ready lifecycle | `onReady` or the `ready` event | | Mode changes | `setDocumentMode()` | | DOCX output | `export()` | | Cleanup | `destroy()` | A minimal v2 mount still looks familiar: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: file, onReady: ({ superdoc }) => { superdoc.setDocumentMode('editing'); }, }); ``` Call document-dependent methods only after `onReady`. V2 opens and renders the DOCX progressively, so readiness is the public boundary for starting product interaction. ## 3. Remove legacy package subpaths [#3-remove-legacy-package-subpaths] SuperDoc v2 exposes a smaller public package surface: | V1 import | V2 replacement | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `superdoc/types` | Not a rename. See [Removed in v2](/editor/migrate-from-v1/removed-apis) | | `superdoc/headless-toolbar` | `superdoc.ui`, the controller the instance owns | | `superdoc/headless-toolbar/react` | Hooks and providers from `superdoc/ui/react` | | `superdoc/headless-toolbar/vue` | Build on `superdoc.ui`, the framework-agnostic controller | | `superdoc/super-editor` | Use the `SuperDoc` instance and its public active-editor facade | | `superdoc/converter`, `superdoc/docx-zipper`, or `superdoc/file-zipper` | Use the supported `SuperDoc` load and export workflow; there is no direct v2 subpath | Do not replace a removed public path with an internal package. Packages such as `@superdoc/v2-host`, `@superdoc/headless`, and `@superdoc/document-api-v2-adapter` are implementation details, not customer integration surfaces. ## 4. Replace direct editor internals [#4-replace-direct-editor-internals] V1 code could reach through the editor to ProseMirror state and commands: ```ts new SuperDoc({ selector: '#editor', document: file, onEditorCreate: ({ editor }) => { const text = editor.state.doc.textContent; console.log(text); }, }); ``` In v2, read and change document content through the public Document API after the editor is ready: ```ts new SuperDoc({ selector: '#editor', document: file, onReady: async ({ superdoc }) => { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); const text = await doc.getText({}); console.log(text); }, }); ``` Do not use `editor.state`, `editor.view`, DOM text offsets, or private command objects as the document model. Use the Document API for fresh reads and mutations, and `superdoc/ui` for reactive custom UI state. ### Move UI interactions off the document runtime [#move-ui-interactions-off-the-document-runtime] `DocumentRendererRuntime` is part of SuperDoc's rendering path. V1 integrations sometimes retrieved it with `getDocumentRuntimeForDocument()` and called navigation methods on it. That lookup is not the V2 readiness boundary. It can return `null` when the renderer is not available, which often surfaces as an application error such as `DocumentRuntime is not available`. Read the instance-owned controller from `superdoc.ui` after `onReady` instead. The controller groups state and actions by the feature your interface is showing. It also owns the navigation needed to reveal content on a virtualized page. #### Scroll to a tracked change [#scroll-to-a-tracked-change] **V2** ```ts import type { SuperDoc } from 'superdoc'; export async function scrollToChange(superdoc: SuperDoc, changeId: string): Promise { const result = await superdoc.ui.trackChanges.scrollTo(changeId); return result.success; } ``` **V1** ```ts import type { SuperDoc } from 'superdoc'; export async function scrollToChange( superdoc: SuperDoc, documentId: string, changeId: string, ): Promise { const runtime = superdoc.getDocumentRuntimeForDocument(documentId); if (!runtime?.scrollToElement) throw new Error('DocumentRuntime is not available.'); return Boolean(await runtime.scrollToElement(changeId)); } ``` Use the narrowest navigation method for the target you already know: | Target you have | V2 navigation method | | ----------------------------------- | ---------------------------------------------------- | | Tracked-change ID | `superdoc.ui.trackChanges.scrollTo(changeId)` | | Comment ID | `superdoc.ui.comments.scrollTo(commentId)` | | Content-control ID | `superdoc.ui.contentControls.scrollIntoView({ id })` | | Stable block, comment, or change ID | `superdoc.scrollToElement(elementId)` | The feature-specific methods return structured results and keep feature state, such as the active tracked change, in the same controller. Use `superdoc.scrollToElement()` when you only have a stable extracted ID and do not know its entity type. It returns `false` when SuperDoc cannot route the navigation. You do not need to import the normal controller from `superdoc/ui`. The `superdoc.ui` getter creates it once, and `superdoc.destroy()` owns its cleanup. Import `createSuperDocUI` from `superdoc/ui` only when you need a separately owned controller with its own lifecycle. Continue with [Custom UI controller setup](/editor/custom-ui/controller-setup) for subscriptions and cleanup, or the [tracked-changes UI guide](/editor/custom-ui/tracked-changes) for review navigation. ## 5. Map common editor and ProseMirror internals [#5-map-common-editor-and-prosemirror-internals] These are the mappings most applications need: reading text, resolving a selection, inserting at it, and exporting. Each example includes its imports, inputs, async boundary, and returned value. V2 is selected by default; switching an example switches every example on this page. For everything else, work from [Removed in v2](/editor/migrate-from-v1/removed-apis). Each entry names the v1 symbol, what you will observe when it breaks, and the v2 replacement if one exists. Entries link to a canonical API page where one covers the replacement. ### Document API [#document-api] #### getText [#gettext] **V2** ```ts import type { SuperDoc } from 'superdoc'; export async function readText(superdoc: SuperDoc): Promise { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); return doc.getText({}); } ``` **V1** ```ts import type { SuperDoc } from 'superdoc'; export function readText(superdoc: SuperDoc): string { return superdoc.activeEditor!.state.doc.textContent; } ``` #### selection.current [#selectioncurrent] **V2** ```ts import type { SuperDoc } from 'superdoc'; export async function currentSelection(superdoc: SuperDoc) { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); return doc.selection.current({ includeText: true }); } ``` **V1** ```ts import type { SuperDoc } from 'superdoc'; export function currentSelection(superdoc: SuperDoc) { return superdoc.activeEditor!.state.selection; } ``` #### selection.current().selectionTarget [#selectioncurrentselectiontarget] **V2** ```ts import type { SuperDoc } from 'superdoc'; export async function selectionTarget(superdoc: SuperDoc) { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); const selection = await doc.selection.current({}); return selection.selectionTarget; } ``` **V1** ```ts import type { SuperDoc } from 'superdoc'; export function selectionRange(superdoc: SuperDoc) { const { from, to } = superdoc.activeEditor!.state.selection; return { from, to }; } ``` #### insert [#insert] **V2** ```ts import type { SuperDoc } from 'superdoc'; export async function insertText(superdoc: SuperDoc, text: string) { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); // A targetless insert appends at the end of the document. V1 inserted at // the caret, so a missing target is a lost selection, not a default. // Failing loudly beats silently putting the text somewhere else. const selection = await doc.selection.current({}); if (!selection.selectionTarget) { throw new Error('No selection target. Capture one while the editor has focus, or pass an explicit target.'); } const result = (await doc.insert({ value: text, target: selection.selectionTarget })) as | { success: boolean } | { receipt: { success: boolean } }; const receipt = 'receipt' in result ? result.receipt : result; if (!receipt.success) throw new Error('Text insertion failed.'); return result; } ``` **V1** ```ts import type { SuperDoc } from 'superdoc'; export function insertText(superdoc: SuperDoc, text: string) { const editor = superdoc.activeEditor!; return editor.view.dispatch(editor.state.tr.insertText(text)); } ``` Pass a target unless you mean to append. `doc.insert({ value })` with no `target` or `ref` routes to the end of the document, so a direct port of `tr.insertText(text)` appends instead of inserting at the caret. In a browser this example throws rather than falling back, because losing the selection means the text would land somewhere the user did not choose. Headless callers with no selection can omit the target deliberately and take the append. The Editor returns a mutation receipt directly, while the headless SDK returns an operation envelope containing the receipt. Normalize the two shapes before checking `success`, as shown above. Headless callers can also use the envelope's `target`, `resolvedRange`, and `context` for details about the applied mutation. Verify persisted output by saving and reopening the DOCX when insertion is part of an automated workflow. ### Export [#export] #### getMarkdown [#getmarkdown] **V2** ```ts import type { SuperDoc } from 'superdoc'; export async function markdown(superdoc: SuperDoc): Promise { const doc = superdoc.activeEditor?.doc; if (!doc) throw new Error('The active document is unavailable.'); return doc.getMarkdown({}); } ``` **V1** ```ts import type { SuperDoc } from 'superdoc'; export function markdown(superdoc: SuperDoc): string { return superdoc.activeEditor!.getMarkdown(); } ``` #### export [#export-1] **V2** ```ts import type { SuperDoc } from 'superdoc'; export async function exportDocx(superdoc: SuperDoc) { return superdoc.export({ exportType: ['docx'], triggerDownload: false }); } ``` **V1** ```ts import type { SuperDoc } from 'superdoc'; export async function exportDocx(superdoc: SuperDoc) { return superdoc.export({ exportType: ['docx'], triggerDownload: false }); } ``` ## 6. Migrate changed integration contracts [#6-migrate-changed-integration-contracts] The following changes are public V2 contracts rather than replacements for ProseMirror internals. Treat the browser Document API as asynchronous, keep document and UI namespaces explicit, and preserve every returned cleanup function. ### Collaboration [#collaboration] #### document.v2Collaboration [#documentv2collaboration] **V2** ```ts import type { Document, V2CollaborationConfig } from 'superdoc'; export function collaborativeDocument( data: Blob, id: string, serverUrl: string, token: string, roomMode: 'join' | 'create' = 'join', ): Document { const v2Collaboration: V2CollaborationConfig = { providerType: 'hocuspocus', documentId: id, serverUrl, token, roomMode, }; return { id, type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', data, v2Collaboration, }; } ``` **V1** ```ts import * as Y from 'yjs'; import { HocuspocusProvider } from '@hocuspocus/provider'; const ydoc = new Y.Doc(); const provider = new HocuspocusProvider({ url: serverUrl, name: id, document: ydoc }); const modules = { collaboration: { ydoc, provider } }; ``` V2 owns the provider and `Y.Doc`; do not pass an external pair through `modules.collaboration`. Use `roomMode: 'join'` for an existing room and `'create'` only while seeding a missing room. Wait for `onCollaborationReady` before reading `instance.provider`, handle `collaboration-v2-room-missing` and `collaboration-v2-room-already-exists` in `onException`, and let `destroy()` release the owned collaboration lifecycle. V2 has no `join-or-create` mode: retrying with a different room mode requires a fresh mount. ## 7. Verify one DOCX round trip [#7-verify-one-docx-round-trip] Before migrating advanced UI or automation, verify the smallest complete path: 1. Open a representative DOCX in v2. 2. Wait for `onReady`. 3. Make one direct or tracked edit. 4. Export the document with `SuperDoc.export()`. 5. Reopen the result in Microsoft Word or another DOCX reader. 6. Confirm the edited content, formatting, comments, and tracked changes still match the intended document state. Keep v1 and v2 on separate branches or deployments while comparing the same input documents. A package upgrade should remain easy to reverse until the documents that matter to your product complete this round trip. Once that round trip holds, migrate whatever else your application uses, working from [Removed in v2](/editor/migrate-from-v1/removed-apis) and the canonical API pages it links to. Next, follow the [Editor quickstart](/editor/quickstart) for a complete v2 mount, or learn the [Document API mental model](/document-api/mental-model) before migrating programmatic edits. --- # Removed in v2 > Every v1 import and editor internal that no longer works in SuperDoc v2, and what replaces it. Upgrading breaks three things, and they surface at different times. Removed subpaths always fail module resolution. Removed root exports fail the build under ESM and TypeScript, but a CommonJS `require` binds them to `undefined` and fails later at the call site. Configuration and editor internals that v2 no longer honors fail only once the editor is running, often without naming SuperDoc at all. Work through them in that order. The import failures are the loudest, and fixing them tells you where the rest of the work is. This page describes `superdoc@2.0.0-next.36` compared against `superdoc@1.45.0`. A machine-readable version is available at [`/migration/v1-to-v2.json`](/migration/v1-to-v2.json). ## How to read the Migration column [#how-to-read-the-migration-column] * **Mechanical** — A direct substitution with equivalent behavior. Safe to apply mechanically, and required to be backed by a compiled fixture before it carries this label. * **Redesign** — A replacement exists, but the semantics differ. Read the replacement and re-verify the behavior; do not apply it as a find-and-replace. * **No equivalent** — No v2 equivalent. The capability must be removed or rebuilt outside SuperDoc. Nothing here is currently classified as Mechanical, so this page is an inventory and a decision aid, not a codemod. Do not apply any of it as an automated find-and-replace. ## Search the reference [#search-the-reference] > **Searchable migration reference.** > > The interactive explorer filters the same entries listed in the tables below, > by symbol name, by when the failure surfaces, and by how much work the migration is. > A machine-readable version of every entry is published at `/migration/v1-to-v2.json`. Every entry is also listed in full below, grouped by when the failure surfaces. The tables are the canonical form: they work without JavaScript and are what `llms.txt` and search engines read. ## Your application no longer imports [#your-application-no-longer-imports] ### Removed package subpaths [#removed-package-subpaths] v1 published 10 code subpaths. v2 publishes 3: `superdoc/collaboration-upgrade-engine`, `superdoc/ui`, `superdoc/ui/react`. | v1 | v2 | Migration | Notes | | --------------------------------- | ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `superdoc/types` | `superdoc` | Redesign | Not a path rename. v1 re-exported 116 names here, almost all of them ProseMirror and schema types, and none of them exist on the v2 root. Integration types such as `Config`, `DocumentMode`, `User`, and `DocumentApi` are exported from `superdoc`; types describing ProseMirror nodes, marks, commands, and transactions have no v2 equivalent because the model they described is gone. [Read more](/document-api/mental-model) | | `superdoc/super-editor` | `superdoc.activeEditor` | Redesign | v2 has no importable editor class. Reach the active document through the `SuperDoc` instance and its Document API facade. [Read more](/document-api/mental-model) | | `superdoc/converter` | *None* | No equivalent | v2 owns DOCX parsing and serialization internally. Load documents through `document` and produce output through `export()`. [Read more](/editor/load-and-save-documents) | | `superdoc/docx-zipper` | *None* | No equivalent | No public archive surface in v2. Use the supported load and export workflow. | | `superdoc/file-zipper` | *None* | No equivalent | No public archive surface in v2. Use the supported load and export workflow. | | `superdoc/headless-toolbar` | `superdoc.ui` | Redesign | `superdoc.ui` is a getter that lazily creates and owns the controller. It returns `BorrowedSuperDocUI`, which omits `destroy()` because `superdoc.destroy()` owns teardown. Call `createSuperDocUI({ superdoc })` from `superdoc/ui` only when you want an independently owned controller to dispose yourself. [Read more](/editor/custom-ui/overview) | | `superdoc/headless-toolbar/react` | `superdoc/ui/react` | Redesign | Hooks and providers moved and now bind to the instance-owned controller. [Read more](/editor/custom-ui/overview) | | `superdoc/headless-toolbar/vue` | `superdoc.ui` | Redesign | v2 ships no Vue-specific bindings. Build on the framework-agnostic controller. [Read more](/editor/custom-ui/overview) | A removed subpath fails module resolution in every module system, because it is absent from the package's exports map. Do not work around it by importing an internal package: names such as `@superdoc/v2-host` and `@superdoc/document-api-v2-adapter` are implementation details with no compatibility guarantee. ### Removed root exports [#removed-root-exports] v1 exported 41 runtime values from the package root. v2 exports 10: `BlankDOCX`, `DOCX`, `HTML`, `PDF`, `SuperDoc`, `buildTheme`, `compareVersions`, `createTheme`, `defineSuperDocExtension`, `getFileObject`. Everything below was removed. `defineSuperDocExtension` is the one value v2 adds. #### Editor internals [#editor-internals] | v1 | v2 | Migration | Notes | | --------------------------- | ---------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Editor` | `superdoc.activeEditor` | Redesign | v2 exposes no editor constructor. Mount through `SuperDoc` and read the active document via its Document API facade. [Read more](/document-api/mental-model) | | `PresentationEditor` | *None* | No equivalent | v2 owns rendering internally. There is no public presentation-editor surface. | | `SuperEditor` | `SuperDoc` | Redesign | The mounting capability survives, the component does not. v1 rendered a Vue component taking `fileSource`, `documentId`, `state`, and `options` props; v2 mounts imperatively with `new SuperDoc({ selector, document })`. A React or Vue wrapper is now application code around that instance. [Read more](/editor/quickstart) | | `SuperInput` | *None* | No equivalent | v2 ships no importable single-line input component, and no v2 surface is a drop-in equivalent. Build the control in your application. | | `superEditorHelpers` | `superdoc.activeEditor.doc` | Redesign | Helper bags are replaced by explicit Document API operations. [Read more](/document-api/mental-model) | | `trackChangesHelpers` | `superdoc.activeEditor.doc.trackChanges` | Redesign | Helper bag replaced by explicit Document API operations for listing, navigating, and deciding tracked changes. [Read more](/document-api/tracked-changes) | | `fieldAnnotationHelpers` | `superdoc.activeEditor.doc` | Redesign | Field and annotation work moves to Document API operations. | | `AnnotatorHelpers` | `superdoc.activeEditor.doc` | Redesign | Helper bag replaced by explicit Document API operations. Field and annotation work goes through `doc`, not a helper namespace. | | `SectionHelpers` | `superdoc.activeEditor.doc.sections` | Redesign | Verify coverage before migrating: linked-section workflows may not have full v2 equivalents. | | `getAllowedImageDimensions` | *None* | No equivalent | No public sizing helper in v2. Compute the constraint in your application, or let the engine apply its own bounds on insert. | | `CommentsPluginKey` | `superdoc.activeEditor.doc.comments` | Redesign | v2 exposes no ProseMirror plugin keys. Use the Document API comments operations. [Read more](/document-api/comments) | | `TrackChangesBasePluginKey` | `superdoc.activeEditor.doc.trackChanges` | Redesign | v2 exposes no ProseMirror plugin keys. [Read more](/document-api/tracked-changes) | #### Converter and archives [#converter-and-archives] | v1 | v2 | Migration | Notes | | ---------------- | ------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SuperConverter` | *None* | No equivalent | DOCX conversion is internal to the v2 engine. Load through `document` and produce output through `export()`. [Read more](/editor/load-and-save-documents) | | `DocxZipper` | *None* | No equivalent | No public archive surface in v2. | | `createZip` | *None* | No equivalent | No public archive surface in v2. | #### Custom UI [#custom-ui] | v1 | v2 | Migration | Notes | | ----------------------- | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SuperToolbar` | `config.toolbar` | Redesign | For the built-in toolbar, pass a container through `toolbar` in the config. For a custom toolbar, drive `superdoc.ui`. [Read more](/editor/built-in-ui/configure-the-toolbar) | | `Toolbar` | `config.toolbar` | Redesign | The built-in toolbar is configured, not imported and rendered by the consumer. [Read more](/editor/built-in-ui/configure-the-toolbar) | | `ContextMenu` | *None* | No equivalent | `getV2FeatureMatrix()` reports `shell.context-menu` as not-shipped: the v2 shell disables the legacy context menu. A `modules.contextMenu` config field exists but no v2 runtime renders it, so it is not a working replacement. Build an application-owned menu on `superdoc.ui` if you need one. [Read more](/editor/built-in-ui/links-and-context-menus) | | `SlashMenu` | *None* | No equivalent | `modules.slashMenu` is marked deprecated in favor of `modules.contextMenu`, which is itself not-shipped. Do not migrate onto either; build an application-owned menu on `superdoc.ui`. | | `AIWriter` | `config.modules.ai` | Redesign | The built-in toolbar renders the AI writer when `modules.ai` is configured, and applies generated text through the Document API. | | `getMarksFromSelection` | `superdoc.ui` | Redesign | Active formatting is published as reactive controller state rather than read from a selection. [Read more](/editor/custom-ui/overview) | | `getActiveFormatting` | `superdoc.ui` | Redesign | Active formatting is published as reactive controller state rather than read from a selection. [Read more](/editor/custom-ui/overview) | #### Extensions [#extensions] | v1 | v2 | Migration | Notes | | ------------------------ | ------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Extensions` | `defineSuperDocExtension` | Redesign | v2 extensions receive commands, anchors, and decorations. They do not receive ProseMirror state, custom schema, or mutable DOM, so extensions that defined custom nodes or marks may have no v2 equivalent. [Read more](/editor/custom-ui/custom-commands) | | `defineNode` | *None* | No equivalent | v2 has no custom-schema surface. Custom document nodes cannot be reproduced. [Read more](/editor/custom-ui/custom-commands) | | `defineMark` | *None* | No equivalent | v2 has no custom-schema surface. Custom marks cannot be reproduced. [Read more](/editor/custom-ui/custom-commands) | | `getStarterExtensions` | *None* | No equivalent | v2 loads its own document capabilities. There is no consumer-assembled extension list. | | `getRichTextExtensions` | *None* | No equivalent | v2 loads its own document capabilities. There is no consumer-assembled extension list. | | `isNodeType` | *None* | No equivalent | ProseMirror node predicates have no v2 equivalent. Query structure through the Document API. [Read more](/document-api/query-content) | | `assertNodeType` | *None* | No equivalent | ProseMirror node predicates have no v2 equivalent. Query structure through the Document API. [Read more](/document-api/query-content) | | `isMarkType` | *None* | No equivalent | ProseMirror mark predicates have no v2 equivalent. | | `getSchemaIntrospection` | *None* | No equivalent | v2 exposes no ProseMirror schema to introspect. | | `registeredHandlers` | *None* | No equivalent | Marked internal in v1 and removed in v2. | A missing named export is only reliably caught at build time under ESM and TypeScript. In CommonJS, `const { Editor } = require('superdoc')` binds `undefined` and fails later wherever the value is used, so a CommonJS integration can appear to upgrade cleanly and then break at the call site. ### Names dropped from subpaths that still exist [#names-dropped-from-subpaths-that-still-exist] A subpath surviving is not the same as its exports surviving. These paths still resolve, so only the named import fails. | v1 | v2 | Migration | What you see | Notes | | -------------------------- | ------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `superdoc/ui type exports` | `superdoc/ui` | Redesign | The import path still resolves, so only the named type fails. TypeScript reports the missing name; a plain JavaScript build sees nothing because these are type-only. | `ContextMenuContribution`, `ContextMenuWhenInput`, `DocumentExportInput`, `DynamicCommandHandle`, `PublicToolbarItemId`, `SelectionAnchorRectOptions`, `TextSegment`, `ToolbarCommandHandleState`, `UIToolbarCommandState`, `ViewportContextAtInput`, `ViewportEntityAtInput`, `ViewportPositionAtInput`, `ViewportPositionHit`, `ZoomMode`, `ZoomViewportMetrics`. v2 rebuilt `superdoc/ui` as a native controller rather than re-exporting v1, so these 15 type names did not carry over. Use the controller types v2 publishes from the same path. [Read more](/editor/custom-ui/overview) | ## Your application imports successfully, then fails when it runs [#your-application-imports-successfully-then-fails-when-it-runs] These compile. Some of them typecheck against the v2 configuration. They fail, or do nothing, once the editor is running. | v1 | v2 | Migration | What you see | Notes | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `editor.commands` | `superdoc.activeEditor.doc` | Redesign | A null-property error at the point of use. The message names the command, not SuperDoc, so it does not read as a migration failure. | The v2 facade exposes `commands` as null rather than removing it. Optional chaining converts the throw into a silent no-op, which is harder to notice. [Read more](/document-api/mental-model) | | `editor.state` | `superdoc.activeEditor.doc` | Redesign | A null-property error, or `undefined` when accessed with optional chaining. | There is no ProseMirror document model in v2. Positions expressed as numeric offsets have no equivalent; use addresses and targets. [Read more](/document-api/query-content) | | `editor.view` | *None* | No equivalent | A null-property error, or `undefined` when accessed with optional chaining. | v2 renders through its own engine and exposes no editor view or mutable DOM. | | `superdoc.getDocumentRuntimeForDocument(documentId)` | `superdoc.ui` | Redesign | The lookup returns `null`, and application code that treats the renderer as the ready UI surface throws an error such as `DocumentRuntime is not available`. | V2 custom UI does not need to retrieve the renderer runtime. Read the instance-owned `superdoc.ui` controller after `onReady`; it publishes reactive feature state and routes UI actions without exposing the renderer object. `superdoc.ui` is a getter, not an import. The instance creates it once and owns its cleanup. [Read more](/editor/migrate-from-v1/overview) | | `documentRuntime.scrollToElement(elementId)` | `superdoc.ui.*.scrollTo / superdoc.scrollToElement` | Redesign | The preceding runtime lookup returns `null`, so navigation throws or is skipped even though the document is mounted. | Route known entities through their feature handle: `ui.trackChanges.scrollTo(changeId)`, `ui.comments.scrollTo(commentId)`, or `ui.contentControls.scrollIntoView({ id })`. These methods resolve through the public host navigation surface and can reveal virtualized pages. Use `superdoc.scrollToElement(elementId)` when all you have is a stable block, comment, or tracked-change id. The root facade keeps the renderer object private and returns `false` when navigation cannot be routed. [Read more](/editor/migrate-from-v1/overview) | | `editor.view.dispatch(tr.setSelection(...))` | `superdoc.ui.selection.apply` | Redesign | A null-property error on `view`, or `undefined` under optional chaining. | Setting the selection programmatically is supported. Pass a `SelectionTarget` to `selection.apply()` rather than dispatching a transaction. Read it from `selection.current().selectionTarget` or a query result; there is no ProseMirror position to hand it. [Read more](/editor/custom-ui/selection-and-viewport) | | `editor.view.posAtCoords` | `superdoc.ui.viewport.entityAt` | Redesign | A null-property error on `view`, or `undefined` under optional chaining. | Partial. `viewport.entityAt({ x, y })` resolves the public entities painted under a point (tracked changes, comments, content controls), innermost first, and returns `[]` over plain text. That covers hit-testing, which is what most `posAtCoords` callers wanted, but v2 ships no general point-to-document-position resolver, so a caller that needs an arbitrary position under the cursor has no equivalent. The legacy positional form `entityAt(x, y)` fails closed and returns `null`. [Read more](/editor/custom-ui/selection-and-viewport) | | `editor.view.coordsAtPos` | `superdoc.ui.viewport.getRect` | Redesign | A null-property error on `view`, or `undefined` under optional chaining. | The opposite direction from `entityAt`, and a different surface. `viewport.getRect({ target })` resolves painted geometry for a public target and returns `{ found, rects, rect, reason }`. It is keyed on a target, not a numeric position, so obtain one from `selection.capture()` or a query result first. [Read more](/editor/custom-ui/selection-and-viewport) | | `manual tracked-change traversal through editor.state` | `superdoc.ui.trackChanges.navigateNext` | Redesign | A null-property error on `state`, or `undefined` under optional chaining. | Review navigation is supported. `trackChanges.navigateNext()` moves through changes and `trackChanges.list()` reads the catalog. The catalog resolves asynchronously, so observe the slice until `status` is `ready` before consuming `items`. [Read more](/editor/custom-ui/tracked-changes) | | `editorExtensions` | `extensions` | Redesign | The config typechecks and the editor mounts, but the extensions never run. SuperDoc logs a console warning naming the field. | A legacy ProseMirror concept that v2 ignores. Rebuild against `extensions` with `defineSuperDocExtension`; extensions defining custom nodes or marks may have no equivalent. [Read more](/editor/custom-ui/custom-commands) | | `modules.collaboration` | `document.v2Collaboration` | Redesign | The config typechecks, then SuperDoc refuses to attach the provider and reports a terminal compatibility failure. | v2 owns the provider and Y.Doc. This is a data migration, not a config change: v2 rooms use a different format and must not be pointed at an existing v1 room. [Read more](/editor/migrate-from-v1/overview) | | `editor.commands.replaceWithFieldAnnotation (citation)` | `doc.citations.insert` | Redesign | A null-property error on `commands`, or a silent no-op under optional chaining. | `editor.commands.replaceWithFieldAnnotation`. One v1 node type did two jobs and v2 splits them, so decide which this annotation was before porting it. This row is the citation branch: a marker backed by a source record is `citations.sources.insert()` followed by `citations.insert({ at, sourceIds })`, where `sourceIds` carries the canonical `sourceId` from the receipt rather than the source tag. Not a rename, and not a replacement either: v1 replaced its `from`/`to` range, while `citations.insert` resolves `at`, then prepends the field to the start of the containing paragraph and reports offset 0. The selected text is left where it was. Delete the range yourself if the citation is meant to stand in for it, and do not expect the citation to land at the offset you passed. If the annotation was application-owned data rather than a citation, use the `metadata.attach` row instead. [Read more](/document-api/reference/citations/insert) | | `editor.commands.replaceWithFieldAnnotation (application annotation)` | `doc.metadata.attach` | Redesign | A null-property error on `commands`, or a silent no-op under optional chaining. | `editor.commands.replaceWithFieldAnnotation`. The other half of the same v1 call. An annotation carrying application-owned data anchored to a range is `metadata.attach({ id, namespace, target, payload })`, and the entries it creates are not v1 field annotations: the ids, the payloads, and the anchoring model all differ, so treat this as remodelling rather than a rename. Read them back with `metadata.list({})` and `metadata.get({ id })`. If the annotation was a citation with a source record, use the `citations.insert` row instead. [Read more](/document-api/reference/metadata/attach) | | `editor.state.doc.descendants (citation nodes)` | `doc.citations.list` | Redesign | A null-property error on `state`, or `undefined` under optional chaining. | Reading citations no longer means walking a node tree. `citations.list()` returns the rows with their addresses and `sourceIds`. Item order can drive application-owned numbering, but that produces local UI labels only: it does not change the citation field result stored in the DOCX. The call is synchronous in some hosts and asynchronous in others, so shared browser and headless code should normalize with `await Promise.resolve(doc.citations.list())`. [Read more](/document-api/reference/citations/list) | | `editor.helpers.fieldAnnotations.getAll()` | `doc.metadata.list` | Redesign | A null-property error on `helpers`, or `undefined` under optional chaining. | A shape change, not a rename. `getAll()` returned v1 field annotations; `metadata.list({})` returns anchored-metadata entries whose ids, payloads, and anchoring model all differ. `metadata.list` is the discovery call and `metadata.get({ id })` reads one payload, so pair them when you need the collection. `get` returns null for an entry that disappeared between the two calls. Treat legacy field annotations as something to remodel rather than something this call returns. [Read more](/document-api/reference/metadata/list) | | `tr.setNodeMarkup(pos, undefined, { resolvedText })` | `doc.fields.insert` | Redesign | A null-property error reaching the transaction. Citations inserted through the citations API persist and export, but render with no visible text. | The citations API cannot do this: `CitationInsertInput` accepts only `at` and `sourceIds`, `CitationUpdateInput.patch` accepts only `sourceIds`, and `displayText` is on `CitationInfo` as a read with no writer. `citations.bibliography.rebuild` is not it either; it rebuilds the bibliography from current sources at a `BibliographyAddress` and never touches inline citation text. The one public path is a level lower: `fields.insert({ at, instruction, mode: "raw", updatePolicy: "preserveCached", cachedResultText })` writes the visible result directly. Three costs come with it. You author the field instruction and keep the source records in step yourself, so you are outside the citation model rather than extending it. `fields.rebuild` recalculates a field and discards the preserved result. And the contract records `preserveCached` returning `CAPABILITY_UNAVAILABLE` on v1-backed sessions, so confirm it on your target runtime before depending on it. There is still no way to change the visible text of a citation the citations API already inserted. [Read more](/document-api/reference/fields/insert) | | `.superdoc-text-run[data-pm-start] (citation pill styling)` | `ctx.visuals.highlight` | Redesign | Nothing breaks, which is the problem. The painter still stamps `data-pm-start` on runs, so the selector keeps matching and the code keeps mutating renderer-owned DOM. | This one does not fail on upgrade. `layout-engine/dom-contract` stamps `data-pm-start` and `data-pm-end` alongside the editor-neutral attributes specifically so v1 consumers keep working, and `.superdoc-text-run` is still the painted class. What you lose is the guarantee: the DOM shape is renderer-owned and carries no stability contract, so this is worth migrating before a paint change breaks it silently rather than after. The supported replacement is a painted class through `ctx.visuals.highlight(id, { className })`, or `ctx.decorations.register` underneath it. A `CitationAddress` is not itself a visual target, but converting one is mechanical rather than blocked, because both sides use the same coordinate system. `CitationAddress.anchor` is an `InlineAnchor` of `{ blockId, offset }` positions, and `Position.offset` is a 0-based offset into the block's flattened text, which is what `SuperDocTextAddress.range` expects: `{ kind: "text", blockId: anchor.start.blockId, range: { start: anchor.start.offset, end: anchor.end.offset } }`. The real limit is the class. `className` accepts paint properties only: background, color, text-decoration, outline, and box-shadow, explicitly not padding, margin, display, or position. Recolouring citation text works; a padded pill shape does not. Decorations are render-only and never reach the DOCX. | | `superdoc.on('fieldAnnotationClicked')` | *None* | No equivalent | The subscription is accepted and the handler never fires. | v2 emits no per-entity click events, and nothing recovers the identity this one carried. `superdoc.ui.viewport.entityAt` is the supported point lookup, but it collects only tracked changes, comments, and content controls, so it cannot return a field annotation and is not a replacement for this event. Whether hit-testing helps at all depends on what the annotation became: content controls are returned, citations and anchored metadata are not. Decide the v2 model for the annotation first, then pick the lookup that model supports. | | `editor.state.selection` | `superdoc.ui.selection.current` | Redesign | A null-property error on `state`, or `undefined` under optional chaining. | A v1 `Selection` was one live object used for both reading and preserving, and v2 splits those. To read the current selection use `selection.current()` or the observed snapshot; both describe a collapsed caret. To preserve a selection across something that moves focus, use `capture()` and then `restore()`. Do not reach for `capture()` as the general read: it returns null whenever the selection is empty, which includes a collapsed caret, so caret-dependent code migrated onto it loses the selection it was trying to keep. Either way the payload is document addresses rather than numeric positions. [Read more](/editor/custom-ui/selection-and-viewport) | | `document.getSelection().getRangeAt(0).getBoundingClientRect()` | `superdoc.ui.selection.getAnchorRect` | Redesign | The browser selection reflects rendered DOM rather than document state, so the rectangle is wrong or the call throws on an empty range. | `getAnchorRect()` resolves one rectangle from the document selection against the painted layout, and accepts `'start'`, `'end'`, or `'center'`. v1's `'union'` placement has no v2 value: use `selection.getRects()` when a bubble needs the full multi-line union. | | `document.querySelector('[data-comment-id="..."]')` | `superdoc.ui.viewport.getRect` | Redesign | Depends on the sidebar, which is the trap. With the built-in comments UI enabled the selector matches a sidebar card and positions against the wrong element; with it disabled the same selector matches nothing. | The painter stamps no singular `data-comment-id` on document content, but `FloatingComments.vue` binds one on each sidebar placeholder, so an integration running the built-in comments UI gets a match that is a panel card rather than the painted anchor. Code that scrolls or positions from it silently targets the sidebar. Turn the sidebar off and the same selector returns null, so the failure changes shape with configuration rather than being absent. There is a second near miss: the painter stamps `data-comment-ids`, plural and comma-separated, on `.superdoc-comment-highlight`, and retargeting the selector there puts you back on renderer-owned DOM with no stability contract. Read the comment through `ui.comments.getById()` and resolve its target with `viewport.getRect({ target })` instead. Treat a `null` from `getById()` as "not loaded yet" rather than "no such comment": it checks the loaded snapshot, then falls back to the Document API, and returns `null` when that fallback is a pending promise, which is the normal browser case. It does not prime the read either, unlike `contentControls.getById()`, and `comments.list()` returns `[]` on the same path. Gate on the observed comments slice, and gate on `listStatus` rather than `status`: `status` folds in the live selection read, so an unrelated selection change holds it at `pending` while the list is ready. Do not cache the rectangle as identity: geometry changes on scroll, zoom, resize, and pagination, and `viewport.observe()` is the signal to measure again. [Read more](/editor/custom-ui/selection-and-viewport) | | `document.querySelector('[data-track-change-id="..."]').scrollIntoView()` | `superdoc.ui.trackChanges.scrollTo` | Redesign | Usually a match, and a `TypeError` when not. The call as written dereferences the result, so a change that is not painted takes down whatever runs it, and the same code works in a short document and throws in a long one. | This survives the upgrade, which is why it is worth migrating deliberately. The v2 painter still stamps `data-track-change-id` on painted runs, so the selector keeps resolving and nothing announces that it has become unreliable. It misses in two different ways. A change on a page that is not currently painted has no element at all, because `querySelector` sees only mounted DOM and virtualization leaves the rest of the document unrendered. And where several changes affect one marker, only one id lands in the singular attribute: the rest are in `data-track-change-ids`, comma-separated, so a secondary overlapping change is unreachable by the selector even while it is on screen. Both misses reach `.scrollIntoView()` on `null` and throw, unless the call is guarded. The attribute is renderer-owned and carries no stability contract either. `trackChanges.scrollTo(changeId)` takes the id rather than a selector, mounts the target page when it has to, resolves overlapping changes through the same catalog the review UI uses, and reports whether it succeeded. [Read more](/editor/custom-ui/tracked-changes) | | `new MutationObserver(...) on the editor element` | `superdoc.ui.viewport.observe` | Redesign | The observer attaches and fires on repaints that do not correspond to document changes, or never fires for layout changes that produce no mutations. | Watching the rendered DOM was always a proxy for the question consumers were asking. `viewport.observe()` fires when painted geometry is invalidated, including scroll, zoom, resize, pagination, and virtualized page mounts, which a subtree MutationObserver either misses or floods on. [Read more](/editor/custom-ui/selection-and-viewport) | | `element.scrollIntoView() on a content-control element` | `superdoc.ui.contentControls.scrollIntoView` | Redesign | There is no element to hold: the query that produced it matches nothing, so the call throws or is skipped. | Address the control by id rather than by element: `contentControls.scrollIntoView({ id, block })` resolves it through the host, including on pages that are not currently painted. A DOM-held reference cannot survive virtualization. | | `editor.comments.filter((c) => c.isTrackedChange)` | `superdoc.ui.trackChanges.list` | Redesign | A null-property error on `comments`. Once ported, the more damaging failure is quiet: a document full of tracked changes reports none. | v2 separates tracked changes from comments instead of tagging one list. The trap is readiness, not the split: the catalog is an async read, so a synchronous `list()` on a controller that has not settled returns an empty snapshot rather than an error. Observe the slice and consume `items` once `status` is `ready`. `total` and `authors` come from the same slice and carry the same caveat. [Read more](/editor/custom-ui/tracked-changes) | | `editor.presentationEditor` | `superdoc.activeEditor.pageMetrics` | Redesign | A null-property error on `presentationEditor`, or `undefined` under optional chaining. | `presentationEditor.getPages`, `presentationEditor.onLayoutUpdated`, `presentationEditor.element`. Page metrics survive as a capability. `pageMetrics` exposes `getSnapshot`, `subscribe`, `setZoom`, `scrollToPage`, `revealBodyTarget`, and `pageIndexForBodyTarget`, and `ui.viewport.getHost()` replaces reading `presentationEditor.element`. The facade types `pageMetrics` as `unknown`, so consuming it means declaring the shape yourself and re-verifying it on upgrade; it is not part of the typed public surface the rest of this catalog points at. | | `.superdoc-page[data-page-index] (page element lookup)` | `superdoc.activeEditor.pageMetrics` | Redesign | Usually nothing, which hides the two cases that matter. The painter still stamps `data-page-index` on `.superdoc-page`, so the selector keeps matching for mounted pages and returns null for virtualized ones. | The DOM lookup survives the upgrade rather than breaking, so this migrates on correctness rather than on a build error. Page windowing means only mounted pages have elements, and a page outside the window returns null from the selector while still having metrics. Read the page from the metrics snapshot instead. The same untyped-surface caveat applies as for `presentationEditor`, and the painted DOM carries no stability contract even where it currently answers. | | `onTransaction` | `defineSuperDocExtension + ctx.onMutation` | Redesign | The config typechecks and the editor mounts, and the callback never fires. | v2 has no transactions to hand back. Declare an extension and subscribe through its activation context: `ctx.onMutation({ origin, sourceComplete, affects }, handler)`. The filter is the point of the redesign, because a v1 handler had to inspect every transaction and decide whether it mattered. | | `editor.chain().insertPageBreak().run()` | `doc.create.paragraph + doc.format.paragraph.setFlowOptions` | Redesign | A null-property error on `chain`, or `undefined` under optional chaining. | Not a behavioral equivalent, and the difference decides the port. v1 broke at the caret. v2 creates a paragraph at an address you supply and sets `pageBreakBefore` on it, which starts that paragraph on a new page rather than inserting a standalone break character. A mid-paragraph caret has no address of its own, so code that broke mid-paragraph must split the paragraph first or accept a block boundary. Await the creation: its returned address is the target of the formatting call. [Read more](/document-api/reference/format/paragraph/set-flow-options) | | `doc.extract({}) / doc.getMarkdown({}) / doc.selection.current({}) read synchronously` | `the same operations, awaited` | Redesign | The call returns a Promise where the code expected a value, so reads look empty and downstream property access yields `undefined`. | The v2 browser facade can route reads and mutations through a worker even when the underlying operation is synchronous in headless Node, so the same code is sync in one host and async in the other. Await browser calls, or normalize shared browser and headless code with `await Promise.resolve(call)`, and keep author overrides or other scoped state active until an awaited mutation settles. [Read more](/document-api/mental-model) | | `ui.createScope().register(...)` | `superdoc.ui.commands.register` | Redesign | A null-property error on `createScope`, or `undefined` under optional chaining. | Register on the instance-owned controller: `superdoc.ui` is created once on first read and reused, and its type omits `destroy()` because the instance owns teardown. The registration result is callable and also exposes `unregister()`; keep either and call it on unmount. Command callbacks receive the public `doc`, `ui`, selection, and mode rather than ProseMirror command state, and the selection should be read from `context.selection`, which is synchronous and always present, not through the deep-partial `context.doc`. [Read more](/editor/custom-ui/custom-commands) | | `editor.on('selectionUpdate')` | `superdoc.ui.selection.subscribe` | Redesign | The subscription is accepted and the handler never fires. | `subscribe` fires immediately and passes `{ snapshot }`; `observe` is the value-shaped alias. Both return an unsubscribe function, where v1 required a matching `off`. The payload is a public selection slice with document addresses, not a ProseMirror selection, so handlers that read positions have to be rewritten rather than rebound. | | `trackedChange.authorColor` | `superdoc.ui.trackChanges.list` | Redesign | Present on some rows and absent on others. Rows without it render `undefined` as a color, and nothing errors. | Conditional rather than removed. The row projection passes the v2 tracked-changes facade fields through, so `list()` returns `authorColor` for rows whose host supplies one. It is not declared on `TrackChangeInfo`, so it is not part of the typed public surface and reading it means asserting the shape yourself. `trackChanges.authors` is flattened to `readonly string[]` and drops the per-author color the facade returns, so it is not the fallback either. Keep an application-owned default rather than assuming the field is there, and do not mutate the readonly slice arrays to add one. [Read more](/editor/custom-ui/tracked-changes) | | `editor.view.dom.addEventListener('paste', ...)` | `the element your application passed as the SuperDoc host` | Redesign | A null-property error on `view`, or `undefined` under optional chaining. | The capability survives as ordinary DOM work, but the element does not: v2 owns and re-paints its internal DOM, so attach the listener to the stable host element you passed to SuperDoc and remove it on unmount. A bubbling listener is enough when validation only needs `preventDefault()`. This guards browser input only; programmatic Document API mutations emit no DOM paste events. | The editor internals are the harder half. v2 exposes `commands`, `state`, and `view` as `null` rather than removing them, so reading a property off one raises a generic null-property error that names your command and not SuperDoc. Optional chaining is worse: it turns the same mistake into a silent no-op that never errors at all. Exact error wording varies by browser, bundler, and minifier. Treat the "What you see" column as symptoms to recognize, not as strings to match on. ## Before you start [#before-you-start] Collaboration is not a configuration change. v2 rooms use a different document format, v2 owns the provider and `Y.Doc`, and a v2 editor must never be pointed at an existing v1 room. If your application uses collaboration, plan that migration separately before upgrading anything else. Continue with the [v1 migration guide](/editor/migrate-from-v1/overview) for the full upgrade path, or the [Document API mental model](/document-api/mental-model) to understand what replaces direct editor access. --- # Commands and state > Keep custom controls synchronized with the Editor and handle command results explicitly. A custom control has two responsibilities: render the current command state and route an action back through the same command handle. SuperDoc owns the state because availability can change with the selection, document mode, history, and document content. Start with the [framework-neutral controller example](/editor/custom-ui/controller-setup) or [React setup](/editor/custom-ui/react-setup). Both use the contract described here. ## Read the state you need [#read-the-state-you-need] Each command exposes a small state object: | Field | Use it for | | ----------- | ------------------------------------------------------------------------ | | `enabled` | Disable the control when the command cannot run in the current context | | `active` | Show toggle state for formatting, lists, links, and similar controls | | `value` | Show a command-specific value such as document mode, zoom, font, or link | | `reason` | Explain why a recognized command is disabled | | `supported` | Distinguish a routed command from an unknown or unsupported command | Use `getState()` for the initial framework-neutral value and `observe()` for updates. In React, `useSuperDocCommand(id)` performs both steps and rerenders the component when the state changes. Do not infer these fields from the document DOM. The canvas is the rendered document, not the public command-state boundary. ## Keep unavailable controls understandable [#keep-unavailable-controls-understandable] Disable a control when `enabled` is false. Use `reason` in a tooltip or nearby explanation when the cause is useful to the person editing. A disabled command often needs context rather than removal. Bold needs a text selection. Undo needs document history. Table actions need a table selection. Hiding those controls can make a stable toolbar appear to change unpredictably. Remove a control when the workflow does not need it. Disable it when the workflow needs it but the current context does not allow it. ## Await actions that affect later work [#await-actions-that-affect-later-work] Use `executeAsync()` when saving, navigating, or running another action depends on the command finishing. The result can be: * `false` when the controller could not route the command. * `true` when the host reports completion without a structured receipt. * A receipt object when the command routes through a document operation. For a receipt, inspect `success` before continuing. Do not treat a resolved promise or the absence of an exception as proof that the document changed. ## Treat values as command-specific [#treat-values-as-command-specific] `value` is intentionally not one universal type. A document-mode control reads a mode string. Zoom reads a percentage. A font control reads a font value. A link control can expose the active URL. Pass only the payload documented for that command. Do not reuse a value from one command as another command's payload. ## Choose commands for the workflow [#choose-commands-for-the-workflow] `BUILT_IN_COMMAND_IDS` from `superdoc/ui` provides the stable core command ids. `ui.commands.ids` lists the commands recognized by the bound controller, including registered custom commands. `ui.commands.has(id)` checks one id. Do not render every recognized command automatically. Choose the controls the product task needs, then use command state to keep those controls truthful. The exhaustive command reference remains generated-source work. Until it is integrated into this site, use the focused examples in this section instead of copying the old command matrix. Continue with [React custom UI setup](/editor/custom-ui/react-setup), or compare custom controls with the [configured built-in toolbar](/editor/built-in-ui/configure-the-toolbar). --- # Build a custom comments UI > Render comment threads from reactive Editor state and run comment actions through the public UI controller. Use `ui.comments` when your application owns the comments panel. The handle provides reactive thread state, selection-aware creation, focus and navigation, and mutation receipts. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This page uses the same ready-Editor and cleanup lifecycle. ## Add the comments surface [#add-the-comments-surface] Create a composer, status message, thread list, and Editor container: ```html
``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX. ## Bind state and actions [#bind-state-and-actions] Read the Editor's controller, `superdoc.ui`, after `onReady`. Observe selection and comments separately so each part of the interface updates from its canonical state. ```ts import { SuperDoc } from 'superdoc'; import type { CommentsSlice, SelectionCapture, SelectionSlice } from 'superdoc/ui'; import 'superdoc/style.css'; const commentText = document.querySelector('#comment-text'); const addComment = document.querySelector('#add-comment'); const commentList = document.querySelector('#comment-list'); const commentsStatus = document.querySelector('#comments-status'); if (!commentText || !addComment || !commentList || !commentsStatus) { throw new Error('The comments UI is incomplete.'); } let capturedSelection: SelectionCapture | null = null; let stopSelection: (() => void) | null = null; let stopComments: (() => void) | null = null; let removeHandlers: (() => void) | null = null; const updateComposer = () => { addComment.disabled = !capturedSelection || commentText.value.trim().length === 0; }; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', user: { name: 'Alex Rivera', email: 'alex@example.com', }, // This application owns the comments presentation, so turn SuperDoc's own // off. It removes interface only: threads in the DOCX are still parsed, and // the composer below still creates, resolves, and reopens them through the // controller. The rest of SuperDoc's built-in surfaces stay, because this // example replaces the comments panel and nothing else. modules: { comments: false, }, onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const renderSelection = (selection: SelectionSlice) => { if (!selection.empty) capturedSelection = ui.selection.capture(); updateComposer(); }; const renderComments = (comments: CommentsSlice) => { commentsStatus.textContent = comments.status === 'pending' ? 'Loading comments…' : `${comments.total} comments`; commentList.replaceChildren(); for (const comment of comments.items) { const row = document.createElement('li'); const body = document.createElement('span'); const show = document.createElement('button'); const resolve = document.createElement('button'); body.textContent = comment.text || 'Comment without text'; show.type = 'button'; show.textContent = 'Show'; show.addEventListener('click', async () => { ui.comments.setActive(comment.id); const result = await ui.comments.scrollTo(comment.id); if (!result.success) commentsStatus.textContent = result.reason ?? 'The comment could not be shown.'; }); resolve.type = 'button'; resolve.textContent = comment.status === 'resolved' ? 'Reopen' : 'Resolve'; resolve.addEventListener('click', async () => { const receipt = comment.status === 'resolved' ? await ui.comments.reopen(comment.id) : await ui.comments.resolve(comment.id); if (!receipt.success) commentsStatus.textContent = receipt.failure.message; }); row.append(body, show, resolve); commentList.append(row); } }; const createComment = async () => { if (!capturedSelection) return; const receipt = await ui.comments.createFromCapture(capturedSelection, { text: commentText.value.trim() }); if (!receipt.success) { commentsStatus.textContent = receipt.failure.message; return; } commentText.value = ''; capturedSelection = null; updateComposer(); }; renderSelection(ui.selection.getSnapshot()); renderComments(ui.comments.getSnapshot()); stopSelection = ui.selection.observe(renderSelection); stopComments = ui.comments.observe(renderComments); commentText.addEventListener('input', updateComposer); addComment.addEventListener('click', createComment); removeHandlers = () => { commentText.removeEventListener('input', updateComposer); addComment.removeEventListener('click', createComment); }; }, }); window.addEventListener('beforeunload', () => { stopSelection?.(); stopComments?.(); removeHandlers?.(); superdoc.destroy(); }); ``` The example uses four parts of the public comments handle: * `observe()` updates the list when comments or their status change. * `createFromCapture()` anchors a new thread after the composer takes focus. * `resolve()` and `reopen()` change the thread lifecycle and return receipts. * `setActive()` and `scrollTo()` coordinate the custom list with the document canvas. It also mounts with `modules.comments: false`. Without it, SuperDoc renders its own comments sidebar beside yours and the reader gets two comment interfaces on one document. Turning it off removes the built-in presentation only: threads in the DOCX are still parsed, and every action above still works through the controller. That is the only surface this example switches off, because the comments panel is the only one it replaces. [Custom UI controller setup](/editor/custom-ui/controller-setup) covers the rest for an application that owns more of the interface. ## Choose a live or captured selection [#choose-a-live-or-captured-selection] The example captures the document selection as soon as it becomes available. This matters because focusing the textarea can clear the live Editor selection. Use `createFromSelection()` only when the action runs while the Editor selection is still live: ```ts const receipt = await ui.comments.createFromSelection({ text: 'Please verify this clause.', }); if (!receipt.success) console.error(receipt.failure.message); ``` For a modal, textarea, or detached composer, use `ui.selection.capture()` followed by `createFromCapture()`, as the complete example does. Do not reconstruct a comment target from DOM ranges. The capture carries a document address that remains meaningful when layout changes. [Preserve selections and position UI](/editor/custom-ui/selection-and-viewport) covers the complete focus and geometry lifecycle. ## Handle failures in the interface [#handle-failures-in-the-interface] Comment actions can fail because the Editor is not ready, the selection is empty, the target is stale, or the operation is unavailable. Keep the composer open and show the receipt message when an action fails. Client-side controls are not an authorization boundary. Enforce document access and trusted identity outside the Editor. For direct document operations without a custom panel, continue with [Document API comments](/document-api/comments). --- # Build a content-control panel > List document fields, move people to them, and update text controls from application-owned UI. Content controls are structured fields stored in a DOCX. Use `ui.contentControls` to keep an application-owned panel synchronized with the Editor. Use the Document API to change a control's content or properties. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This example expects `/contract.docx` to contain at least one plain-text content control. ## Add the field panel [#add-the-field-panel] Create a status message, field list, and Editor container: ```html
``` ## Bind navigation and editing [#bind-navigation-and-editing] Read the Editor's controller, `superdoc.ui`, after the Editor and Document API are ready: ```ts import { SuperDoc } from 'superdoc'; import type { ContentControlsSlice } from 'superdoc/ui'; import 'superdoc/style.css'; const controlList = document.querySelector('#control-list'); const controlsStatus = document.querySelector('#controls-status'); if (!controlList || !controlsStatus) { throw new Error('The content-control UI is incomplete.'); } let stopContentControls: (() => void) | null = null; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const doc = readySuperDoc.activeEditor?.doc; if (!doc) throw new Error('The Document API is not ready.'); const ui = readySuperDoc.ui; const focusControl = async (id: string) => { const result = await ui.contentControls.focus({ id, block: 'center', behavior: 'smooth' }); if (!result.success) controlsStatus.textContent = `The field is ${result.reason}.`; }; const updateTextControl = async (id: string, value: string) => { const control = ui.contentControls.get({ id }); if (!control || control.controlType !== 'text') { controlsStatus.textContent = 'This field is no longer an editable text control.'; return; } const receipt = await doc.contentControls.text.setValue({ target: control.target, value }); if (!receipt.success) { controlsStatus.textContent = receipt.failure.message; return; } controlsStatus.textContent = 'Field updated.'; }; const render = (controls: ContentControlsSlice) => { controlList.replaceChildren(); controlsStatus.textContent = controls.status === 'pending' ? 'Loading content controls…' : `${controls.total} document fields`; for (const control of controls.items) { const row = document.createElement('li'); const label = document.createElement('span'); const show = document.createElement('button'); label.textContent = control.properties.alias ?? control.properties.tag ?? control.controlType; show.type = 'button'; show.textContent = controls.activeIds.includes(control.id) ? 'Focused' : 'Show'; show.addEventListener('click', () => void focusControl(control.id)); row.append(label, show); if (control.controlType === 'text') { const value = document.createElement('input'); const update = document.createElement('button'); value.type = 'text'; value.value = control.text ?? ''; value.setAttribute('aria-label', `Value for ${label.textContent}`); update.type = 'button'; update.textContent = 'Update'; update.addEventListener('click', () => void updateTextControl(control.id, value.value)); row.append(value, update); } controlList.append(row); } }; ui.contentControls.list(); stopContentControls = ui.contentControls.observe(render); }, }); window.addEventListener('beforeunload', () => { stopContentControls?.(); superdoc.destroy(); }); ``` The example uses two public surfaces for different jobs: * `ui.contentControls` observes the loaded list, reports controls at the current selection, focuses a known control, and keeps navigation aligned with the document canvas. * `doc.contentControls.text.setValue()` performs the document mutation and returns the receipt. Do not find controls by querying rendered DOM attributes. The list item contains the stable control ID, mutation target, type, lock mode, properties, and an optional selection target. ## Check the control type before editing [#check-the-control-type-before-editing] `text.setValue()` applies to plain-text controls. Date, checkbox, choice-list, repeating-section, and rich-text controls have different operations and constraints. Branch on `controlType` and expose only actions that match the current control. Read `lockMode` before presenting an edit affordance, but still inspect the mutation receipt. The document can change between the list read and the update. ## Keep the panel reactive [#keep-the-panel-reactive] Call `list()` when opening the panel to request the catalog, then keep `observe()` active. The snapshot reports `pending`, `stale`, or `ready` state as the document loads and changes. `focus()` scrolls to the control and places the caret inside it when the host can resolve its selection target. It does not bypass a content lock or viewing mode. Focus is navigation. The later mutation still decides whether editing is allowed. Verify the workflow by updating one plain-text control, exporting the DOCX, and reopening it. The new value should remain attached to the same content control. Continue with [Load and save documents](/editor/load-and-save-documents). Use the generated Document API reference for checkbox, date, choice-list, metadata, binding, and structural operations. --- # Custom UI controller setup > Bind a custom control to live SuperDoc command state and execute it safely. This guide creates one application-owned Bold button over the public `superdoc/ui` controller. The button follows live selection state, executes the real Editor command, and cleans up with the Editor. Complete the [Editor quickstart](/editor/quickstart) first if you have not mounted a v2 Editor before. ## Add the HTML surface [#add-the-html-surface] Create the button and an editor container: ```html
``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or update the example to use another DOCX URL. ## Bind the controller after readiness [#bind-the-controller-after-readiness] Use this as `src/main.ts`: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const boldButton = document.querySelector('#bold'); if (!boldButton) throw new Error('The Bold button is missing.'); let stopObserving: (() => void) | null = null; let removeClickHandler: (() => void) | null = null; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const bold = readySuperDoc.ui.commands.get('bold'); const render = (state: ReturnType) => { boldButton.disabled = !state.enabled; boldButton.setAttribute('aria-pressed', String(state.active)); boldButton.title = state.reason ?? 'Toggle bold'; }; const onBoldClick = async () => { const result = await bold.executeAsync(); if (result === false) console.warn('Bold is not available for the current selection.'); }; render(bold.getState()); stopObserving = bold.observe(render); boldButton.addEventListener('click', onBoldClick); removeClickHandler = () => boldButton.removeEventListener('click', onBoldClick); }, }); window.addEventListener('beforeunload', () => { stopObserving?.(); removeClickHandler?.(); // Tears the controller down too. Never call `superdoc.ui.destroy()` yourself. superdoc.destroy(); }); ``` Every SuperDoc instance owns one controller at `superdoc.ui`. Read it as many times as you like; you always get the same object, and so does the built-in toolbar if you render one. Reading it before the document is ready is safe, but the command handle only reports real state once the Editor is, which is why the binding happens in `onReady`. The instance owns the controller's lifecycle. `superdoc.destroy()` tears it down, and `superdoc.ui` is typed as the borrowed handle `BorrowedSuperDocUI`, which has no `destroy()` at all. Calling it is a compile error rather than a rule to remember, because doing so would freeze command state for every other part of your interface still reading it. > **When you need a separate controller (note)** > > `createSuperDocUI()` from `superdoc/ui` builds an independent controller that you own and must destroy. It exists for > cases like driving a detached preview from its own state. Application UI should read `superdoc.ui` instead. ## Remove the built-in surfaces [#remove-the-built-in-surfaces] This guide's Bold button replaces one toolbar control, so omitting `toolbar` is all it needs. As your interface takes over more of the document experience, each built-in surface has its own switch: * Omitting `toolbar` means no built-in toolbar is rendered. * `modules: { comments: false }` removes the built-in comments interface: the sidebar, floating threads, and comment dialog. * `disableContextMenu: true` removes the built-in right-click and slash menus. Take only the switches whose surface you actually replace. They are independent, and each removes one presentation and nothing else, so turning off a surface you have not rebuilt leaves the reader with less than SuperDoc would have given them. The [custom comments UI](/editor/custom-ui/comments) shows the comments switch used the right way round: it mounts with `modules: { comments: false }` because it renders its own panel, and leaves everything else in place. This is also not every surface SuperDoc can render. Clicking a link, for example, still opens the built-in link popover; none of the switches above covers it. The underlying capabilities stay available through `superdoc.ui`. Comment threads in the DOCX are still parsed and readable through `superdoc.ui.comments` with the module off, and `resolve` and `reopen` still commit. These are presentation switches, not permission or data switches. See [built-in comments](/editor/built-in-ui/comments) for the full distinction. ## Follow command state [#follow-command-state] `getState()` provides the initial state. `observe()` keeps the button synchronized as the selection and document change: * `enabled` controls whether the action can run. * `active` indicates whether the current selection is bold. * `reason` explains why a known command is disabled. This is more reliable than deriving state from rendered DOM nodes. The document canvas is presentation; the controller is the public UI state boundary. ## Execute and inspect the result [#execute-and-inspect-the-result] The click handler calls `executeAsync()`. A `false` result means the command did not run. A mutation receipt should be inspected before starting dependent work. For a complete toolbar, repeat this pattern with command IDs exposed by `superdoc/ui`, but keep the first implementation focused on controls the workflow actually needs. > **Verification target (success)** > > Select text in the DOCX. The Bold button should become enabled, reflect the active formatting state, and toggle bold > without reading or changing the document DOM directly. Build the next control with the same state-and-command pattern, or return to [Choose your editor interface](/editor/ui/choose-an-interface) before expanding the toolbar. --- # Register custom commands > Add application-owned actions to the same reactive command system as SuperDoc controls. Register a custom command when an application action should share the Editor's enabled state, execution path, cleanup, and optional context-menu metadata. Keep ordinary application buttons outside the command system unless they need that integration. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This example expects `/contract.docx` to be available from your application. ## Add the application action [#add-the-application-action] This control inserts a reusable clause at the live Editor selection: ```html
``` ## Register and execute the command [#register-and-execute-the-command] Register after the Editor is ready and unregister during cleanup: ```ts import { SuperDoc } from 'superdoc'; import type { CommandExecutionResult } from 'superdoc/ui'; import 'superdoc/style.css'; const clauseText = document.querySelector('#clause-text'); const insertClause = document.querySelector('#insert-clause'); const status = document.querySelector('#command-status'); if (!clauseText || !insertClause || !status) throw new Error('The custom command controls are incomplete.'); let disposeCommand: (() => void) | null = null; let stopCommandState: (() => void) | null = null; let removeHandlers: (() => void) | null = null; const report = (result: CommandExecutionResult) => { if (result === false) { status.value = 'The clause could not be inserted.'; } else if (typeof result === 'object' && !result.success) { status.value = result.failure.message; } else { status.value = 'Clause inserted.'; } }; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const registration = ui.commands.register<{ text: string }>({ id: 'insert-standard-clause', shortcut: 'Mod-Shift-K', getState: ({ state, documentMode }) => ({ enabled: state.ready && documentMode !== 'viewing', disabled: !state.ready || documentMode === 'viewing', active: false, supported: true, reason: !state.ready ? 'not-ready' : documentMode === 'viewing' ? 'document-readonly' : undefined, }), execute: ({ payload, insertText }) => insertText(payload?.text ?? ''), }); const command = registration.handle; const render = () => { const commandState = command.getState(); insertClause.disabled = !commandState.enabled; insertClause.title = commandState.reason ?? ''; }; const run = async () => report(await command.executeAsync({ text: clauseText.value })); const runShortcut = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k') { event.preventDefault(); void run(); } }; stopCommandState = command.observe(render); insertClause.addEventListener('click', run); window.addEventListener('keydown', runShortcut); removeHandlers = () => { insertClause.removeEventListener('click', run); window.removeEventListener('keydown', runShortcut); }; disposeCommand = registration.unregister; }, }); window.addEventListener('beforeunload', () => { stopCommandState?.(); removeHandlers?.(); disposeCommand?.(); superdoc.destroy(); }); ``` The registration provides four optional integration points: * `getState()` derives `enabled`, `active`, `value`, and `reason` from current Editor state. * `execute()` receives the typed payload, live selection, document mode, controller, and Document API facade. * `shortcut` describes the intended shortcut. Your application still owns the keyboard listener and conflict policy. * `contextMenu` describes an item that your menu can obtain with `ui.commands.getContextMenuItems(context)`. Use the returned handle to observe state and call `executeAsync()`. This keeps the button and keyboard path on one execution contract. Inspect the result before reporting success or starting dependent work. ## Keep commands focused [#keep-commands-focused] A custom command should represent one user intent. Compose existing commands through `context.executeAsync()`, use `context.insertText()` for the narrow insertion helper, or call the public `context.doc` Document API for document operations. Do not reach into Editor state, view objects, or DOM offsets. Custom commands do not create an authorization boundary. The browser can gate normal interactions, but your backend must still enforce document access, identity, persistence, and collaboration permissions. --- # Handle unavailable commands > Turn command state, stable reasons, and mutation receipts into clear custom-UI behavior. Custom controls must distinguish a temporarily disabled action from a capability the current host does not support. Read command state before execution, then inspect the execution result. Neither check replaces the other. ## Explain the current state [#explain-the-current-state] Map the stable `reason` value to product language. Keep a fallback so newer reasons remain understandable before your application adds tailored copy: ```ts import type { CommandExecutionResult, CommandState, SuperDocUIReason } from 'superdoc/ui'; export const explainUnavailableCommand = (state: CommandState): string | null => { if (state.enabled) return null; const messages: Partial> = { 'not-ready': 'The document is still loading.', 'document-readonly': 'Switch from viewing mode before editing.', 'selection-required': 'Place the cursor in the document first.', 'range-selection-required': 'Select some text first.', 'table-context-unavailable': 'Place the cursor inside a table first.', 'content-control-locked': 'This field does not allow formatting changes.', 'command-unsupported': 'This action is not available in this SuperDoc build.', }; return state.reason ? messages[state.reason] ?? 'This action is currently unavailable.' : 'This action is unavailable.'; }; export const explainCommandResult = (result: CommandExecutionResult): string => { if (result === false) return 'The command could not be routed.'; if (result === true) return 'The command completed.'; if (!result.success) return `${result.failure.code}: ${result.failure.message}`; return 'The command completed.'; }; ``` Use the state fields together: * `enabled: false` means the action cannot run now. * `supported: false` means the controller cannot route the command on this host. * `reason` explains the current cause without requiring string parsing from an error message. * `active` and `value` describe current selection or control state. They do not imply that the next execution will succeed. Show unavailable actions only when their presence helps the reader understand what can change. Disable a familiar formatting action with a short reason. Hide an integration that the current product never provides. Do not render every ID from the command catalog as a toolbar. ## Inspect the final result [#inspect-the-final-result] State is a snapshot, not a guarantee. The document can change mode, selection, revision, or capability between the check and execution. Call `executeAsync()` and inspect its result: * `false` means the controller could not route the action. * `true` means a legacy or host action completed without a structured receipt. * A receipt with `success: false` contains a machine-readable failure code and message. * A receipt with `success: true` confirms the Document API mutation. Branch on stable reason strings and failure codes. Show the supplied message to developers or adapt it for users, but do not infer success from the absence of an exception. For document-operation capability checks and receipt failure codes, read [Receipts and errors](/document-api/receipts-and-errors). For the complete reactive state contract, read [Commands and state](/editor/custom-ui/commands-and-state). --- # Build formatting controls > Connect custom font, size, paragraph-style, and document-mode controls to the active Editor state. Use the custom UI controller when your application owns the formatting toolbar. The controller provides picker options, active values, disabled reasons, and commands that follow the current selection. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This example expects `/contract.docx` to be available from your application. ## Add the controls [#add-the-controls] Create font, size, paragraph-style, and document-mode selectors with a status message: ```html

``` ## Bind options, state, and commands [#bind-options-state-and-commands] Read the Editor's controller, `superdoc.ui`, after the Editor is ready: ```ts import { SuperDoc } from 'superdoc'; import type { CommandExecutionResult } from 'superdoc/ui'; import 'superdoc/style.css'; const fontFamilySelect = document.querySelector('#font-family'); const fontSizeSelect = document.querySelector('#font-size'); const paragraphStyleSelect = document.querySelector('#paragraph-style'); const documentModeSelect = document.querySelector('#document-mode'); const formattingStatus = document.querySelector('#formatting-status'); if (!fontFamilySelect || !fontSizeSelect || !paragraphStyleSelect || !documentModeSelect || !formattingStatus) { throw new Error('The formatting controls are incomplete.'); } let stopObservers: Array<() => void> = []; let removeHandlers: (() => void) | null = null; const replaceOptions = ( select: HTMLSelectElement, options: readonly { value: string; label: string }[], selected: string | null, ) => { select.replaceChildren( ...options.map(({ value, label }) => { const option = document.createElement('option'); option.value = value; option.textContent = label; return option; }), ); if (selected && options.some((option) => option.value === selected)) select.value = selected; }; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const fontFamily = ui.commands.get('font-family'); const fontSize = ui.commands.get('font-size'); const paragraphStyle = ui.commands.get('linked-style'); const documentMode = ui.commands.get('document-mode'); const render = () => { const fonts = ui.fonts.getSnapshot(); const styles = ui.styles.getSnapshot(); const document = ui.document.getSnapshot(); const familyState = fontFamily.getState(); const sizeState = fontSize.getState(); const styleState = paragraphStyle.getState(); replaceOptions( fontFamilySelect, fonts.options, typeof familyState.value === 'string' ? familyState.value : null, ); replaceOptions(fontSizeSelect, fonts.sizeOptions, typeof sizeState.value === 'string' ? sizeState.value : null); replaceOptions( paragraphStyleSelect, styles.quickGallery.map((style) => ({ value: style.id, label: style.name })), styles.activeParagraphStyleId, ); fontFamilySelect.disabled = !familyState.enabled; fontSizeSelect.disabled = !sizeState.enabled; paragraphStyleSelect.disabled = !styleState.enabled || styles.quickGallery.length === 0; documentModeSelect.value = document.mode ?? 'editing'; }; const report = (result: CommandExecutionResult) => { if (result === false) { formattingStatus.textContent = 'The formatting action is unavailable.'; return; } if (typeof result === 'object' && !result.success) { formattingStatus.textContent = result.failure.message; return; } formattingStatus.textContent = 'Formatting updated.'; }; const setFontFamily = async () => report(await fontFamily.executeAsync(fontFamilySelect.value)); const setFontSize = async () => report(await fontSize.executeAsync(fontSizeSelect.value)); const setParagraphStyle = async () => report(await paragraphStyle.executeAsync(paragraphStyleSelect.value)); const setDocumentMode = async () => report(await documentMode.executeAsync(documentModeSelect.value)); stopObservers = [ ui.fonts.observe(render), ui.styles.observe(render), ui.document.observe(render), fontFamily.observe(render), fontSize.observe(render), paragraphStyle.observe(render), ]; fontFamilySelect.addEventListener('change', setFontFamily); fontSizeSelect.addEventListener('change', setFontSize); paragraphStyleSelect.addEventListener('change', setParagraphStyle); documentModeSelect.addEventListener('change', setDocumentMode); removeHandlers = () => { fontFamilySelect.removeEventListener('change', setFontFamily); fontSizeSelect.removeEventListener('change', setFontSize); paragraphStyleSelect.removeEventListener('change', setParagraphStyle); documentModeSelect.removeEventListener('change', setDocumentMode); }; }, }); window.addEventListener('beforeunload', () => { for (const stop of stopObservers) stop(); removeHandlers?.(); superdoc.destroy(); }); ``` Each surface owns one part of the interface: * `ui.fonts` provides document-aware family options and supported size choices. * `ui.styles` provides the Word quick gallery and active paragraph style. * `ui.document` reports the current document mode. * `ui.commands` applies the selected value and reports whether the action can run. ## Preserve document intent [#preserve-document-intent] Pass the font option's `value` to `font-family`. The value is the logical family written to the DOCX. `previewFamily` is only for rendering the option in your application's picker. Pass the size option's `value` to `font-size`. The controller normalizes the public picker value before calling the formatting operation. Paragraph styles use stable style IDs. Show the catalogue's human-readable `name`, but execute `linked-style` with its `id`. Do not reproduce a style by copying its current CSS properties. Applying the style ID preserves the document's style relationship. ## Follow selection and mode changes [#follow-selection-and-mode-changes] Observe the font, style, document, and command surfaces. A mixed selection can have no single active paragraph style. A locked content control or viewing mode can disable formatting even when the option remains visible. Use `executeAsync()` before saving or starting dependent work. Inspect the returned result instead of assuming that a change event mutated the document. Changing document mode updates the posture of the whole Editor. It is not an authorization boundary. Read [Document modes](/editor/document-modes) before exposing mode changes to users. Verify the workflow by selecting text, changing its font and size, applying a paragraph style, and exporting the DOCX. Reopen it and confirm that the formatting and style ID remain intact. --- # Custom UI overview > Build application-owned controls over the public SuperDoc v2 UI controller. A custom UI keeps SuperDoc's DOCX canvas and editing behavior while your application owns the toolbar, panels, and workflow around it. Use this approach when a general-purpose document toolbar does not match the product task. A contract approval screen, structured intake flow, or focused review experience may need a smaller set of controls and application-specific state. ## Read the controller off the instance [#read-the-controller-off-the-instance] Every `SuperDoc` instance exposes its own UI controller as `editor.ui`. It is the shortest path to custom controls and the only one available to classic ` ``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX. ## Bind the search handle [#bind-the-search-handle] Read the Editor's controller, `superdoc.ui`, after readiness, then observe `ui.search` as the source of match and availability state. ```ts import { SuperDoc } from 'superdoc'; import type { WorkflowActionResult } from 'superdoc/ui'; import 'superdoc/style.css'; const query = document.querySelector('#search-query'); const matchCase = document.querySelector('#match-case'); const previous = document.querySelector('#previous-match'); const next = document.querySelector('#next-match'); const count = document.querySelector('#search-count'); const replacement = document.querySelector('#replacement'); const replace = document.querySelector('#replace-match'); const replaceAll = document.querySelector('#replace-all'); const status = document.querySelector('#search-status'); if (!query || !matchCase || !previous || !next || !count || !replacement || !replace || !replaceAll || !status) { throw new Error('The search UI is incomplete.'); } let stopSearch: (() => void) | null = null; let removeHandlers: (() => void) | null = null; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const render = (search: ReturnType) => { const hasMatches = search.total > 0; previous.disabled = !hasMatches; next.disabled = !hasMatches; replace.disabled = !search.canReplace; replaceAll.disabled = !search.canReplace; count.textContent = hasMatches ? `${search.activeIndex + 1} of ${search.total}` : 'No matches'; status.textContent = search.reason ?? ''; }; const runSearch = () => { if (!query.value) { ui.search.clear(); return; } const opened = ui.search.open(); if (!opened.ok) { status.textContent = opened.reason ?? 'Search is unavailable.'; return; } render(ui.search.search(query.value, { caseSensitive: matchCase.checked })); }; const reportAction = (result: WorkflowActionResult) => { if (!result.ok) status.textContent = result.reason ?? 'The search action did not run.'; }; const replaceCurrent = async () => { reportAction(await ui.search.replace(replacement.value)); }; const replaceEveryMatch = async () => { reportAction(await ui.search.replaceAll(replacement.value)); }; const goPrevious = () => reportAction(ui.search.previous()); const goNext = () => reportAction(ui.search.next()); render(ui.search.getSnapshot()); stopSearch = ui.search.observe(render); query.addEventListener('input', runSearch); matchCase.addEventListener('change', runSearch); previous.addEventListener('click', goPrevious); next.addEventListener('click', goNext); replace.addEventListener('click', replaceCurrent); replaceAll.addEventListener('click', replaceEveryMatch); removeHandlers = () => { query.removeEventListener('input', runSearch); matchCase.removeEventListener('change', runSearch); previous.removeEventListener('click', goPrevious); next.removeEventListener('click', goNext); replace.removeEventListener('click', replaceCurrent); replaceAll.removeEventListener('click', replaceEveryMatch); }; }, }); window.addEventListener('beforeunload', () => { stopSearch?.(); removeHandlers?.(); superdoc.destroy(); }); ``` The snapshot provides the state needed by a focused search interface: * `total` and `activeIndex` describe the current match. * `available` and `reason` explain whether the host can search. * `canReplace` reflects document mode, host support, and whether the complete match set can be changed safely. * `query`, `caseSensitive`, and `regex` describe the active session. ## Observe after starting a search [#observe-after-starting-a-search] `search()` returns the best-known snapshot immediately. A worker-backed search can settle later, so keep the interface subscribed with `observe()` instead of treating the first return value as final. `next()` and `previous()` update the active match and reveal it in the document. Check their `ok` result before announcing navigation. ## Await replacements [#await-replacements] `replace()` and `replaceAll()` can settle asynchronously. Await them before saving the document or starting dependent work. A failed result includes a stable reason such as an unavailable search host, a read-only document, or an operation the current session cannot perform. Keep replace controls synchronized with `canReplace` and still inspect the returned result. Call `clear()` when the query becomes empty. Call `close()` when the application dismisses the search experience and should remove its highlights. For the standard interface, use [Find and replace in the built-in UI](/editor/built-in-ui/search-and-replace). --- # Preserve selections and position UI > Keep a document selection after application controls take focus and anchor custom UI to painted document geometry. Custom composers, floating toolbars, and review panels often move focus away from the Editor. Use `ui.selection` to preserve the document target and `ui.viewport` to position application UI without reading the rendered DOM. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This guide uses the same controller lifecycle. ## Add the application surface [#add-the-application-surface] Place the overlay inside a positioned shell around the Editor. The textarea makes the focus change easy to verify. ```html

Select text in the document.

``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX. ## Capture a document selection [#capture-a-document-selection] Observe the selection after the Editor is ready. When it becomes non-empty, `capture()` freezes its document targets, selected text, active marks, and related comment or tracked-change IDs. ```ts import { SuperDoc } from 'superdoc'; import type { SelectionCapture, SelectionSlice } from 'superdoc/ui'; import 'superdoc/style.css'; const editorShell = document.querySelector('#editor-shell'); const overlay = document.querySelector('#selection-overlay'); const preview = document.querySelector('#selection-preview'); const restoreButton = document.querySelector('#restore-selection'); const status = document.querySelector('#selection-status'); if (!editorShell || !overlay || !preview || !restoreButton || !status) { throw new Error('The selection UI is incomplete.'); } let capture: SelectionCapture | null = null; let stopSelection: (() => void) | null = null; let stopViewport: (() => void) | null = null; let removeRestoreHandler: (() => void) | null = null; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const positionOverlay = () => { const target = capture?.selectionTarget ?? capture?.target; if (!target) { overlay.hidden = true; return; } const geometry = ui.viewport.getRect({ target, relativeTo: editorShell }); if (!geometry.found || !geometry.rect) { overlay.hidden = true; status.textContent = geometry.reason ?? 'The selection is not currently painted.'; return; } overlay.hidden = false; const overlayHeight = overlay.offsetHeight; overlay.style.left = `${geometry.rect.left}px`; overlay.style.top = `${Math.max(0, geometry.rect.top - overlayHeight - 8)}px`; }; const renderSelection = (selection: SelectionSlice) => { if (selection.empty) return; capture = ui.selection.capture(); if (!capture) return; preview.textContent = capture.quotedText; status.textContent = `Captured “${capture.quotedText}”. Move focus, then restore it.`; positionOverlay(); }; const restoreSelection = () => { if (!capture) return; const result = ui.selection.restore(capture); status.textContent = result.success ? 'Selection restored.' : `Restore failed: ${result.reason ?? 'unknown'}`; }; renderSelection(ui.selection.getSnapshot()); stopSelection = ui.selection.observe(renderSelection); stopViewport = ui.viewport.observe(positionOverlay); restoreButton.addEventListener('click', restoreSelection); removeRestoreHandler = () => restoreButton.removeEventListener('click', restoreSelection); }, }); window.addEventListener('beforeunload', () => { stopSelection?.(); stopViewport?.(); removeRestoreHandler?.(); superdoc.destroy(); }); ``` The capture is not a DOM range. It contains document addresses that can survive focus changes and layout updates. The example preserves the latest non-empty selection automatically. A contextual action can instead call `capture()` when the reader opens a menu or starts a specific workflow. ## Position from painted geometry [#position-from-painted-geometry] `ui.viewport.getRect()` resolves the captured target against the current painted layout. Passing `relativeTo: editorShell` returns coordinates for the overlay's containing block. Geometry can change when the Editor scrolls, zooms, resizes, paginates, or mounts a virtualized page. `ui.viewport.observe()` tells the application to measure again. Do not cache rectangle coordinates as document identity. Keep the captured target and resolve fresh geometry when the viewport changes. ## Resolve entities under a point [#resolve-entities-under-a-point] `ui.viewport.entityAt()` answers the opposite question: which document entities are painted under a screen point. Coordinates are `MouseEvent` `clientX` and `clientY` space, so a pointer handler can pass its own event through: ```ts editorShell.addEventListener('pointerdown', (event) => { const hits = ui.viewport.entityAt({ x: event.clientX, y: event.clientY }); const control = hits.find((hit) => hit.type === 'contentControl'); if (control) console.info('content control under pointer', control.id); }); ``` It returns a `ViewportEntityHit[]` ordered innermost first. Every hit carries a `type` and an `id`. The hits are tracked changes, comments, and content controls. A point over ordinary text carries none of those, so an empty array is the normal answer, not an error. Branch on what you find; do not treat `[]` as a failed lookup. `story` is not a general field on a hit. Only tracked-change hits carry it, and only when the change is painted outside the body, in a footnote, endnote, header, footer, or textbox. It exists because one tracked-change id can repeat across stories, so `story` names the occurrence actually under the point. Comment and content-control hits are returned normally in those stories but carry only `type` and `id`, so do not write story-sensitive handling for them. Use `ui.trackChanges.getAt()` when you need the full tracked-change row rather than the hit. Pass the object form. The legacy positional form `entityAt(x, y)` fails closed and returns `null`, because the addresses it produced are not resolvable by `getRect()`. ## Coming from editor.view coordinates [#coming-from-editorview-coordinates] `posAtCoords()` and `coordsAtPos()` are inverse operations, and v2 answers them on different surfaces. Screen point to content is `entityAt()`, above. It is a partial replacement: it resolves the entities painted under a point, not an arbitrary document position. v2 ships no general point-to-position resolver, so a v1 caller that mapped a click to a numeric offset and then did arithmetic on it has no equivalent, and the calculation needs rebuilding around targets. Content to screen rectangle is `getRect()`, above. It takes a public target rather than a numeric position, so resolve one from `capture()` or a query result first. ## Restore and handle failure [#restore-and-handle-failure] `ui.selection.restore()` reapplies the captured target through the public host selection controller. It returns `success: false` with a stable reason when the Editor is not ready, the target cannot be resolved, the document is read-only, or the host cannot apply selections. Restoring a selection is optional. Comment creation and other target-based workflows can operate directly on the capture without moving browser focus back to the Editor. ## Apply a selection target directly [#apply-a-selection-target-directly] `ui.selection.apply()` moves the document selection to a `SelectionTarget` you already hold, without going through a capture. Use it when the target came from the live selection or from a Document API query rather than from this page's capture workflow. ```ts const info = ui.selection.current(); if (info?.selectionTarget) { const result = ui.selection.apply(info.selectionTarget); if (!result.ok) console.error(`Could not select: ${result.reason}`); } ``` Read `selectionTarget`, not `target`. `target` is a `TextTarget` describing text segments; `selectionTarget` is the selection envelope that write APIs and `apply()` consume. `apply()` returns `{ ok, reason? }` and never throws. It fails closed with the same stable reasons as `restore()` when the Editor is not ready, the target cannot be resolved, the document is read-only, or the host cannot apply selections. `restore()` is the capture-based form of the same operation. Coming from v1, this replaces `editor.view.dispatch(tr.setSelection(...))`. Pass a target, not a transaction. There is no ProseMirror position to hand it. ## Verify the interaction [#verify-the-interaction] 1. Select text in the document. 2. Confirm that the overlay shows the selected text near its painted range. 3. Type in the review-note textarea. 4. Scroll or resize the Editor and confirm that the overlay follows the target. 5. Choose **Restore selection** and confirm that the document selection returns. Continue with [Build a custom comments UI](/editor/custom-ui/comments) to use the same capture for an anchored comment. --- # Build contextual table controls > Enable application-owned table actions from the current cell and explain why an action is unavailable. Table commands depend on the current Editor selection. Use `ui.tables` to read the active table context and `ui.commands` to run actions against it. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This example expects `/contract.docx` to contain a table. ## Add the controls [#add-the-controls] Create two table actions, context output, status output, and an Editor container: ```html
``` ## Bind actions to table context [#bind-actions-to-table-context] Read the Editor's controller, `superdoc.ui`, after the Editor is ready: ```ts import { SuperDoc } from 'superdoc'; import type { CommandExecutionResult } from 'superdoc/ui'; import 'superdoc/style.css'; const tablePosition = document.querySelector('#table-position'); const addRowButton = document.querySelector('#add-row'); const deleteRowButton = document.querySelector('#delete-row'); const tableStatus = document.querySelector('#table-status'); if (!tablePosition || !addRowButton || !deleteRowButton || !tableStatus) { throw new Error('The table controls are incomplete.'); } let stopAddRow: (() => void) | null = null; let stopDeleteRow: (() => void) | null = null; let removeHandlers: (() => void) | null = null; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const addRow = ui.commands.get('table-add-row-after'); const deleteRow = ui.commands.get('table-delete-row'); const render = () => { const context = ui.tables.getContext(); const addState = addRow.getState(); const deleteState = deleteRow.getState(); tablePosition.textContent = context.inTable ? `Row ${(context.rowIndex ?? 0) + 1}, column ${(context.columnIndex ?? 0) + 1}` : 'Place the caret in a table.'; addRowButton.disabled = !addState.enabled; deleteRowButton.disabled = !deleteState.enabled; tableStatus.textContent = addState.reason ?? deleteState.reason ?? ''; }; const report = (result: CommandExecutionResult, successMessage: string) => { if (result === false) { tableStatus.textContent = 'The table action is unavailable.'; return; } if (typeof result === 'object' && !result.success) { tableStatus.textContent = result.failure.message; return; } tableStatus.textContent = successMessage; }; const insertRow = async () => report(await addRow.executeAsync(), 'Row added.'); const removeRow = async () => report(await deleteRow.executeAsync(), 'Row deleted.'); stopAddRow = addRow.observe(render); stopDeleteRow = deleteRow.observe(render); addRowButton.addEventListener('click', insertRow); deleteRowButton.addEventListener('click', removeRow); removeHandlers = () => { addRowButton.removeEventListener('click', insertRow); deleteRowButton.removeEventListener('click', removeRow); }; }, }); window.addEventListener('beforeunload', () => { stopAddRow?.(); stopDeleteRow?.(); removeHandlers?.(); superdoc.destroy(); }); ``` The example does not construct table locators. The controller resolves the enclosing table, row, column, and cell from the live selection. It then builds the Document API input for each command. ## Render command state, not assumptions [#render-command-state-not-assumptions] Each table command reports whether it is supported and enabled. Outside a table, contextual actions fail closed with `table-context-unavailable`. In viewing mode, mutations report `document-readonly`. Use `getState()` for the initial render and `observe()` for later selection, mode, and capability changes. Still inspect the result from `executeAsync()`. Context can change between rendering a button and clicking it. The routed table command family includes inserting or deleting rows and columns, deleting a table, merging cells, splitting a cell, and removing borders. Add only the controls your workflow needs. Do not render every catalog command as a toolbar. ## Keep context tied to selection [#keep-context-tied-to-selection] `ui.tables.getContext()` returns the current table ID, row and column indices, cell ID, and dimensions when the host can resolve them. Treat that snapshot as presentation state. Do not cache it as a durable mutation target or derive table identity from painted DOM attributes. Verify the workflow by placing the caret in a table, adding a row, deleting a row, and exporting the DOCX. Outside the table, both buttons should disable with a contextual reason. Use the Document API tables reference when application code already has an explicit table target or needs an operation that is not selection-driven. --- # Build tracked-change review controls > Render open changes, move reviewers to them, and await accept or reject decisions from a custom interface. Use `ui.trackChanges` when your application owns the review panel. The handle provides the reactive change list, active change, document navigation, and review actions. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. Use [Document API tracked changes](/document-api/tracked-changes) when code reviews known change IDs without an Editor. ## Add the review panel [#add-the-review-panel] Create a status message, change list, and Editor container: ```html
``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX with open changes. ## Bind review state and actions [#bind-review-state-and-actions] Read the Editor's controller, `superdoc.ui`, after the Editor is ready. Observe `ui.trackChanges` so the panel follows document mutations and selection changes. ```ts import { SuperDoc } from 'superdoc'; import type { TrackChangesSlice } from 'superdoc/ui'; import 'superdoc/style.css'; const changeList = document.querySelector('#change-list'); const reviewStatus = document.querySelector('#review-status'); if (!changeList || !reviewStatus) { throw new Error('The tracked-change review UI is incomplete.'); } let stopTrackChanges: (() => void) | null = null; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', documentMode: 'suggesting', user: { name: 'Alex Rivera', email: 'alex@example.com', }, onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const focusChange = async (id: string) => { if (!ui.trackChanges.setActive(id)) { reviewStatus.textContent = 'The tracked change is no longer available.'; return; } const result = await ui.trackChanges.scrollTo(id); if (!result.success) { reviewStatus.textContent = result.reason ?? 'The tracked change could not be shown.'; } }; const decideChange = async (decision: 'acceptChange' | 'rejectChange', id: string) => { const result = await ui.commands.executeAsync(decision, { id }); if (result === false) { reviewStatus.textContent = 'The review decision is unavailable.'; return; } if (typeof result === 'object' && !result.success) { reviewStatus.textContent = result.failure.message; return; } reviewStatus.textContent = decision === 'acceptChange' ? 'Change accepted.' : 'Change rejected.'; }; const render = (changes: TrackChangesSlice) => { changeList.replaceChildren(); reviewStatus.textContent = changes.status === 'pending' ? 'Loading tracked changes…' : `${changes.total} open changes`; for (const change of changes.items) { const row = document.createElement('li'); const summary = document.createElement('span'); const show = document.createElement('button'); const accept = document.createElement('button'); const reject = document.createElement('button'); const detail = change.excerpt ?? change.insertedText ?? change.deletedText ?? change.type; summary.textContent = `${detail}${change.author ? ` by ${change.author}` : ''}`; show.type = 'button'; show.textContent = changes.activeId === change.id ? 'Showing' : 'Show'; show.addEventListener('click', () => void focusChange(change.id)); accept.type = 'button'; accept.textContent = 'Accept'; accept.addEventListener('click', () => void decideChange('acceptChange', change.id)); reject.type = 'button'; reject.textContent = 'Reject'; reject.addEventListener('click', () => void decideChange('rejectChange', change.id)); row.append(summary, show, accept, reject); changeList.append(row); } }; stopTrackChanges = ui.trackChanges.observe(render); }, }); window.addEventListener('beforeunload', () => { stopTrackChanges?.(); superdoc.destroy(); }); ``` The example keeps each responsibility on its public surface: * `observe()` renders the current list and active change. * `setActive()` marks the change the application is showing. * `scrollTo()` moves the document canvas to that change. * `executeAsync()` waits for the accept or reject operation to settle. ## Step through the review sequence [#step-through-the-review-sequence] `setActive()` and `scrollTo()` are the right pair when the application already knows which row to show. When the reader is walking the document in order, use `ui.trackChanges.navigateNext()` and `navigatePrevious()` instead. Each one moves `activeId` to the adjacent change and awaits viewport navigation to it. Traversal wraps. Stepping past the last change returns to the first, so there is no end of the list to detect and `{ success: false }` never means the reader finished the document. Navigation reads the loaded change list, and it does not start that read. `observe()`, `getSnapshot()`, and `list()` do. Calling `navigateNext()` on a controller whose catalog has not settled finds no rows and returns `{ success: false }` even in a document full of changes, so gate the control on a ready slice instead of navigating and interpreting the failure: ```ts ui.trackChanges.observe(() => { const changes = ui.trackChanges.getSnapshot(); nextButton.disabled = changes.status !== 'ready' || changes.total === 0; }); nextButton.addEventListener('click', async () => { const result = await ui.trackChanges.navigateNext(); if (!result.success && reviewStatus) { reviewStatus.textContent = 'Could not move to the next change.'; } }); ``` Both resolve `{ success }`. Behind a ready, non-empty catalog, `{ success: false }` means a target that could not be resolved or a scroll the host could not route, and both roll the `activeId` move back. When the target resolves but cannot be made visible, `activeId` stays on the requested change and `success` is still false. Coming from v1, this replaces walking `editor.state` to find the next change yourself. `list()` reads the catalog and the navigate pair owns the traversal. ## Await every decision [#await-every-decision] Pass the change ID to `acceptChange` or `rejectChange`. An explicit ID lets a sidebar decide its own row without depending on the live Editor selection. Use `executeAsync()` when saving, navigation, or status UI depends on the decision. Inspect `false` and unsuccessful receipts before announcing success. The observed change list refreshes after a successful mutation. Bulk decisions use `acceptAllChanges` and `rejectAllChanges`. They can be unavailable when the host has not enabled bulk review. Keep per-change controls available as the predictable path. ## Keep access enforcement outside the panel [#keep-access-enforcement-outside-the-panel] Viewing mode and client-side controls can prevent a normal interaction, but they are not an authorization boundary. Your application still owns trusted identity, document access, persistence, and collaboration authorization. Verify the workflow by accepting or rejecting one row, exporting the DOCX, and reopening it. The resolved change should be absent. Unresolved changes should remain reviewable. For the standard interface, use [Review tracked changes](/editor/review/tracked-changes). For the operation contract, use [Document API tracked changes](/document-api/tracked-changes). --- # Control zoom and document state > Build fit-to-width, manual zoom, dirty-state, and export controls around the active document. Use `ui.zoom` for viewport scale and `ui.document` for readiness, mode, dirty state, export, and file replacement. These handles keep application chrome synchronized with the active Editor. Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This example expects `/contract.docx` to be available from your application. ## Add document controls [#add-document-controls] Create zoom, export, and status controls next to the Editor: ```html
Loading the document…
``` ## Observe both snapshots [#observe-both-snapshots] Bind the controls after the Editor is ready: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const zoomOut = document.querySelector('#zoom-out'); const fitWidth = document.querySelector('#fit-width'); const zoomIn = document.querySelector('#zoom-in'); const exportButton = document.querySelector('#export'); const status = document.querySelector('#document-status'); if (!zoomOut || !fitWidth || !zoomIn || !exportButton || !status) { throw new Error('The document controls are incomplete.'); } let stopObservers: Array<() => void> = []; let removeHandlers: (() => void) | null = null; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const render = () => { const zoom = ui.zoom.getSnapshot(); const currentDocument = ui.document.getSnapshot(); zoomOut.disabled = zoom.value <= zoom.min; zoomIn.disabled = zoom.value >= zoom.max; fitWidth.setAttribute('aria-pressed', String(zoom.mode === 'fit-width')); exportButton.disabled = !currentDocument.ready; status.value = currentDocument.ready ? `${currentDocument.dirty ? 'Unsaved changes' : 'Saved'} · ${zoom.value}% · ${currentDocument.mode ?? 'loading'}` : 'Loading the document…'; }; const changeZoom = (delta: number) => { const zoom = ui.zoom.getSnapshot(); ui.zoom.setMode('manual'); ui.zoom.set(Math.min(zoom.max, Math.max(zoom.min, zoom.value + delta))); }; const zoomOutHandler = () => changeZoom(-10); const zoomInHandler = () => changeZoom(10); const fitWidthHandler = () => ui.zoom.setMode('fit-width'); const exportHandler = async () => { status.value = 'Preparing the DOCX…'; const pendingExport = ui.document.export({ exportType: ['docx'] }); if (!pendingExport) { status.value = 'Export is unavailable in this host.'; return; } try { await pendingExport; status.value = 'DOCX downloaded.'; } catch (error) { status.value = error instanceof Error ? error.message : 'The DOCX could not be exported.'; } }; stopObservers = [ui.zoom.observe(render), ui.document.observe(render)]; zoomOut.addEventListener('click', zoomOutHandler); zoomIn.addEventListener('click', zoomInHandler); fitWidth.addEventListener('click', fitWidthHandler); exportButton.addEventListener('click', exportHandler); removeHandlers = () => { zoomOut.removeEventListener('click', zoomOutHandler); zoomIn.removeEventListener('click', zoomInHandler); fitWidth.removeEventListener('click', fitWidthHandler); exportButton.removeEventListener('click', exportHandler); }; }, }); window.addEventListener('beforeunload', () => { for (const stop of stopObservers) stop(); removeHandlers?.(); superdoc.destroy(); }); ``` `ui.zoom.getSnapshot()` returns the current mode, percentage, and supported range. Setting a percentage switches the example to manual mode. Fit-to-width remains responsive as the Editor container changes size, so it is the better default for flexible layouts. `ui.document.getSnapshot()` reports whether the Editor is ready, its mode, and whether it has unsaved changes. Treat `dirty` as a prompt to save or export, not as proof that a remote persistence request succeeded. ## Handle unavailable host operations [#handle-unavailable-host-operations] `ui.document.export()` and `replaceFile()` return `undefined` when the host does not expose the operation. Check before awaiting. Export triggers a browser download by default; pass `{ triggerDownload: false }` when your application needs the result for upload instead. `ui.zoom.set()`, `ui.zoom.setMode()`, and `ui.document.setMode()` are control methods, not mutation receipts. Observe the resulting snapshot. When later work depends on a document-mode change, use the `document-mode` command and await `executeAsync()` instead. For file loading policies and export options, read [Load and save documents](/editor/load-and-save-documents). For the meaning of editing, suggesting, and viewing, read [Document modes](/editor/document-modes). --- # Review tracked changes > Let people propose, inspect, accept, and reject changes in the Editor. Tracked changes let a person propose an edit without immediately changing the accepted document. The Editor renders each proposal in the DOCX and provides controls for accepting or rejecting it. Use this page for the human review experience. Use [Document API tracked changes](/document-api/tracked-changes) when application code needs to create, list, inspect, or decide changes programmatically. ## Open the Editor for review [#open-the-editor-for-review] Start in `suggesting` mode when a reviewer should create new tracked changes while inspecting existing ones. Provide the current user so exported changes retain their author. Use the same toolbar and Editor containers from [Configure the built-in toolbar](/editor/built-in-ui/configure-the-toolbar), then initialize SuperDoc: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', documentMode: 'suggesting', toolbar: '#toolbar', user: { name: 'Jordan Lee', email: 'jordan@example.com', }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or update the document URL. The built-in toolbar reads the active selection and exposes accept or reject actions when a tracked change can be decided. New typing in suggesting mode becomes another proposal. ## Try the review flow [#try-the-review-flow] The sample opens automatically with one tracked change. Select the change, then accept or reject it. > **Interactive editor: Review a tracked change** > > Sample: [open the fixture](/fixtures/tracked-changes.docx). > Preset: `tracked-review`. > Tracked-change review: accept or reject the sample change. > Local DOCX selection: disabled. Accept keeps the proposed result and removes the review mark. Reject restores the prior content and removes the review mark. Both decisions change the open document and must be followed by your normal save or export flow. ## Choose the right mode [#choose-the-right-mode] | Reviewer task | Mode | Tracked-change visibility | | ------------------------------------------------ | ------------ | ---------------------------------------------- | | Propose edits and decide existing changes | `suggesting` | Review marks are shown | | Edit directly while deciding existing changes | `editing` | Review marks are shown | | Inspect proposals without changing the document | `viewing` | Set `modules.trackChanges.visible` to `true` | | Read the document before proposals were accepted | `viewing` | Leave tracked-change visibility at its default | Viewing mode is read-only. Showing tracked changes in that mode does not grant permission to accept, reject, or create them. ## Keep review decisions explicit [#keep-review-decisions-explicit] The built-in controls and `superdoc/ui` use the same tracked-change state. A control can be disabled because there is no active change, the document is read-only, or the current client interaction policy does not allow the decision. Do not infer the active change from document DOM attributes. Use the built-in controls or the public custom UI controller. Use the Document API when a service or application workflow decides a known change by ID. Editor modes and client-side review controls are not an authorization boundary. Your application still owns access to the DOCX, trusted user identity, persistence, and collaboration authorization. Verify the completed workflow by exporting the DOCX and reopening it. Accepted text should remain without a pending review mark. Rejected text should be restored. Unresolved changes should remain available for another reviewer. Continue with [Load and save documents](/editor/load-and-save-documents), or build application-owned controls with the [Custom UI overview](/editor/custom-ui/overview). --- # Build accessible Editor experiences > Preserve keyboard access, names, focus, status announcements, and application-owned accessibility around SuperDoc. The built-in toolbar and surfaces provide keyboard and ARIA behavior, but the complete experience includes the application shell, custom controls, dialogs, validation, and save state. Accessibility remains a shared responsibility. ## Preserve the built-in contract [#preserve-the-built-in-contract] * Keep visible labels or accessible names when replacing toolbar text and icons. * Do not remove focus outlines without an equally visible replacement. * Give dialogs and floating surfaces a title, `ariaLabel`, or `ariaLabelledBy`. * Keep Escape and focus behavior predictable. Do not trap focus in a non-modal floating tool. * Let fit-to-width respond to zoom and container changes without preventing browser zoom. ## Own custom UI semantics [#own-custom-ui-semantics] Use native buttons, inputs, and selects whenever possible. Mirror command `active` with `aria-pressed`, disable unavailable actions, and announce async save or mutation outcomes through a polite live region. Return focus to the document when a temporary control closes and preserve the selection when a composer takes focus. Keyboard shortcut metadata does not install a listener. The application must bind shortcuts, prevent conflicts with browser and assistive-technology commands, and route every shortcut through the same command handle as its visible control. Test keyboard-only navigation, high contrast, 200% browser zoom, reduced motion, and at least one screen reader on supported browsers. Automated checks catch markup defects but do not prove that document editing is understandable. --- # Configure the Editor > Organize SuperDoc initialization around document input, interface, behavior, and host integration. Start with the smallest configuration that loads the document and interface you need. Add a field only when a product requirement justifies it. ## Use one typed configuration [#use-one-typed-configuration] ```ts import { SuperDoc, type Config } from 'superdoc'; import 'superdoc/style.css'; const editorConfig = { selector: '#editor', document: '/contract.docx', documentMode: 'editing', user: { name: 'Avery Stone', email: 'avery@example.com' }, contained: true, zoom: { mode: 'fit-width', fitWidth: { max: 100, padding: 24 } }, modules: { toolbar: { responsiveToContainer: true }, comments: { displayMode: 'auto' }, surfaces: { findReplace: true }, }, onReady: () => { console.info('SuperDoc is ready.'); }, } satisfies Config; const superdoc = new SuperDoc(editorConfig); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` The important configuration groups are: * `selector`, `document`, and `documentMode` establish the Editor and its starting posture. * `user` identifies the person for comments, suggestions, and collaboration presence. Supply trusted identity from your application. * `modules` configures built-in interface and integration subsystems. * `zoom`, `contained`, fonts, proofing, and layout options control host behavior. * `onReady`, error callbacks, and focused events connect the Editor to application lifecycle. Keep the configuration object stable in reactive frameworks. Recreating the Editor to apply a new object can reset selection and transient UI state. Use runtime methods for supported changes such as mode and zoom, and remount deliberately for initialization-only fields such as extensions. `modules` combines presentation, behavior, and integration settings. Add only the subsystem configuration your application uses. Client configuration is not authorization. A browser user can inspect or alter client code, so document access and collaboration authorization must be enforced by trusted services. --- # Open dialogs and floating surfaces > Render application UI in SuperDoc-managed dialog and floating layers with explicit lifecycle outcomes. Surfaces place application UI above the document without coupling it to Editor internals. Use a dialog for modal decisions and a floating surface for a non-modal tool or inspector. ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx' }); export const confirmInEditor = async (message: string): Promise => { const handle = superdoc.openSurface<{ confirmed: true }>({ mode: 'dialog', title: 'Confirm action', render: ({ container, close, resolve }) => { const text = document.createElement('p'); text.textContent = message; const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = 'Cancel'; const confirm = document.createElement('button'); confirm.type = 'button'; confirm.textContent = 'Confirm'; cancel.addEventListener('click', () => close('cancel')); confirm.addEventListener('click', () => resolve({ confirmed: true })); container.append(text, cancel, confirm); }, }); const outcome = await handle.result; return outcome.status === 'submitted' && outcome.data?.confirmed === true; }; window.addEventListener('beforeunload', () => superdoc.destroy()); ``` The `render()` callback receives an empty container plus `resolve()` and `close()` controls. Mount DOM, React, Vue, or another framework into that container and return cleanup for any framework root or listener that outlives the nodes themselves. `handle.result` resolves for normal lifecycle outcomes. Check `status` before reading data: a surface can be submitted, closed, replaced by another surface in the same slot, or destroyed with the Editor. There is one active slot per mode. Give every surface a visible `title`, `ariaLabel`, or `ariaLabelledBy`. Decide whether Escape, backdrop clicks, and outside pointer events should close it. These defaults affect interaction, not authorization or transaction safety. Configure `modules.surfaces` only for shared defaults, intent resolution, or built-in surfaces such as find/replace and password prompts. Direct `openSurface()` calls do not require a resolver. --- # Manage document files > Replace the active DOCX, export for download or persistence, and define explicit storage ownership. SuperDoc edits the active document in the browser. Your application owns where the source comes from, when changes are persisted, and who may read the result. ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const fileInput = document.querySelector('#document-file'); const saveButton = document.querySelector('#save-document'); const status = document.querySelector('#document-status'); if (!fileInput || !saveButton || !status) throw new Error('The document-management controls are incomplete.'); let documentReady = false; let requiresRecreation = false; let superdoc: SuperDoc | null = null; const setControlsBusy = () => { fileInput.disabled = true; saveButton.disabled = true; }; const setControlsIdle = () => { fileInput.disabled = false; saveButton.disabled = !documentReady; }; const showError = (error: unknown) => { status.value = error instanceof Error ? error.message : 'The document operation failed.'; }; const handleRuntimeError = (error: unknown) => { showError(error); if (documentReady) return; requiresRecreation = true; setControlsIdle(); }; const openDocument = (document: string | File) => { superdoc?.destroy(); documentReady = false; requiresRecreation = false; setControlsBusy(); status.value = 'Opening document…'; try { superdoc = new SuperDoc({ selector: '#editor', document, onReady: () => { documentReady = true; setControlsIdle(); status.value = 'Document ready.'; }, onContentError: ({ error }) => { handleRuntimeError(error); }, onException: ({ error }) => { handleRuntimeError(error); }, }); } catch (error) { superdoc = null; handleRuntimeError(error); } }; const replaceDocument = async () => { const file = fileInput.files?.[0]; if (!file) return; if (requiresRecreation) { openDocument(file); return; } setControlsBusy(); try { status.value = 'Opening document…'; if (!superdoc) throw new Error('SuperDoc is not initialized.'); const result = await superdoc.replaceFile(file); const replacementResult = result && typeof result === 'object' ? result : null; const replacementState = replacementResult && 'state' in replacementResult ? replacementResult.state : null; if (replacementState && replacementState !== 'review-ready' && replacementState !== 'editing-ready') { if (replacementResult && 'mount' in replacementResult && replacementResult.mount === null) { documentReady = false; requiresRecreation = true; } showError(new Error('SuperDoc could not open the selected DOCX.')); return; } documentReady = true; status.value = 'Document ready.'; } catch (error) { documentReady = false; requiresRecreation = true; showError(error); } finally { setControlsIdle(); } }; const uploadDocument = async () => { setControlsBusy(); try { status.value = 'Preparing DOCX…'; if (!superdoc) throw new Error('SuperDoc is not initialized.'); const result = await superdoc.export({ exportType: ['docx'], triggerDownload: false }); if (!(result instanceof Blob)) throw new Error('SuperDoc did not return a DOCX blob.'); const body = new FormData(); body.set('document', result, 'contract.docx'); const response = await fetch('/api/documents/contract', { method: 'PUT', body }); if (!response.ok) throw new Error(`Upload failed with ${response.status}.`); status.value = 'Saved.'; } catch (error) { showError(error); } finally { setControlsIdle(); } }; fileInput.addEventListener('change', replaceDocument); saveButton.addEventListener('click', uploadDocument); openDocument('/contract.docx'); window.addEventListener('beforeunload', () => { fileInput.removeEventListener('change', replaceDocument); saveButton.removeEventListener('click', uploadDocument); superdoc?.destroy(); }); ``` ## Choose a source deliberately [#choose-a-source-deliberately] At construction, `document` accepts a URL, `File`, or `Blob`. A URL is fetched by the browser and must satisfy CORS, authentication, and Content Security Policy requirements. A local `File` or `Blob` stays in browser memory unless your application, collaboration provider, proofing provider, or other integration sends its contents elsewhere. Use `replaceFile()` to swap the active file while preserving the mounted SuperDoc instance when the runtime supports it. Await completion before enabling dependent controls. For a full application route change or a different Editor configuration, destroying and creating a new instance may be clearer. ## Export once for the intended destination [#export-once-for-the-intended-destination] `export()` downloads by default. Set `triggerDownload: false` to receive the `Blob` for upload, storage, or another browser workflow. Choose comment and tracked-change export policy explicitly when producing a clean or final document. An export proves that SuperDoc produced bytes, not that your backend stored them. Check the persistence response and update application save state only after the trusted service succeeds. For the shorter end-to-end workflow, read [Load and save documents](/editor/load-and-save-documents). For server-side processing, use [Agents & automation](/agents/overview). --- # Handle lifecycle and events > Connect Editor readiness, updates, errors, and cleanup to application lifecycle. Use configuration callbacks for events known at construction time. Use `on()` and `off()` when a listener belongs to a later application lifecycle, and always remove it before destroying the instance. ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const status = document.querySelector('#editor-status'); if (!status) throw new Error('The Editor status output is missing.'); const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', onReady: () => { status.value = 'Ready'; }, onEditorUpdate: () => { status.value = 'Unsaved changes'; }, onContentError: ({ error }) => { status.value = error instanceof Error ? error.message : 'The document could not be loaded.'; }, onException: ({ error }) => { console.error('SuperDoc exception', error); }, }); const handleModeChange = ({ documentMode }: { documentMode: 'editing' | 'suggesting' | 'viewing' }) => { status.value = `Mode: ${documentMode}`; }; superdoc.on('document-mode-change', handleModeChange); window.addEventListener('beforeunload', () => { superdoc.off('document-mode-change', handleModeChange); superdoc.destroy(); }); ``` ## Choose the narrowest signal [#choose-the-narrowest-signal] * `onReady` means the SuperDoc instance and active Editor are ready for normal interaction. * `onEditorUpdate` reports document updates. Debounce persistence work and export the current DOCX instead of storing internal Editor state. * `onContentError` reports document loading or processing failures. * `onException` is the general integration error channel. Its payload is a union, so narrow optional fields before using them. * `document-mode-change`, `zoomChange`, `viewport-change`, comments, content-control, font, and collaboration events support focused integrations. Do not use a broad update event when a domain handle already exposes a snapshot and `observe()`. Custom UI controls should observe `editor.ui`; application lifecycle and operational logging belong on SuperDoc events. Call `destroy()` when the owning route or component unmounts. It releases Editor resources, listeners, surfaces, collaboration connections, and the instance's `ui` controller. Remove application listeners and framework roots as part of the same cleanup. --- # Add spelling and grammar proofing > Connect a provider to SuperDoc's segment, scheduling, rendering, and replacement workflow. SuperDoc owns document segmentation, scheduling, issue rendering, ignore actions, and replacement. Your proofing provider receives text segments and returns issue ranges. ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', proofing: { enabled: true, defaultLanguage: 'en-US', debounceMs: 500, provider: { id: 'local-example', getCapabilities: () => ({ issueKinds: ['spelling'], supportsSuggestions: true, requiresNetwork: false, }), check: async ({ segments }) => ({ issues: segments.flatMap((segment) => { const start = segment.text.indexOf('teh'); return start < 0 ? [] : [{ segmentId: segment.id, start, end: start + 3, kind: 'spelling', replacements: ['the'] }]; }), }), }, onProofingError: ({ message }) => console.error('Proofing failed', message), }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` The local example is intentionally small but executable. A production provider may call a service and return spelling, grammar, or style issues. Ranges are zero-based UTF-16 offsets within the supplied segment, not document-global positions. Declare capabilities honestly. Configure timeouts, concurrency, batching, debounce, language fallback, and error handling for the provider's real behavior. Treat proofing as advisory: the document can change while a request is in flight, and SuperDoc owns reconciling results with its current segments. If the provider uses a network, document text leaves the browser. Obtain the required user consent, send only necessary segments, use authenticated transport, set retention policy, and never place document text in logs or URL query strings. Proofing is disabled by default. Do not enable it without a provider and a clear data-handling policy. --- # Secure browser document workflows > Define trusted boundaries for document access, identity, persistence, integrations, and browser policy. SuperDoc runs document editing in the browser. That improves data locality, but it does not make every integration private or make client code a trusted authorization boundary. ## Identify every data boundary [#identify-every-data-boundary] * A URL document is fetched by the browser from its origin. * A local `File` or `Blob` stays in browser memory until application code sends or persists it. * Exported bytes remain local unless downloaded, uploaded, cached, or passed to another API. * Collaboration providers transmit document updates and awareness data. * Network proofing, AI, telemetry, logging, and upload handlers may transmit document content or metadata. Publish this behavior in product privacy language. Minimize data, retention, and logs. Never log document bodies, clauses, comment text, credentials, signed URLs, or mutation payloads by default. ## Keep trusted decisions on trusted services [#keep-trusted-decisions-on-trusted-services] Document mode, disabled buttons, permission resolvers, read-only comments, and hidden review controls guide normal browser interaction. They do not enforce access against a user who controls the browser. Authenticate document requests and enforce read, write, export, collaboration, and agent approval policy on trusted services. Treat names and emails supplied in `user` as display identity unless they came from an authenticated application session. Validate uploaded DOCX files, set size and timeout limits, and isolate sensitive processing where your threat model requires it. ## Configure the browser boundary [#configure-the-browser-boundary] Use HTTPS, restrictive CORS, and a Content Security Policy that permits only the scripts, workers, connections, images, and fonts your deployment needs. Use `cspNonce` or `buildTheme()` when policy requires nonced styles. Avoid unversioned third-party runtime dependencies, and decide whether Editor assets are self-hosted before production. --- # Theme UI and resolve document fonts > Style SuperDoc chrome without confusing application typography with DOCX font fidelity. SuperDoc has two font concerns. UI typography controls toolbars, comments, menus, and surfaces. Document font resolution controls how logical Word fonts are measured and painted. Configure them separately. ```ts import { SuperDoc, createTheme } from 'superdoc'; import 'superdoc/style.css'; const shell = document.querySelector('#editor-shell'); if (!shell) throw new Error('The Editor shell is missing.'); const themeClass = createTheme({ name: 'product', font: 'Inter, system-ui, sans-serif', colors: { action: '#2563eb', bg: '#f8fafc', text: '#0f172a', border: '#cbd5e1' }, vars: { '--sd-ui-toolbar-bg': '#eef2ff', '--sd-layout-page-bg': '#ffffff' }, }); shell.classList.add(themeClass); const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', uiDisplayFallbackFont: 'Inter, system-ui, sans-serif', onReady: async ({ superdoc: readySuperDoc }) => { readySuperDoc.fonts.add({ family: 'Product Sans', faces: [{ source: '/fonts/product-sans-regular.woff2', weight: 400, style: 'normal' }], }); readySuperDoc.fonts.map({ Calibri: 'Product Sans' }); await readySuperDoc.fonts.preload(['Calibri']); }, onFontsChanged: ({ missingFonts }) => { if (missingFonts?.length) console.warn('Missing document fonts', missingFonts); }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` `createTheme()` injects a scoped CSS-variable class. Apply it to the element that contains the Editor. Start with semantic colors and add raw `--sd-*` variables only for a specific component need. For strict CSP or server rendering, use `buildTheme()` and inject the returned CSS with your own nonce. `uiDisplayFallbackFont` sets the UI fallback stack. It does not change fonts stored in the DOCX. The `superdoc.fonts` API reports document fonts, registers physical faces, maps logical Word families to render families, and preloads faces before interaction. Mapping is render-only: export preserves the logical family name. Observe `onFontsChanged` or `fonts.onReport()` before declaring a font missing, because the authoritative report includes substitution and load state. Host font assets on a stable, CORS-compatible origin and include them in CSP. Confirm their license permits web delivery. Test pagination after font changes because glyph metrics can change line and page breaks. --- # Choose your editor interface > Decide whether to use SuperDoc's built-in UI, configure it, or build your own controls. Every Editor integration uses the same DOCX engine, document lifecycle, and public Document API. The interface decision determines who owns the controls around the document. > **Diagram:** The built-in UI and a custom application UI both control the same SuperDoc editor and Document API. ## Compare the options [#compare-the-options] | Approach | Choose it when | Your application owns | SuperDoc owns | | ---------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------- | | Built-in UI | You need a complete editing experience quickly | Product shell, document access, workflow | Toolbar, editing surface, review controls, responsive editor behavior | | Configured built-in UI | The built-in experience fits, but some controls should be hidden or rearranged | Product shell and toolbar configuration | Control behavior, command state, editing surface | | Custom UI | Your product needs its own layout, controls, or workflow | Toolbar, panels, interaction design, application state | Document rendering, editing, public command and state controller | Start with the built-in UI unless product requirements clearly need custom controls. It provides the shortest path to a complete, accessible editing workflow and helps the team learn which interactions actually need customization. ## Use the built-in UI [#use-the-built-in-ui] Choose the built-in UI for document editors, review screens, and internal workflows where the standard editing experience already fits. You can still control the document mode, current user, modules, theme, file lifecycle, and export behavior. Configuring the built-in UI is usually a smaller and safer step than rebuilding every control. [See the built-in UI overview](/editor/built-in-ui/overview). ## Build a custom UI [#build-a-custom-ui] Choose a custom UI when the surrounding product experience is part of the differentiation. Examples include a focused contract approval screen, a form-like document workflow, or controls that must match an established application design system. `superdoc/ui` exposes a framework-neutral controller. `superdoc/ui/react` adds React providers and hooks over that controller. Both stay on public v2 surfaces and operate against the same active Editor and Document API. [Understand the custom UI model](/editor/custom-ui/overview). ## Keep the lifecycle the same [#keep-the-lifecycle-the-same] Whichever interface you choose: 1. Create a `SuperDoc` editor with a DOCX and a visible container. 2. Wait for `onReady` before binding document-dependent controls. 3. Read state and run commands through public surfaces. 4. Inspect receipts for programmatic changes. 5. Export the DOCX and call `editor.destroy()` when finished. That tears down `editor.ui` with it, so destroy a controller yourself only if you built it with `createSuperDocUI()`. Switching interface strategies should not change how the document is opened, represented, or saved. It changes which layer renders and owns the controls. > **Do not rebuild the document canvas (note)** > > Bring your own UI means building the controls and workflow around SuperDoc. The Editor still owns DOCX rendering, > layout, selection, and editing behavior. For the shortest complete path, continue with the [Editor quickstart](/editor/quickstart). To own the controls, start with [Custom UI controller setup](/editor/custom-ui/controller-setup).