# 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 | [Build an agent](/agents/build/build-an-agent) | | 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) | | Run a known operation from a shell or CI | [CLI](/agents/automation/cli) | | 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 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 | | ----------- | --------------------------------------------------- | --------------- | | Node.js SDK | Node.js owns the workflow, errors, and output files | `@superdoc/sdk` | | Python SDK | Python owns the workflow, errors, and output files | `superdoc-sdk` | | CLI | A shell script or CI job runs the workflow | `@superdoc/cli` | The Node.js SDK also provides the agent toolkit, so a Node.js product that embeds its own agent needs only `@superdoc/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. --- # Store application data in DOCX > Choose the DOCX structure that matches how your application stores and presents data. Choose a document structure by what the data means and how other DOCX tools should understand it. Do not recreate the ProseMirror node or mark that previously carried it. | What you need | Use | Stored as | How it appears | | ----------------------------------------------------- | --------------------- | ----------------------------------------------- | --------------------- | | A Word-compatible citation with a bibliography source | `doc.citations` | A citation field and source record | From the field result | | A calculated or cached Word field | `doc.fields` | An OOXML field instruction and result | From the field result | | A structured region that people can edit | `doc.contentControls` | A content control (`w:sdt`) | Yes | | Application JSON attached to a text range | `doc.metadata` | A hidden inline content control plus Custom XML | Not visible by itself | | Application data that is not attached to text | `doc.customXml` | A Custom XML part | Not visible | Use the public API rather than editing these XML elements directly. SuperDoc keeps the related package parts and relationships consistent when the document changes. ## Anchored application data [#anchored-application-data] Use anchored metadata for an application-owned record such as a review finding, external reference, or backend identifier. The caller chooses the record ID and namespace: ```ts // A user action, not module scope: the guards below return early, and `return` // is only legal inside a function. async function attachFinding() { const doc = superdoc.activeEditor?.doc; // `capture()` reports readiness; `current()` does not. A worker-backed read is // `pending` or `stale` while it settles and still carries the previous range, // so writing from it anchors the record to the user's earlier selection. const capture = superdoc.ui.selection.capture(); const target = capture?.status === 'ready' ? capture.selectionTarget : null; // Anchoring is a single non-empty text range inside one **body** paragraph. // Check every part of that before writing, rather than handling rejections: // // - `SelectionPoint` is a union; a `nodeEdge` endpoint has no blockId or offset. // - Two paragraphs cannot share one hidden inline SDT. // - The adapter resolves the paragraph against the main document part, so a // header, footer, footnote, or endnote paragraph is not found. // - A selection crossing deleted tracked text reports `coordinateSpace: // 'tracked'`, and the anchor rewrite searches visible `w:t` runs only, so the // same offsets address different characters. // // These guards are necessary but not sufficient. A selection crossing two // `w:t` runs, which is what a bold or italic boundary produces, passes all of // them and still throws: the rewrite requires both offsets inside one run. // Nothing in the public selection shape reports run boundaries, so the catch // below is the only place that case can be handled. if (!doc || !target) return; if (target.coordinateSpace === 'tracked') return; if (target.start.kind !== 'text' || target.end.kind !== 'text') return; if (target.start.blockId !== target.end.blockId) return; if (target.start.offset === target.end.offset) return; const story = target.story ?? target.start.story; if (story !== undefined && story.storyType !== 'body') return; // `attach()` throws for several ordinary cases instead of returning an // unsuccessful receipt: when the id already exists (including the second time // this action runs), when the range crosses two `w:t` runs, and when the // paragraph is not in the main document part. // // Use try/catch rather than `.catch()`. This facade is typed `MaybePromise`, so // on a synchronous host `attach()` returns the receipt itself and has no // `.catch` method, and a synchronous validation throw happens before any // promise exists to reject. try { const receipt = await doc.metadata.attach({ id: 'finding-42', namespace: 'urn:example:review', target, payload: { sourceId: 'source-7', justification: 'Verify this statement' }, }); if (!receipt.success) reportToUser(receipt.failure.message); } catch (error) { reportToUser(error instanceof Error ? error.message : String(error)); } } ``` > **Attaching metadata discards run formatting (warning)** > > Attaching metadata currently discards the formatting of the text it wraps. The adapter removes the enclosing `w:r` and > rebuilds the selected text in a new run without its `w:rPr`, so bold, italic, colour, and character-style formatting > are lost from the saved DOCX with no error and no visible failure at attach time. The same rewrite can also drop a > space that follows the anchored range, described below. Both are adapter defects rather than intended behaviour, so > verify exported files if you anchor into formatted text or at a word boundary. Metadata persists with the DOCX but does not add visible styling. Add a render-only extension visual when people need to see the anchored range. The anchor is painted as a content control. A viewport hit carries the metadata ID in `tag`, while `id` identifies the underlying content control. A record with that ID existing is not proof the click landed on its anchor: nothing stops an ordinary content control from carrying a tag that matches a record anchored elsewhere, including elsewhere in the same paragraph. Comparing the clicked control's full range with where the record resolves rules out that case, though not every case, as the note after the example explains: ```ts import type { SelectionTarget } from 'superdoc/ui'; // Both endpoints must be `text` to compare positions at all: a `nodeEdge` // endpoint carries a node reference rather than a block and offset. // // Compare the whole story, not just its `storyType`. Two different headers are // both `headerFooterPart`, and block ids are only unique within a story, so // comparing the type alone lets an anchor in one header match a control in // another. The Document API has an internal canonical key for this; until it is // public, discriminate on the fields that identify each variant. const storyKey = (story: SelectionTarget['story']) => { if (!story) return 'body'; switch (story.storyType) { case 'headerFooterPart': return `headerFooterPart:${story.refId}`; case 'headerFooterSlot': return `headerFooterSlot:${story.section.sectionId}:${story.headerFooterKind}:${story.variant}`; case 'footnote': case 'endnote': return `${story.storyType}:${story.noteId}`; case 'textbox': return `textbox:${story.textboxId}`; default: return 'body'; } }; const sameTarget = (a: SelectionTarget, b: SelectionTarget) => { if (a.start.kind !== 'text' || a.end.kind !== 'text') return false; if (b.start.kind !== 'text' || b.end.kind !== 'text') return false; return ( a.start.blockId === b.start.blockId && a.end.blockId === b.end.blockId && a.start.offset === b.start.offset && a.end.offset === b.end.offset && storyKey(a.story) === storyKey(b.story) ); }; editorShell.addEventListener('pointerdown', async (event) => { const doc = superdoc.activeEditor?.doc; if (!doc) return; const context = superdoc.ui.viewport.contextAt({ x: event.clientX, y: event.clientY }); // Hits are innermost-first, and a metadata anchor can contain an ordinary // nested control. Test every content-control hit rather than the innermost, // or a nested control shadows the anchor that encloses it. // // A non-body hit cannot be filtered out here, and it cannot be detected // afterwards either. `context.position` is always `null` in v2 and // `hit.story` is populated only for tracked-change hits, so nothing reports // the story of a content control. Both lookups below read the main document // part, and painted ids are unique only within that part, so a header, // footer, note, or textbox control that reuses a body anchor's id and tag // resolves the body record and passes every check below. // // The example opens the record because a colliding non-body control is // unlikely in documents this application produced. That is a judgement about // your corpus, not a guarantee from the API. If your documents come from // elsewhere, treat a match as unverified and confirm through your own // backend before acting on it. for (const hit of context.entities.filter((entity) => entity.type === 'contentControl')) { if (!hit.tag) continue; // A metadata anchor is a hidden **inline** content control, so `kind` below // must say `inline`. Read it from the hit rather than hardcoding it: `scope` // is absent when the painted control carries no scope attribute, and a // block control cannot be an anchor, so both cases skip. if (hit.scope !== 'inline') continue; // Read the control through the Document API rather than the UI catalog. // `ui.contentControls.list()` schedules its read and returns whatever is // cached, so an early click sees an empty or stale array; the awaited // Document API call always answers about the current document. // // Compare the whole range, not just the block: a colliding tag can sit in // the same paragraph as the real anchor. // // `get()` throws rather than returning null for an id it cannot find, and // an uncaught throw here would surface as an unhandled rejection from this // listener. Treat a failed lookup as "not this hit" and keep scanning. let control: Awaited> | null = null; let anchor: Awaited> | null = null; try { [control, anchor] = await Promise.all([ doc.contentControls.get({ target: { kind: 'inline', nodeType: 'sdt', nodeId: hit.id } }), doc.metadata.resolve({ id: hit.tag }), ]); } catch { continue; } if (!anchor || !control?.selectionTarget) continue; if (!sameTarget(anchor.target, control.selectionTarget)) continue; const record = await doc.metadata.get({ id: hit.tag }); if (record) openApplicationRecord(record); return; } }); ``` > **The range comparison narrows the risk without closing it (warning)** > > The adapter treats the public `w:alias` value `Anchored metadata` as proof on its own that a control is an anchor, > without checking the rest of the anchor shape. That alias is the **Title** field in Word's content-control properties > dialog, so any author or any other tool can set it. Anchors are then matched by document position, first match wins, > so a control carrying that alias and a colliding tag earlier in the document resolves *instead of* the real anchor. > The comparison above still succeeds, because both sides describe the same wrong control. Treat an anchored record as a > hint from document content rather than as an authenticated identity, and keep anything security-relevant in your own > backend, keyed by the record ID. Anchored metadata currently requires a non-empty text range within one body paragraph, in visible coordinates, and within a single run. The adapter resolves the paragraph against the main document part, so a header, footer, or note paragraph is not found, and the anchor rewrite searches visible `w:t` runs, so a selection crossing deleted tracked text addresses different characters than its offsets suggest. Resolve the record again with `doc.metadata.resolve({ id })` after document edits instead of keeping an old range. The read path carries the same body-only restriction, and gives you nothing to check it with. `contentControls.get()` and `metadata.resolve()` both read the main document part, while painted content-control ids are unique only within that part. A control in a header, footer, note, or textbox that reuses a body anchor's id and tag therefore resolves the body record, and every comparison the example makes succeeds against it. Nothing lets you tell the two apart: `context.position` is always `null` in this release, and `hit.story` is populated only for tracked-change hits, so neither reports the story of a content control. The single-run requirement is the one that surfaces most often, because a bold or italic word splits a paragraph into several runs and a selection across that boundary throws even though it looks like ordinary text in one paragraph. Plain unformatted text is not a safe case either. The rewrite marks the text before the anchor with `xml:space="preserve"` and does not mark the text after it, so anchoring a word that ends right before a space can lose that space when the document is saved and reopened, turning `foo bar` into `foobar`. That is document text changing, not just formatting. Until it is fixed, prefer ranges that do not end next to a space, and check exported files if you anchor at word boundaries. Anything in a paragraph that is not literal text shifts the two coordinate systems apart. A selection counts a tab, a line break, and an inline image as caret positions, while the anchor rewrite accumulates offsets from `w:t` contents alone. Any of them before or inside the selection makes the stored anchor land later in the paragraph than the user selected, and a long enough run of following text absorbs the difference so the write succeeds instead of failing. Tabs make this ordinary rather than exotic: a hanging-indent list item begins with one. There is no public option to reconcile the two coordinate systems today, so prefer selections in paragraphs with no tabs, breaks, or inline objects before the range, and verify anchors in exported files when a paragraph has them. ## Citation or application record? [#citation-or-application-record] Use `doc.citations` when Word and other DOCX tools should recognize the value as a citation backed by a bibliography source. Use `doc.metadata` when the meaning belongs to your application and the document only needs to preserve its ID, payload, and text anchor. These models are not interchangeable. Check the generated operation reference for the current insertion and presentation capabilities before migrating a citation workflow. --- # 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. --- # Real-time collaboration > Connect the browser editor to a v2 collaboration room. SuperDoc v2 can synchronize one DOCX through a y-websocket, Hocuspocus, or Liveblocks provider. The browser editor owns the provider and Y.Doc lifecycle after your application supplies a `v2Collaboration` target on the document. Use `roomMode: 'create'` once to seed a missing room from the document's `data`. Later clients use `roomMode: 'join'` with the same `documentId`: ```ts const document = { id: 'shared-document', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', data: docxBlob, v2Collaboration: { providerType: 'hocuspocus', documentId: 'contract-123', serverUrl: 'wss://collaboration.example.com', roomMode: 'join', }, }; ``` Wait for `onCollaborationReady` before enabling document-dependent controls. Call `destroy()` when the editor unmounts so SuperDoc releases its owned connection. The [collaboration example](https://go.superdoc.dev/examples/collaboration) provides a complete local setup with an in-memory Hocuspocus server and two synchronized browser pages. Authentication and persistence belong in server hooks and are intentionally outside that first example. > **Create and join are explicit (note)** > > V2 does not provide a join-or-create mode. Creating an existing room or joining a missing room fails. Retry with a > fresh editor instance after your application decides which operation is correct. --- # 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. The [document modes example](https://go.superdoc.dev/examples/document-modes) is a complete project that switches one DOCX between all three modes and exports a suggested edit. ## 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, and `latest` is the v2 line, so `pnpm add superdoc` installs it. There is no runtime setting that switches an installed editor between v1 and v2. ## Mount the Editor [#mount-the-editor] SuperDoc v2 has one stable browser mounting API: `SuperDoc` from `superdoc`. Use it directly in vanilla JavaScript, or bind its creation and cleanup to your framework's component lifecycle. | Application | Start with | What it owns | | --------------------------------------- | ------------------------------------------------ | ------------------------------------------------------- | | Vanilla JavaScript or another framework | [Editor quickstart](/editor/quickstart) | Editor creation, configuration, lifecycle, and export | | React | [React guide](/editor/frameworks/react) | Mount timing, readiness state, cleanup, and export | | React with application-owned controls | [React custom UI](/editor/custom-ui/react-setup) | Reactive toolbar and panel state over the same instance | `superdoc/ui/react` provides hooks for custom controls. It is part of the `superdoc` package and does not mount a second Editor. 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 ``` SuperDoc v2 is the stable major release, so the default `latest` tag installs it. 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 complete project is available at [go.superdoc.dev/examples/vanilla](https://go.superdoc.dev/examples/vanilla). 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. ## Decide who renders the interface [#decide-who-renders-the-interface] The editor above renders the document canvas and SuperDoc's default surfaces. It has no toolbar: the built-in toolbar needs an element to mount into, and this example never named one. Adding `ui: { toolbar: { container: '#toolbar' }, search: true }` and a matching element is all it takes. Pair the toolbar with `search: true`. The toolbar draws a Search button either way, and that button opens the find/replace surface, so without it the control renders and does nothing. That is the first of three ownership modes, and moving between them is a configuration change rather than a different architecture. * **Keep the built-in UI.** Give the toolbar a mount target and configure what you need. [Built-in UI overview](/editor/built-in-ui/overview). * **Replace selected surfaces.** Turn off the ones your product owns and keep the rest. This is where most production integrations land. * **Build a fully custom UI.** `ui: false` hides all built-in chrome while editing, the Document API, and `superdoc.ui` keep working. [Custom UI overview](/editor/custom-ui/overview). [Choose an interface](/editor/ui/choose-an-interface) walks through all three with a runnable example of each. ## 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. * [Configure the Editor](/editor/platform/configuration) explains how `ui`, `interaction`, and `surfaces` divide up the configuration. --- # SuperDoc DOCX Engine Proprietary License > Read the proprietary license that governs access to and use of the DOCX Engine. **Version 2026-07-14** Company: Harbour Enterprises, Inc., d/b/a SuperDoc, 1735A Union Street, San Francisco, CA 94123 (“Company”) Canonical URL: [https://docs.superdoc.dev/resources/docx-engine-license](/resources/docx-engine-license) This SuperDoc DOCX Engine Proprietary License (this “License”) governs access to and use of the proprietary package published as @superdoc/docx-engine, together with any related code, documentation, examples, tests, artifacts, and materials. It is entered into between Harbour Enterprises, Inc., d/b/a SuperDoc (“Company”), and the customer or user that accepts it (“Customer”). This License operates in two ways: (a) where Customer and Company have entered into a SuperDoc Master Services Agreement, SuperDoc Commercial License Agreement, order form, or other written agreement governing SuperDoc or DOCX Engine (the “Base Agreement”), this License supplements and forms part of that Base Agreement with respect to DOCX Engine; and (b) where no Base Agreement exists, this License is a standalone, binding proprietary license governing Customer’s access to and use of DOCX Engine. Capitalized terms used but not defined here have the meanings given in the Base Agreement, if any. Acceptance by use. By installing, importing, executing, copying, or otherwise using DOCX Engine — including by installing any software package that incorporates DOCX Engine as a dependency — Customer agrees to be bound by this License. This License is a conspicuous license included with the DOCX Engine package; no click-through or signature is required for it to be binding. If Customer does not agree, Customer must not install, access, or use DOCX Engine. Existing customers; no separate acceptance required. If, at the time of access, Customer has a Base Agreement in effect that grants Customer the right to use DOCX Engine (including where the Base Agreement’s defined “Services,” “Software,” or licensed scope encompasses DOCX Engine), then the Base Agreement governs Customer’s use of DOCX Engine, this License’s acceptance requirement is deemed satisfied, and no separate or further acceptance of this License is required as a condition of access. In that case, this License applies only to supplement the Base Agreement to the extent set forth in Section 6, and any conspicuous notice or in-package license is provided for the convenience of users who lack a Base Agreement and does not impose additional terms on Customer. Open-source notice. SuperDoc’s open-source code is available under AGPLv3. DOCX Engine, published as @superdoc/docx-engine, is proprietary software and is not open source. The open-source SuperDoc editor and the proprietary DOCX Engine are separately licensed components. Use of SuperDoc’s open-source code remains governed by the applicable open-source license. Use of DOCX Engine is governed by this DOCX Engine Proprietary License. Nothing in the open-source license grants any right to reverse engineer, deobfuscate, reconstruct, clone, or reimplement DOCX Engine, and nothing in this License modifies Customer’s rights or obligations under the AGPLv3 license for SuperDoc’s open-source code. ## 1. Definitions [#1-definitions] 1.1 “DOCX Engine” means the proprietary DOCX engine software package published as @superdoc/docx-engine, including any updates, upgrades, patches, and successor versions provided by or on behalf of Company. 1.2 “DOCX Engine Materials” means DOCX Engine and any related code, object code, minified, compiled, or obfuscated code, source maps, if ever provided, APIs, SDKs, libraries, license keys, examples, sample code, documentation, install instructions, tests, demos, package artifacts, and other materials provided by or on behalf of Company in connection with DOCX Engine. 1.3 “Derived Engine Information” means non-public behavior, outputs, performance characteristics, benchmarks, analyses, implementation details, test results, or other technical information derived from access to the DOCX Engine Materials, including information derived by inspecting, testing, benchmarking, prompting, analyzing, or running DOCX Engine for the purpose of understanding its non-public implementation, reconstructing its functionality, or creating a substitute or substantially similar implementation. For clarity, DOCX Engine Materials and Derived Engine Information do not include Customer Data, Customer-authored applications, Customer-authored integrations, or ordinary documents, files, content, or outputs created by Customer or its end users through authorized use of DOCX Engine, except to the extent such materials reveal non-public implementation details of DOCX Engine or are used to reverse engineer, benchmark, validate, train, or create a substitute or substantially similar implementation. 1.4 “Competing Product” means any document editor, document editing or rich-text SDK, collaborative editing system, document rendering or conversion engine, diffing tool, document export system, hosted document infrastructure product, or other product or service that competes with or is substantially similar to SuperDoc or any DOCX Engine Materials. 1.5 “Authorized Use” means installing and using DOCX Engine solely as a dependency of SuperDoc, including with SuperDoc’s AGPL-licensed open-source code, for evaluation, development, testing, and other use permitted under the AGPLv3 license applicable to SuperDoc’s open-source code, in each case solely in compliance with this DOCX Engine Proprietary License. For Customers with a Base Agreement, order form, selected plan, or other written agreement with Company, Authorized Use also includes any production or commercial use expressly permitted by that agreement, subject to the licensed scope, plan limits, permitted document experience(s), usage limits, and term stated therein. 1.6 “Prohibited AI Use” means uploading, submitting, disclosing, ingesting, indexing, embedding, summarizing, translating, deobfuscating, analyzing, or otherwise processing the DOCX Engine Materials, or any behavior, outputs, performance characteristics, benchmarks, analyses, implementation details, or test results derived from access to the DOCX Engine Materials, using any artificial intelligence, machine learning, code-generation, code-analysis, model-training, model-evaluation, or automated reverse-engineering system, including any large language model (LLM) or other generative AI system, for the purpose of reconstructing source code, understanding non-public implementation details, creating a substitute or substantially similar implementation, including any clean-room, black-box, or behavior-based reimplementation, or developing, training, fine-tuning, prompting, benchmarking, validating, improving, or assisting a Competing Product. ## 2. License Grant [#2-license-grant] 2.1 Subject to Customer’s continued compliance with this DOCX Engine Proprietary License, the Base Agreement, if any, and payment of all applicable fees, if any, Company grants Customer a limited, non-exclusive, non-transferable, non-sublicensable license to install and use DOCX Engine solely as a dependency of SuperDoc, including with SuperDoc’s AGPL-licensed open-source code, and solely for Authorized Use. If Customer has a Base Agreement, order form, selected plan, or other written agreement with Company that expressly grants rights to use DOCX Engine, Customer’s use is limited to the licensed scope, plan limits, permitted document experience(s), usage limits, and term stated in that agreement. If Customer does not have a Base Agreement, Customer may install and use DOCX Engine solely as a dependency of SuperDoc, including with SuperDoc’s AGPL-licensed open-source code, for uses permitted under the AGPLv3 license applicable to SuperDoc’s open-source code, provided that Customer complies with this DOCX Engine Proprietary License. For clarity, this License does not permit Customer to use DOCX Engine, DOCX Engine Materials, or Derived Engine Information to reverse engineer, reconstruct, deobfuscate, clone, reimplement, validate or benchmark the DOCX Engine, nor does this License permit Customer to use DOCX Engine, DOCX Engine Materials, or Derived Engine Information, including in connection with any AI tools, to train, improve, or assist any Competing Product or substitute implementation of the DOCX Engine. 2.2 Contractors and affiliates. Customer may permit its affiliates and contractors to use DOCX Engine solely for Customer’s internal benefit and solely within Customer’s licensed scope, provided that each such affiliate and contractor is bound by written obligations no less protective of Company than this License. Customer is fully responsible and liable for all acts and omissions of its affiliates, contractors, users, and representatives as if they were Customer’s own. 2.3 Self-hosting. DOCX Engine is self-hosted by Customer. Company does not host Customer’s production deployment and does not access Customer’s document contents from Customer’s production environment unless Customer voluntarily provides such information to Company for support, troubleshooting, implementation, or other assistance. 2.4 Reservation of rights. All rights not expressly granted in this License are reserved by Company. No right or license is granted by implication, estoppel, or otherwise. ## 3. Restrictions [#3-restrictions] 3.1 Customer will not, and will not permit its affiliates, contractors, users, or representatives to, directly or indirectly: (a) Reverse engineer, deobfuscate, decompile, disassemble, decode, reconstruct source code from, reconstruct any source map of, or otherwise attempt to discover the source code, object code, underlying structure, ideas, know-how, algorithms, or non-public implementation details of the DOCX Engine Materials or Derived Engine Information, whether manually or with the assistance of any large language model (LLM) or other AI system, except to the limited extent, and only to the extent, such restriction is prohibited by non-waivable applicable law; (b) Modify, translate, or create derivative works based on the DOCX Engine Materials, except to the extent expressly permitted by Company or authorized within DOCX Engine; (c) Remove, alter, or obscure any license, copyright, or other proprietary notices, banners, or labels, or any hidden, embedded, or forensic markers, contained in or accompanying the DOCX Engine Materials; (d) Redistribute, republish, mirror, resell, rent, lease, sublicense (except to contractors as permitted in Section 2.2), or otherwise make the DOCX Engine Materials available to any third party, or make any DOCX Engine package available as a standalone package; (e) No cloning. Use the DOCX Engine Materials or Derived Engine Information to develop, train, prompt, benchmark, validate, improve, or assist any Competing Product, substitute implementation, or substantially similar implementation, including by means of any artificial intelligence, machine learning, or automated system. For clarity, building Customer-authored applications and integrations powered by DOCX Engine, including with AI or developer-assistance tools, is permitted within Authorized Use; using the DOCX Engine Materials or Derived Engine Information to reconstruct or substitute for DOCX Engine is not. (f) No prohibited AI use; no clean-room cloning. Engage in any Prohibited AI Use, or use DOCX Engine behavior, outputs, examples, tests, documentation, DOCX Engine Materials, or Derived Engine Information as a specification to clone, substitute for, or otherwise reimplement DOCX Engine, whether by clean-room, black-box, or behavior-based methods or with the assistance of a large language model (LLM) or other AI system. (g) Circumvent or attempt to circumvent any obfuscation, technical protection, marker, or license-enforcement measure in the DOCX Engine Materials; (h) Use the DOCX Engine Materials outside the scope of the Authorized Use or the licensed scope, plan, or document experience(s) without Company’s prior written approval; or (i) Accept this License using a false or misappropriated identity, or purport to accept it on behalf of an entity without authority to bind that entity. 3.2 Customer represents, covenants, and warrants that it will use the DOCX Engine Materials and Derived Engine Information only in compliance with this License, the Base Agreement, if any, and all applicable laws and regulations. ## 4. Ownership and Proprietary Rights [#4-ownership-and-proprietary-rights] 4.1 Company owns and retains all right, title, and interest in and to the DOCX Engine Materials, all improvements, enhancements, modifications, and derivative works thereof, and all intellectual property rights in any of the foregoing. Except for the limited license expressly granted in Section 2, no ownership interest or other right in the DOCX Engine Materials is transferred to Customer. 4.2 As between the parties, Customer owns all right, title, and interest in and to Customer Data, as defined in the Base Agreement. If no Base Agreement exists, “Customer Data” means documents, files, data, content, prompts, workflows, models, outputs, materials, and other information processed through DOCX Engine by Customer or its end users, or voluntarily provided by Customer to Company. 4.3 Feedback. If Customer provides Company with suggestions, feedback, or recommendations regarding the DOCX Engine Materials, Company may use and incorporate such feedback without restriction or obligation to Customer. ## 5. Confidentiality [#5-confidentiality] 5.1 The DOCX Engine Materials are proprietary to Company. Non-public information regarding the source code, non-public implementation details, architecture, roadmap, pricing, support, implementation, security, performance, benchmarks, test results, and operation of DOCX Engine constitutes Company’s Proprietary Information and Confidential Information. The confidentiality obligations of the Base Agreement, if any, apply to such information. 5.2 If no Base Agreement exists, Customer agrees (a) to take reasonable precautions to protect the DOCX Engine Materials and Company’s other Proprietary Information, and (b) not to use or disclose such information except as necessary to exercise its rights under this License. These obligations do not apply to information that Customer can document is or becomes generally available to the public without breach of this License, was rightfully known to Customer without restriction prior to receipt, was rightfully disclosed to Customer without restriction by a third party, or was independently developed without use of Company’s Proprietary Information, or to disclosures required by law. ## 6. Order of Precedence [#6-order-of-precedence] 6.1 This License supplements the Base Agreement with respect to DOCX Engine. In the event of a conflict between this License and the Base Agreement regarding DOCX Engine: (a) to the extent the Base Agreement expressly addresses the same subject matter and affords Company protections equal to or greater than those in this License, the Base Agreement controls; and (b) to the extent this License affords Company greater protection with respect to the DOCX Engine Materials — including the restrictions in Section 3 (reverse engineering, no cloning, prohibited AI use, and proprietary notices and markers), ownership in Section 4, and the remedies in Sections 9 and 10 — this License controls. 6.2 Where no Base Agreement exists, this License is the complete and exclusive agreement between the parties governing the DOCX Engine Materials and supersedes all prior or contemporaneous understandings relating to that subject matter. 6.3 Existing customers. For Customers with a Base Agreement that expressly grants rights to use DOCX Engine, this License supplements the Base Agreement only with respect to DOCX Engine. This License does not create additional fees or expand Customer’s licensed scope, plan limits, permitted document experience(s), usage limits, or term. To the extent Customer’s Base Agreement expressly addresses the same subject matter, Section 6.1 governs order of precedence. ## 7. Term and Termination [#7-term-and-termination] 7.1 This License takes effect upon Customer’s acceptance and continues for the term of the Base Agreement or the applicable order form or selected plan. Where no Base Agreement exists, this License continues until terminated in accordance with this Section 7. 7.2 Any breach or attempted breach of Section 3 involving reverse engineering, deobfuscation, source-code reconstruction of the DOCX Engine, Prohibited AI Use, clean-room or black-box cloning of the DOCX Engine, circumvention of technical protections, removal of proprietary notices or markers, or unauthorized redistribution of the DOCX Engine automatically and immediately terminates the license granted in Section 2. For other breaches of Authorized Use, licensed scope, plan limits, or document experience limits, Company may suspend or terminate Customer’s access to DOCX Engine if Customer does not cure the breach after notice. No cure period is required for any breach that is intentional, repeated, unlawful, likely to cause material harm, or reasonably likely to compromise the confidentiality, security, ownership, or proprietary status of the DOCX Engine Materials. 7.3 Upon termination or expiration, Customer’s license rights end, and Customer will cease all use of the DOCX Engine Materials, permanently delete or destroy all copies in its possession or control, and, upon Company’s request, certify such deletion or destruction in writing. Customer remains responsible for ensuring that any continued use of SuperDoc complies with the applicable open-source license or a separate commercial license. 7.4 Survival. Sections 1, 3, 4, 5, 6, 7.3, 8, 9, 10, and 11, and any accrued payment obligations, survive termination or expiration of this License. ## 8. Warranty Disclaimer [#8-warranty-disclaimer] THE DOCX ENGINE MATERIALS ARE PROVIDED “AS IS,” AND COMPANY DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. COMPANY DOES NOT WARRANT THAT THE DOCX ENGINE MATERIALS WILL BE UNINTERRUPTED OR ERROR-FREE, OR THAT ANY RESULTS OBTAINED FROM THEIR USE WILL BE ACCURATE OR RELIABLE. CUSTOMER ACKNOWLEDGES THAT DOCX ENGINE IS SELF-HOSTED AND THAT CUSTOMER IS RESPONSIBLE FOR OPERATING ITS OWN DEPLOYMENT ENVIRONMENT, INFRASTRUCTURE, SYSTEMS, PROMPTS, MODELS, WORKFLOWS, AND ANY THIRD-PARTY AI SYSTEMS USED WITH DOCX ENGINE. ## 9. Limitation of Liability [#9-limitation-of-liability] 9.1 EXCEPT FOR A PARTY’S GROSS NEGLIGENCE OR WILLFUL MISCONDUCT, IN NO EVENT WILL EITHER PARTY BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, REVENUE, GOODWILL, OR DATA, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 9.2 EXCEPT FOR A PARTY’S GROSS NEGLIGENCE OR WILLFUL MISCONDUCT, EACH PARTY’S AGGREGATE LIABILITY ARISING OUT OF OR RELATING TO THIS LICENSE SHALL NOT EXCEED THE FEES PAID OR PAYABLE BY CUSTOMER FOR DOCX ENGINE IN THE TWELVE (12) MONTHS IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO LIABILITY; PROVIDED THAT EACH PARTY’S AGGREGATE LIABILITY FOR CLAIMS ARISING FROM ITS CONFIDENTIALITY OBLIGATIONS, DATA SECURITY OBLIGATIONS, OR INDEMNIFICATION OBLIGATIONS SHALL NOT EXCEED TWO TIMES (2X) THE FEES PAID OR PAYABLE BY CUSTOMER FOR DOCX ENGINE IN THE TWELVE (12) MONTHS IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO LIABILITY. WHERE A BASE AGREEMENT EXISTS, THE LIABILITY PROVISIONS OF THE BASE AGREEMENT GOVERN ON AN AGGREGATE BASIS ACROSS THE BASE AGREEMENT AND THIS LICENSE, AND THIS SECTION 9 DOES NOT INCREASE EITHER PARTY’S CAP UNDER THE BASE AGREEMENT. IF CUSTOMER HAS NOT PAID AND DOES NOT OWE ANY FEES FOR DOCX ENGINE, COMPANY’S AGGREGATE LIABILITY ARISING OUT OF OR RELATING TO THIS LICENSE SHALL NOT EXCEED ONE HUNDRED DOLLARS ($100). 9.3 NOTWITHSTANDING THE FOREGOING, (A) CUSTOMER’S PAYMENT OBLIGATIONS SHALL NOT BE LIMITED BY SECTION 9.2; (B) CUSTOMER’S BREACH OF SECTION 3 OR USE OF THE DOCX ENGINE MATERIALS OUTSIDE THE LICENSED SCOPE SHALL NOT LIMIT COMPANY’S RIGHT TO SEEK PAYMENT OF APPLICABLE ADDITIONAL OR UPGRADE FEES, SUSPENSION, TERMINATION, OR INJUNCTIVE OR EQUITABLE RELIEF; AND (C) TO THE EXTENT THE PARTIES HAVE A BASE AGREEMENT, THIS SECTION 9 DOES NOT LIMIT EITHER PARTY’S INDEMNIFICATION OBLIGATIONS UNDER THE BASE AGREEMENT. ## 10. Equitable Relief [#10-equitable-relief] 10.1 Customer acknowledges that any breach or threatened breach of Section 3 (Restrictions), Section 4 (Ownership), or Section 5 (Confidentiality) would cause Company irreparable harm for which monetary damages would be an inadequate remedy. Accordingly, Company is entitled to seek injunctive and other equitable relief to prevent or restrain any such breach, without the necessity of posting a bond or proving actual damages, in addition to any other remedies available at law or in equity. ## 11. General [#11-general] 11.1 Governing law. This License is governed by the laws of the State of California, without regard to its conflict-of-laws provisions. The state and federal courts located in San Francisco, California will have exclusive jurisdiction over any dispute arising out of or relating to this License, and the parties consent to such jurisdiction and venue. 11.2 Assignment. This License is not assignable or transferable by Customer without Company’s prior written consent, except that either party may assign it in connection with a merger, acquisition, corporate reorganization, or sale of all or substantially all of its assets, provided that the assignee agrees in writing to be bound and the assignment does not expand Customer’s licensed scope or permitted use of the DOCX Engine Materials. 11.3 Severability and waiver. If any provision of this License is found unenforceable, it will be limited or eliminated to the minimum extent necessary so that the remainder remains in full force and effect. No waiver or modification is effective unless set forth in a writing signed by both parties, except that Company may publish updated versions of the standalone DOCX Engine license at a stable URL, and Customer’s continued use of DOCX Engine after the effective date of an updated standalone version constitutes acceptance; for Customers under a Base Agreement, changes to this License require mutual written agreement. 11.4 Versioning and records. Company may maintain records of the version, effective date, and content hash of each published version of this License and of Customer’s acceptance. Such records are admissible as evidence of the terms accepted by Customer. 11.5 Entire agreement. This License, together with the Base Agreement (if any) and any applicable order form, is the complete and exclusive statement of the parties’ agreement regarding the DOCX Engine Materials and supersedes all prior and contemporaneous communications relating to that subject matter. ## Acceptance [#acceptance] This License becomes binding on Customer when Customer (acting through a person with authority to bind it) installs, imports, executes, copies, or otherwise uses DOCX Engine, including by installing any software package that incorporates DOCX Engine as a dependency. The person acting for Customer represents that they are authorized to bind Customer, or that they are acting in an individual capacity and will not use DOCX Engine for any entity that has not accepted these terms. No click-through, order form, or handwritten or electronic signature is required for this License to be binding. *** Copyright © 2026 Harbour Enterprises, Inc., d/b/a SuperDoc. All rights reserved. --- # Licensing > Compare SuperDoc's open-source and commercial licensing options. SuperDoc is available under dual licensing. Choose the license that matches how you distribute and operate your application. ## Open source [#open-source] SuperDoc's open-source code is available under the [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.html). Use the open-source license when your application can meet the AGPLv3 requirements. ## Commercial [#commercial] Proprietary and commercial deployments are available under the [SuperDoc Commercial License](https://www.superdocportal.dev/superdoc-terms-of-service). For licensing questions, email [q@superdoc.dev](mailto:q@superdoc.dev). ## Contributing [#contributing] Contributions to the open-source project are welcome: 1. Check the [issue tracker](https://github.com/superdoc/docx-editor/issues) for open work. 2. Fork the repository and create a focused branch. 3. Follow the project's code and documentation guidelines. 4. Open a pull request that explains the reason for the change. Read the [Contributing Guide](https://github.com/superdoc/docx-editor/blob/main/CONTRIBUTING.md) for the complete workflow. --- # Trust & Security > Review SuperDoc's SOC 2 controls, deployment model, data practices, infrastructure, and vulnerability disclosure program. SuperDoc is SOC 2 Type II certified. Independent auditors verify security, privacy, and compliance controls. Drata continuously monitors more than 100 controls for security and GDPR compliance. **SuperDoc Editor**, the JavaScript library, is open source and self-hosted. Your documents stay on your infrastructure. The SuperDoc team cannot access your content. **SuperDoc APIs** are covered by SOC 2 controls and do not persist document data. Documents are processed and returned without persistent storage. ## SOC 2 report [#soc-2-report] An independent auditor maintains the SOC 2 report, certifying the controls that protect your data. The report follows the Trust Services Criteria from the AICPA's Assurance Services Executive Committee (ASEC). It evaluates the design and effectiveness of controls for security, availability, processing integrity, confidentiality, and privacy. ## Continuous control monitoring [#continuous-control-monitoring] Drata monitors more than 100 security and privacy controls continuously, including GDPR compliance. Automated alerts and evidence collection verify SuperDoc's compliance posture. ## Team access and training [#team-access-and-training] All employees use two-factor authentication, have role-based access restrictions, and sign a Non-Disclosure and Confidentiality Agreement. The team completes annual security training. ## Penetration tests [#penetration-tests] SuperDoc works with independent security firms to perform annual network and application-layer penetration tests. ## Secure software development [#secure-software-development] Manual and automated security checks run throughout the software development lifecycle. ## Data encryption [#data-encryption] Data is encrypted in transit with Transport Layer Security (TLS) and at rest with AES-256 encryption. ## Infrastructure [#infrastructure] SuperDoc's cloud infrastructure runs on Google Cloud Platform (GCP). Production data storage uses Google Spanner and Google Cloud Storage. GCP provides security, compliance, and auditing controls. ## Multi-region data storage and automated backups [#multi-region-data-storage-and-automated-backups] Cloud data is stored across multiple regions within the United States. Automatic backups replicate data across multiple US data center locations. ## Compliance, audit logs, and monitoring [#compliance-audit-logs-and-monitoring] Third-party monitoring detects potential attacks and anomalous network behavior. User actions in SuperDoc's cloud services are logged and auditable. GCP systems are regularly audited for ongoing security and compliance, including SOC 2. ## Terms of service and privacy policy [#terms-of-service-and-privacy-policy] SuperDoc is dual-licensed. The open-source SuperDoc project is available under the [GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0). Proprietary and commercial deployments are licensed under the [SuperDoc Commercial License](https://www.superdocportal.dev/superdoc-terms-of-service). Use of SuperDoc websites is governed by the [Website Terms of Use](https://app.termly.io/policy-viewer/policy.html?policyUUID=0b852fc9-5d9c-4170-8c96-08b072a55a9f). See the [Privacy Policy](https://www.harbourshare.com/privacy-policy) for how SuperDoc handles data. ## Vulnerability disclosure program [#vulnerability-disclosure-program] Found a security issue? Email [security@superdoc.dev](mailto:security@superdoc.dev). The security team investigates all reported issues promptly. --- # 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/overview): Inspect, change, and save DOCX files from Node.js, Python, the CLI, or an agent. --- # 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` in any web framework. In React, create and destroy the instance with the component lifecycle. Use `superdoc/ui/react` when React also owns custom toolbar or panel controls. ## 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/sdk` from Node.js or `superdoc-sdk` from Python. Use `@superdoc/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: * `createAgentToolkit()` from `@superdoc/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) or [Python SDK](/agents/automation/python-sdk) | | A shell or CI job changes DOCX files | [CLI](/agents/automation/cli) | | 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. ## Interact with DOCX files the same way in the browser and on the server [#interact-with-docx-files-the-same-way-in-the-browser-and-on-the-server] Use 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 renders the DOCX and gives people a visual interface for editing it 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. Try the browser surface below. Select the tracked change, then accept or reject it. > **Interactive editor: Try SuperDoc in the browser** > > Sample: [open the fixture](/fixtures/tracked-changes.docx). > Preset: `tracked-review`. > Tracked-change review: accept or reject the sample change. > Local DOCX selection: disabled. ## Licensing [#licensing] SuperDoc is available under the GNU AGPLv3 or a commercial license. Read [Licensing](/resources/license) to compare the options and choose the right path for your application. [See commercial 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 from the CLI > Accept tracked changes and save a separate DOCX from a shell or CI job. Use the CLI when a shell script or CI job 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 CLI [#1-install-the-cli] ```bash pnpm add --global @superdoc/cli@latest ``` The package installs the matching native command for your platform. Published builds support macOS on Apple Silicon and Intel, Linux on ARM64 and x64, and Windows on x64. Confirm that the command is available: ```bash superdoc --version ``` ## 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 ## 3. Accept the changes and save [#3-accept-the-changes-and-save] Create `accept-changes.sh`: ```sh #!/usr/bin/env bash set -euo pipefail superdoc open ./contract.docx trap 'superdoc close --discard >/dev/null 2>&1 || true' EXIT superdoc track-changes list superdoc track-changes accept-all superdoc save --out ./contract.accepted.docx ``` Run it from the directory containing `contract.docx`: ```bash bash accept-changes.sh ``` `open` creates an active document session. The review command changes that session, and `save --out` writes a separate DOCX. The `EXIT` trap closes the session whether the script succeeds or fails. The source file is not overwritten. > **Keep the output path clear (warning)** > > The CLI refuses to replace an existing output file unless you pass `--force`. Rename or inspect the existing file > before rerunning the script. ## 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 [Node.js SDK](/agents/automation/node-sdk) or [Python SDK](/agents/automation/python-sdk) when application code needs to own the workflow, errors, and output paths. For the operation and receipt model shared by every surface, continue with 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/sdk@latest ``` 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/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/sdk@latest ``` One call produces all three pieces: ```ts import { createAgentToolkit } from '@superdoc/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 it impossible for the pieces to disagree. ## 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/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_blocks', '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` gets 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/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/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. 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/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/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. One consequence is worth knowing: * A `createAgentToolkit()` call with no `preset` gets legacy, not core. 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. ## 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/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/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. ## 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. --- # 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/sdk@latest ``` ## 2. Create a tracked replacement [#2-create-a-tracked-replacement] Create `propose-change.mjs` next to `contract.docx`: ```mjs import { SuperDocClient } from '@superdoc/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. In this guide, you turn on the standard comments experience. A person can select text, create a thread, reply to it, resolve it, and export the result without building a separate comments UI. ## 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', user: { name: 'Alex Rivera', email: 'alex@example.com', }, ui: { toolbar: { container: '#toolbar' }, comments: { displayMode: 'auto', }, }, onCommentsUpdate: ({ type, comment }) => { console.log('Comment update:', type, comment?.commentId); }, }); ``` Comments are enabled by default, and so is resolution. The explicit configuration above makes one presentation choice: * `displayMode: 'auto'` switches between the sidebar and a compact inline presentation as the Editor narrows. Resolve and reopen actions are shown unless you turn them off with `interaction: { comments: { allowResolve: false } }`, which is a permission rather than a presentation setting. 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] `ui: { 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 `ui: { 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. In this guide, you build 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 = { container: '#toolbar', groups: { left: ['undo', 'redo'], center: ['bold', 'italic', 'underline', 'link'], right: ['documentMode', 'zoom'], }, responsiveToContainer: true, }; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', ui: { 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. Lower-priority controls leave the visible row when space becomes tight. That is on by default; set `ui.toolbar.hideButtons: false` to keep every control in the row and let it overflow instead. 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 become enabled, disabled, or 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: ```html
``` ```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, // `onSelect`, not the v1 `action` callback: v2 cannot invoke `action` // because its first argument is a ProseMirror Editor this runtime does // not have, so an `action`-only item warns once and dismisses. // // `context` is the snapshot captured when the menu opened, and it is // null when none was captured. `selectedText` is read synchronously to // keep the click's user activation, which `navigator.clipboard` // requires; it is empty when a worker-backed read had not settled by // click time, so copy only when it carries text. Awaiting // `selectedTextSettled` would return the accurate text but spend the // activation the clipboard write needs. // `onSelect` returns `void | Promise`, so returning the write // hands the rejection to the runtime instead of leaving an unhandled // one when clipboard access is denied. The write is still initiated // synchronously inside the gesture, which is what the permission // check requires. onSelect: ({ context }) => { if (!context?.selectedText) return; return navigator.clipboard.writeText(context.selectedText); }, }, ], }, ], } satisfies ContextMenuConfig; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', ui: { // A toolbar renders only once it has somewhere to mount. Naming groups // without a container leaves the handle available and the toolbar absent. toolbar: { container: '#toolbar', groups: { center: ['link'] } }, contextMenu, }, // `popoverResolver` has no `ui` equivalent yet: the link popover reads it // from `modules.links`, so this one stays where the runtime looks for it. modules: { links: { popoverResolver: resolveLinkPopover }, }, }); 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 a cleanup function when it installs listeners or mounts a framework root. This resolver is the one built-in UI setting that has no `ui` equivalent yet, so it stays under `modules.links`; `ui: { linkPopover: false }` suppresses the popover entirely. `ui.contextMenu.customItems` adds sections to both the built-in right-click menu and the slash menu. SuperDoc calls `showWhen()` with the current context; return `true` when the action is relevant. 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 the document mode, fitting the editor into a responsive layout, or matching product colors. Choose a custom UI when your application needs to own the control layout, how state is presented, 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] Request fullscreen on an element that contains the toolbar, the fullscreen button, and the 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', document: '/contract.docx', contained: true, zoom: { mode: 'fit-width', fitWidth: { min: 40, max: 100, padding: 24 }, }, ui: { toolbar: { container: '#toolbar', responsiveToContainer: 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. Lower-priority controls leave the visible row when space becomes tight, which is the default and needs no setting. `comments.displayMode: 'auto'` lets the review UI move between the sidebar and a compact 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', ui: { toolbar: { container: '#toolbar' }, search: true, }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` `ui: { search: 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. The [search example](https://go.superdoc.dev/examples/search) is a complete project with this configuration and a real DOCX behavior test. ## 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 editing is allowed [#replace-only-when-editing-is-allowed] 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 is still available but replace is 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: ```html
``` ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const temporaryImageUrls: string[] = []; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', ui: { toolbar: { // A toolbar renders only once it has somewhere to mount. container: '#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. --- # 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. If SuperDoc should render the panel instead, see [built-in comments](/editor/built-in-ui/comments). 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. ui: { 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 `ui: { 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, so the comment is anchored to the text the reader had selected when they started writing rather than to whatever is selected when they submit. This Editor keeps its selection when a control elsewhere on the page takes focus, so capture is not a workaround for losing it. Capture is how you freeze the intended target: once a draft is open, changing the selection in the document does not move the pending comment. 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. A capture stays usable for as long as its target still resolves. `capturedAt` records when the selection was frozen so your interface can show it; it is not an expiry, and elapsed time alone never invalidates a capture. Submitting with nothing captured fails the same way as submitting with an empty selection: both return a `NO_SELECTION` receipt before any document mutation runs. ## Let an author correct a comment [#let-an-author-correct-a-comment] `edit()` replaces a comment's body text: ```ts const receipt = await ui.comments.edit(commentId, { text: 'Please verify this clause against schedule B.', }); if (!receipt.success) console.error(receipt.failure.message); ``` Editing is a separate permission from resolving. A document configured with `allowResolve: false` still permits body edits, because that setting governs the resolve and reopen transition rather than authorship. A read-only comments configuration refuses both. ## Build the panel in React [#build-the-panel-in-react] The example above is deliberately small. A real panel has threads, replies, an edit affordance, a delete confirmation your product owns, and somewhere sensible for focus to land after each of those. This React version implements the complete lifecycle against the same controller: ```tsx import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { SuperDoc } from 'superdoc'; import type { UIConfig } from 'superdoc'; import { SuperDocUIProvider, useSetSuperDoc, useSuperDocComments, useSuperDocDocument, useSuperDocSelection, useSuperDocUI, } from 'superdoc/ui/react'; import type { CommentInfo, SelectionCapture, WorkflowReceipt } from 'superdoc/ui'; import 'superdoc/style.css'; // The one surface this application replaces. Everything else — toolbar, context // menu, search, the document canvas — stays built in. Declared at module scope // because a new object on every render would rebuild the Editor. const EDITOR_UI = { comments: false, } satisfies UIConfig; const CURRENT_USER = { name: 'Alex Rivera', email: 'alex@example.com' }; export default function App() { return (
); } function Editor() { const mountRef = useRef(null); const setSuperDoc = useSetSuperDoc(); useEffect(() => { if (!mountRef.current) return; // `onReady` is asynchronous, so a mount that is torn down before the // document finishes loading can still fire it. Under StrictMode, React // does exactly that on every dev mount. Binding then would publish a // destroyed instance and leave the panel reading dead state. let destroyed = false; const superdoc = new SuperDoc({ selector: mountRef.current, document: '/contract.docx', user: CURRENT_USER, ui: EDITOR_UI, onReady: ({ superdoc: ready }) => { if (!destroyed) setSuperDoc(ready); }, onException: ({ error }) => console.error('SuperDoc could not open the document.', error), }); // The component owns the Editor, so the component destroys it. That also // disposes the controller the provider is publishing. return () => { destroyed = true; superdoc.destroy(); }; }, [setSuperDoc]); return
; } function CommentsPanel() { const ui = useSuperDocUI(); const comments = useSuperDocComments(); const selection = useSuperDocSelection(); const { mode } = useSuperDocDocument(); const [status, setStatus] = useState(''); const composerRef = useRef(null); // Viewing mode is the one refusal this panel can anticipate: it is on the // public document slice. The comments `readOnly` and `allowResolve` // interaction policies are NOT publicly observable, so controls they forbid // stay visible and surface their refusal through the receipt instead. That is // a presentation limit, not a safety one — the controller refuses those // mutations either way. const readOnly = mode === 'viewing'; // Announce through a live region rather than an alert, so a failure reaches a // screen reader without stealing focus from the composer the user is in. const announce = useCallback((message: string) => setStatus(message), []); const report = useCallback( (receipt: Awaited, success: string) => { if (!receipt.success) { announce(receipt.failure.message); return false; } announce(success); return true; }, [announce], ); const threads = useMemo(() => toThreads(comments.items), [comments.items]); if (!ui) { return ( ); } return ( ); } type UIHandle = NonNullable>; type Report = (receipt: Awaited, success: string) => boolean; type Announce = (message: string) => void; function NewCommentComposer({ announce, composerRef, report, selectionIsEmpty, ui, }: { announce: Announce; composerRef: React.RefObject; report: Report; selectionIsEmpty: boolean; ui: UIHandle; }) { const [text, setText] = useState(''); const [capture, setCapture] = useState(null); const [pending, setPending] = useState(false); const fieldId = useId(); // Capture on the press, before focus moves anywhere. `mousedown` fires // before the browser moves focus to the button; `click` fires after. Reading // the selection at submit time instead would tie the comment to whatever is // selected then, which is not what the user was looking at when they started // writing. const captureNow = useCallback(() => { const frozen = ui.selection.capture(); if (frozen) setCapture(frozen); }, [ui]); const startComment = useCallback(() => { // Keyboard activation never fires mousedown, so the click handler is the // only hook the keyboard path has. That works because this controller keeps // its selection when a control takes focus — it is NOT the same guarantee // as capturing on the press. A control that cleared the selection on focus // would need a keydown handler instead. captureNow(); composerRef.current?.focus(); }, [captureNow, composerRef]); const submit = useCallback(async () => { if (!capture || pending) return; setPending(true); try { // The capture is the target, not the live selection. A selection change // made while this composer was open does not retarget the draft. const created = report(await ui.comments.createFromCapture(capture, { text }), 'Comment added.'); if (!created) return; setText(''); setCapture(null); } finally { setPending(false); } }, [capture, pending, report, text, ui]); const cancel = useCallback(() => { setText(''); setCapture(null); announce('Draft discarded.'); }, [announce]); return (
{ event.preventDefault(); void submit(); }} >

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`. Content-control hits can also carry their `tag` and `scope`. The hits are tracked changes, comments, content controls, and citations. 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. For citation cards, attach the listener to the public host and match the hit against the citation list. The hit id is the same id as the matching list item. Treat it as opaque and compare it only for equality: ```ts const host = ui.viewport.getHost(); const onClick = async (event: MouseEvent) => { const hit = ui.viewport.entityAt({ x: event.clientX, y: event.clientY }).find((entity) => entity.type === 'citation'); if (!hit) return; const citations = await Promise.resolve(superdoc.activeEditor?.doc.citations.list()); const citation = citations?.items.find((item) => item.id === hit.id); if (citation) openCitationCard(citation); }; host?.addEventListener('click', onClick); // When this custom UI unmounts: host?.removeEventListener('click', onClick); ``` Do not recover citation identity with `event.target.closest(...)`; the painter DOM is not a public API. Anchored metadata uses a hidden content control rather than its own hit type, so its record ID arrives as the content-control hit's `tag`. A record with that ID existing does not prove the pointer hit its anchor: an ordinary control can carry a colliding tag. Compare the hit control's `selectionTarget` with `doc.metadata.resolve({ id: hit.tag })` before treating the hit as application metadata. That comparison narrows the risk without settling it. Content-control hits carry no story, both lookups resolve against the main document part, and painted ids are unique only within that part, so a control in a header, footer, note, or textbox that reuses a body anchor's id and tag passes every check. For documents your own application produced the collision is unlikely; for externally authored files, treat a match as unverified and confirm through your own records. See [Store application data in DOCX](/document-api/application-data). A point over ordinary text carries no entities, 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 no `story`, 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. If SuperDoc should render the review surfaces instead, see [tracked changes](/editor/review/tracked-changes). 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). --- # Mount SuperDoc in React > Open, edit, and export a DOCX from a React component with SuperDoc v2. Use the stable `superdoc` package in React. The component creates one Editor after its mount element exists, waits for the document to open, and destroys the Editor when React unmounts it. ## 1. Create a React project [#1-create-a-react-project] ```bash pnpm create vite@latest superdoc-react --template react-ts cd superdoc-react pnpm add superdoc ``` ## 2. Prepare a document [#2-prepare-a-document] Download the sample DOCX and save it as `public/sample.docx`: [Download the sample document](/fixtures/sample-nda.docx): Synthetic agreement with headings, paragraphs, and a list · DOCX Vite serves files in `public` from the root of the development server, so SuperDoc can load it from `/sample.docx`. ## 3. Create the Editor component [#3-create-the-editor-component] Replace `src/App.tsx` with this component: ```tsx import { useEffect, useRef, useState } from 'react'; import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; export default function App() { const mountRef = useRef(null); const superdocRef = useRef(null); const exportingRef = useRef(false); const [ready, setReady] = useState(false); const [exporting, setExporting] = useState(false); useEffect(() => { if (!mountRef.current) return; let active = true; let opened = false; let loadFailed = false; setReady(false); const superdoc = new SuperDoc({ selector: mountRef.current, document: '/sample.docx', onReady: () => { if (!active || loadFailed) return; opened = true; setReady(true); }, onException: ({ error }) => { if (!opened) { loadFailed = true; if (active) setReady(false); } console.error('SuperDoc could not open the document.', error); }, }); superdocRef.current = superdoc; return () => { active = false; if (superdocRef.current === superdoc) superdocRef.current = null; superdoc.destroy(); }; }, []); async function exportDocument() { if (exportingRef.current) return; exportingRef.current = true; setExporting(true); try { await superdocRef.current?.export({ exportType: ['docx'], exportedName: 'sample-edited' }); } catch (error) { console.error('SuperDoc could not export the document.', error); } finally { exportingRef.current = false; if (superdocRef.current) setExporting(false); } } return (
); } ``` The complete project is available at [go.superdoc.dev/examples/react](https://go.superdoc.dev/examples/react). Remove the starter `App.css` import and replace the contents of `src/index.css` with: ```css html, body, #root { min-height: 100%; margin: 0; } ``` The mount effect runs once. `onReady` enables export only after the DOCX is open. The cleanup calls `destroy()` to release Editor resources when React removes the component. ## 4. Edit and export [#4-edit-and-export] Run `pnpm dev` and open the printed URL. Replace a word in the document, then select **Export DOCX**. The browser should download `sample-edited.docx`. > **Verification target (success)** > > Reopen the exported file in Word or SuperDoc. Your edit should be present, and the document formatting should be > unchanged. ## Add React-owned controls [#add-react-owned-controls] The component above uses SuperDoc's built-in interface. When React should render your toolbar or panels, use the provider and hooks from the `superdoc/ui/react` subpath. They bind to the same Editor instance and do not require another package. Continue with [React custom UI setup](/editor/custom-ui/react-setup), or learn how to [load and save user documents](/editor/load-and-save-documents). --- # 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 the current `latest` release: ```bash pnpm add superdoc ``` 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. ### Migrate a custom toolbar [#migrate-a-custom-toolbar] A v1 custom toolbar is not restored by changing only its import. Bind the application UI to the controller owned by the ready `SuperDoc` instance, then move each control's state and execution to a public command handle. | V1 integration responsibility | V2 path | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Import React headless-toolbar bindings | Import `SuperDocUIProvider`, `useSetSuperDoc`, and hooks from `superdoc/ui/react` | | Bind controls to an editor or toolbar scope | Call `useSetSuperDoc()` with the `SuperDoc` instance received by `onReady` | | Read whether a control is active or disabled | Read `active`, `enabled`, `value`, and `reason` from `useSuperDocCommand(id)` | | Run a toolbar action | Await `superdoc.ui.commands.executeAsync(id, payload)` and inspect `false` or an unsuccessful receipt | | Replace the document in the mounted editor | Call `superdoc.replaceFile(file)` and keep the existing controller binding | | Replace the entire `SuperDoc` instance | Bind the new instance from its `onReady` and destroy the instances your application created | Do not create a controller for every render, tab, or document. One `SuperDoc` instance owns one stable controller. Replacing its document resets document-scoped state while existing command hooks stay subscribed. Replacing the entire instance is different: the provider must receive the new ready instance. Follow the complete [React custom UI setup](/editor/custom-ui/react-setup) for a toolbar that reports command outcomes and stays live after document replacement. Use [Commands and state](/editor/custom-ui/commands-and-state) for the framework-neutral contract. ## 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 })` | | Paragraph block ID | `superdoc.ui.viewport.scrollIntoView({ target: { ... } })` | The first three return structured results and keep feature state, such as the active tracked change, in the same controller. A paragraph block ID has no feature handle, so wrap it in a `TextAddress` and scroll the viewport directly: ```ts import type { SuperDoc } from 'superdoc'; import type { TextAddress } from 'superdoc/ui'; // Take the story from `TextAddress` itself. The root `superdoc` package also // exports a `StoryLocator`, but that is the legacy `string | Record` alias and is not assignable to the Document API locator this // address requires. async function revealBlock( superdoc: SuperDoc, blockId: string, maxSteps: number, story?: TextAddress['story'], ): Promise { // Carry the paragraph's story. Omitting it defaults the address to the body, // so a header, footer, or note paragraph never resolves however many times // you retry. const target: TextAddress = { kind: 'text', blockId, range: { start: 0, end: 0 }, ...(story ? { story } : {}) }; // A block beyond the retained frontier of a long virtualized document needs // more than one pass: the host performs one bounded reveal step per call and // returns `success: false` while the target is still out of reach. Retry // against a step budget rather than treating the first result as final. // // `maxSteps` is yours to choose and to bound: a stale or unreachable ID // fails on every pass, so an unbounded loop never terminates. Measure it // against your own longest document rather than copying a number. for (let step = 0; step < maxSteps; step += 1) { const result = await superdoc.ui.viewport.scrollIntoView({ target, block: 'center' }); if (result.success) return true; } return false; } ``` `scrollIntoView` also accepts a `TextTarget` for a multi-segment range, or an `EntityAddress` when you would rather address a comment or tracked change by ID than go through its feature handle. The feature-specific methods delegate to the same one-step host call, so a deep target reached through `ui.trackChanges.scrollTo()`, `ui.comments.scrollTo()`, or `ui.contentControls.scrollIntoView()` needs the same bounded retry. Apply the pattern above to whichever call you use, and keep the step budget: a stale or unreachable ID returns `success: false` forever, so a loop with no ceiling never terminates. > **Only paragraph IDs work with this recipe (warning)** > > `TextAddress` is a single-block **text** range, and the host resolves an off-screen `blockId` through its paragraph > index. A structural block such as a table, image, TOC, or content control has no entry there, so wrapping its ID this > way resolves nothing. Reach those through the surface that owns them, such as > `superdoc.ui.contentControls.scrollIntoView({id})`, or navigate to a paragraph inside them. > **Root navigation is not a working replacement yet (warning)** > > `superdoc.scrollToElement()` and `superdoc.navigateTo()` read the renderer runtime slot directly, and nothing on the > V2 path populates it, so both return `false` for every target in current V2 packages. Use the method for your target > type above instead. If you hold a stable extracted ID and do not know its type, resolve the type through the Document > API first, then choose from the table. 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. ### Content-control clicks [#content-control-clicks] V2 keeps the `onContentControlClick` configuration callback. The equivalent instance event is `content-control:click`: ```ts const superdoc = new SuperDoc({ selector: '#editor', document: file, onContentControlClick: ({ target, source }) => { console.log(target.id, target.scope, source); }, }); const handleClick = ({ target, source }) => { console.log(target.id, target.controlType, source); }; superdoc.on('content-control:click', handleClick); ``` Each user click emits once for the innermost clicked content control. `target` contains `id`, `controlType`, `scope: 'inline' | 'block'`, and the optional `tag` and `alias`; `source` is `'pointer'`. The listener remains active after document replacement and collaboration updates. Programmatic `focus()` and selection changes do not emit this event. Use `superdoc.ui.contentControls` — for example `observe()` and `activeIds` — when the integration needs focus or selection state instead. See [Custom content controls](/editor/custom-ui/content-controls). ### 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. #### Split-origin worker assets [#split-origin-worker-assets] If your JavaScript bundle is served from a different origin than the application, copy the three emitted v2 worker assets to the application's origin and pass their URLs explicitly: ```ts new SuperDoc({ selector: '#editor', document, workerUrls: { document: '/superdoc-workers/document.js', collaboration: '/superdoc-workers/collaboration.js', reviewIndex: '/superdoc-workers/review-index.js', }, }); ``` Each URL must be same-origin with the page and serve a module worker. Omit this option when SuperDoc and the application share an origin; the bundled worker URLs remain the default. ## 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.6.0` 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` | `superdoc.ui.viewport.contextAt` | Redesign | The component is gone, not the workflow. Add actions to the built-in menu with `ui.contextMenu.customItems`. For a fully application-owned menu, set `ui: { contextMenu: false }`, listen on the Editor host, and resolve entities and selection with `superdoc.ui.viewport.contextAt({ x, y })`. This does not restore arbitrary ProseMirror positions. [Read more](/editor/custom-ui/context-menus) | | `SlashMenu` | `config.ui.contextMenu.customItems` | Redesign | The v2 built-in surface combines right-click and slash actions under `ui.contextMenu`. Add application sections with `customItems`; use the application-owned context-menu path when your product replaces the complete surface. [Read more](/editor/custom-ui/context-menus) | | `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/review-highlights) | | `defineNode` | *None* | No equivalent | v2 has no custom-schema surface. Custom document nodes cannot be reproduced. | | `defineMark` | *None* | No equivalent | v2 has no custom-schema surface. Custom marks cannot be reproduced. | | `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` | 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 })`. For a paragraph block id, wrap it in a `TextAddress` and call `ui.viewport.scrollIntoView({ target })`, carrying the paragraph `story` when it is not in the body: an omitted story defaults to body and the paragraph never resolves. That form is a single-block text range resolved through the paragraph index, so a table, image, or content-control id needs the surface that owns it instead. Every one of these reveals one bounded step per call, so retry while `success` is false against a fixed step budget: a stale or unreachable id fails forever, and an unbounded loop never terminates. Do not substitute `superdoc.scrollToElement()` or `superdoc.navigateTo()`: both read the renderer runtime slot that nothing on the V2 path populates, so they return `false` for every target in current V2 packages. [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, and citations), 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/review-highlights) | | `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. The inserted field has a visible cached result such as `[Parity Handbook]`, and changing `sourceIds` with `citations.update` recomputes it. Not a rename, and not an atomic replacement: `citations.insert` accepts only a collapsed target. To replace selected text, derive its start as the insertion caret, delete the selected range, and only then pass the resulting collapsed caret to `citations.insert`. The returned `CitationAddress.anchor` spans the complete visible result in flattened paragraph offsets, while `CitationAddress.story` identifies the body, header/footer, note, or textbox containing it. 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 (application review marks)` | `doc.metadata + ctx.visuals.highlight` | Redesign | A null-property error on `state`, or `undefined` under optional chaining. | `editor.state.doc.descendants`. Split the old mark into two responsibilities. Store caller-owned identity and JSON payload with anchored metadata, resolve its current target with `metadata.resolve({ id })`, and paint that target through an extension visual. Metadata persists but renders nothing; visuals render but never enter the DOCX. [Read more](/editor/custom-ui/review-highlights) | | `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.citations.insert + ctx.visuals.inlineBox` | Redesign | A null-property error reaching the transaction. | Insert and update citations through `doc.citations`; v2 writes and recomputes the visible cached field result from the source tags. For application-owned presentation, convert the returned address to `{ kind: "text", blockId: address.anchor.start.blockId, range: { start: address.anchor.start.offset, end: address.anchor.end.offset }, story: address.story }`. Create a visual handle with `const citationPill = ctx.visuals.inlineBox("citations", { layout: { paddingInline: 5, paddingBlock: 2, borderWidth: 1 }, appearance: { borderColor: "#8aa8d8", borderRadius: 8 } })`, then pass the target to `citationPill.replace([target])`. The offsets already span the complete citation result, and carrying `story` ensures a citation outside the body resolves and paints in its owning header, footer, note, or textbox. Local typing rebases the target. Remote edits fail closed until the extension re-queries. Presentation is render-only and never changes the DOCX. [Read more](/document-api/reference/citations/insert) | | `.superdoc-text-run[data-pm-start] (citation pill styling)` | `ctx.visuals.inlineBox` | 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. Replace a padded pill with `ctx.visuals.inlineBox(id, options)`. Its integer-pixel padding, gaps, and border participate in wrapping and pagination; `className` remains paint-only. A `CitationAddress` is not itself a visual target, but converting one is mechanical: `{ kind: "text", blockId: citation.address.anchor.start.blockId, range: { start: citation.address.anchor.start.offset, end: citation.address.anchor.end.offset }, story: citation.address.story }`. Carry `CitationAddress.story` so repeated block ids resolve in the owning body, header/footer, note, or textbox. Wrapped ranges clone box edges on each line. Overlaps, RTL targets, header/footer slot locators, and tracked-coordinate targets fail closed. Local typing, undo, and redo rebase the anchor; remote edits may briefly remove the pill until the extension re-queries. Deleting the whole range drops the box. The presentation is render-only, adds repaint and measurement cost when targets change, and never reaches the DOCX. | | `superdoc.on('fieldAnnotationClicked')` | `superdoc.ui.viewport.entityAt` | Redesign | The subscription is accepted and the handler never fires. | v2 emits no generic field-annotation click event, and the replacement depends on what the annotation became. For citations, register a listener on `superdoc.ui.viewport.getHost()`, call `viewport.entityAt({ x, y })`, and select the `{ type: "citation", id }` hit; that `id` matches a `doc.citations.list().items` row. For anchored metadata, read the record id from the content-control hit's `tag` and compare the hit control's `selectionTarget` with `doc.metadata.resolve({ id: tag }).target`. That comparison narrows the risk but does not confirm the hit: content-control hits carry no story, both lookups resolve against the body, and painted ids are unique only within the main document part, so a header, footer, note, or textbox control reusing a body anchor's id and tag passes every check. Treat a match as unverified for externally authored files. Remove the listener when the custom UI unmounts. Decide the document model before choosing the interaction path. [Read more](/editor/custom-ui/selection-and-viewport) | | `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. --- # 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] ```html
``` ```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 } }, ui: { // `container` is what makes the toolbar render; the other keys only // describe it once it has somewhere to go. toolbar: { container: '#toolbar', responsiveToContainer: true }, comments: { displayMode: 'auto' }, search: 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. * `ui` decides which built-in interface SuperDoc renders, one surface at a time. * `interaction` decides what a user is allowed to do, which outlives the built-in UI: a custom comments panel still has to honor `readOnly`. * `surfaces` configures the shared dialog and floating infrastructure both built-in and application-owned UI mount into. * `modules` configures integration subsystems such as collaboration, tracked changes, and AI. * `zoom`, `contained`, fonts, proofing, and layout options control host behavior. * `onReady`, the `onContentError` and `onException` callbacks, and events such as `document-mode-change` connect the Editor to your application's 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. Most applications should keep the default 30-second worker startup limit. If your worker bundle needs longer to download or evaluate, set `workerStartupTimeoutMs` to a larger value in milliseconds. Rendering and permission are separate decisions, which is why `ui` and `interaction` are separate groups. Turning off a built-in surface says nothing about what the application's own replacement may do, and `ui: false` never disables editing, the Document API, `interaction`, `surfaces`, or `superdoc.ui`. Add only the configuration your application uses; every omitted key keeps its default. 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 `surfaces` only for shared defaults and intent resolution. Direct `openSurface()` calls do not require a resolver. The built-in find/replace surface has its own switch, `ui: { search: true }`, because it is chrome rather than infrastructure. --- # 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. The [version history example](https://go.superdoc.dev/examples/version-history) keeps exported snapshots in browser memory and restores them with `replaceFile()`. Replace its in-memory array with your application's storage when versions must survive a reload. 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 spelling or grammar provider to SuperDoc. Proofing helps people catch spelling and grammar mistakes while they write, before those mistakes reach a reader. Word processors usually underline an issue so the writer can replace or ignore it without leaving the document. > **Interactive editor: Try proofing** > > Preset: `proofing`. > Proofing: type `mispelled`, `workng`, or `teh`, then right-click the underline. > Local DOCX selection: disabled. ## Enable proofing [#enable-proofing] SuperDoc schedules checks, underlines issues, and handles Ignore and replacements. It does not include a dictionary or grammar checker, so you provide one: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', proofing: { enabled: true, provider: { id: 'local-example', 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'] }]; }), }), }, }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` This provider flags only `teh`. For a complete local dictionary, see the [proofing example](https://go.superdoc.dev/examples/proofing). SuperDoc sends text segments to the provider after edits. Return spelling, grammar, or style issues with zero-based UTF-16 offsets. Honor the request's `signal` so SuperDoc can cancel stale or timed-out checks. ## Configure proofing [#configure-proofing] Start with **Setup**, then open the other groups only when you need them. Proofing runs only when both `enabled: true` and `provider` are present. | Field | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | `false` | Enables proofing. A provider is also required before SuperDoc runs checks. | | `provider` | `{ id: string; getCapabilities?: () => ProofingCapabilities \| Promise; check: (request: ProofingCheckRequest) => Promise; dispose?: () => void \| Promise; } \| null` | `null` | Checks the text segments SuperDoc supplies and returns spelling, grammar, or style issues. | | `defaultLanguage` | `string \| null` | `null` | Fallback language passed to the provider when a text segment has no resolved language. | | `debounceMs` | `number` | `500` | Delay in milliseconds between an edit and the next proofing check. Values at or below 0 run without a delay. | | `maxSuggestions` | `number` | — | Suggestion limit passed to the provider. The provider decides how to apply it. | | `visibleFirst` | `boolean` | — | Prioritize checking visible pages first. | | `allowIgnoreWord` | `boolean` | `true` | Shows Ignore in the proofing context menu. Ignored words remain suppressed for this editor session. | | `ignoredWords` | `string[]` | `[]` | Words whose proofing issues SuperDoc suppresses. Matching is case-insensitive after Unicode normalization. | | `timeoutMs` | `number` | `10000` | Maximum provider call time in milliseconds. Non-positive or non-finite values use the default. | | `maxConcurrentRequests` | `number` | — | Maximum concurrent provider requests. | | `maxSegmentsPerBatch` | `number` | — | Maximum segments per provider call. | | `onProofingError` | `(error: { kind: "provider-error" \| "validation-error" \| "timeout"; message: string; segmentIds?: string[]; cause?: unknown; }) => void` | — | Runs when a provider check fails or times out. | | `onStatusChange` | `(status: ProofingStatus) => void` | — | Runs when the proofing lifecycle status changes. | Options under **Reserved** are present in the TypeScript type but do not affect the current runtime. ## Protect document text [#protect-document-text] If the provider uses a network, document text leaves the browser. Obtain user consent, send only the required segments over authenticated encrypted transport, define how the service retains and deletes the text, and never include document text in URLs or logs. --- # 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. --- # 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 displays each proposal inline in the document 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. Anything you type in suggesting mode becomes a new proposal rather than a direct edit. ## 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. Accepting a change keeps the proposed result and removes the review mark. Rejecting it 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 as if no proposals had been made | `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). --- # Choose your editor interface > Decide how much of the editor interface SuperDoc renders and how much your application owns. Every Editor integration uses the same DOCX engine, document lifecycle, and public Document API. The interface decision determines who renders the controls around the document. > **Diagram:** The built-in UI and a custom application UI both control the same SuperDoc editor and Document API. ## Three ownership modes [#three-ownership-modes] `Config.ui` is the single dial. Which mode you are in is a consequence of what you pass it, not a separate architecture to commit to. | Approach | Configuration | Who renders the chrome | | ------------ | ----------------------------------- | ---------------------------------------------------- | | Built-in | Name a toolbar mount, omit the rest | SuperDoc renders its default surfaces | | Hybrid | Configure selected surfaces | SuperDoc and your application split it | | Fully custom | `ui: false` | Your application renders all of it via `superdoc.ui` | Start with the built-in UI unless a product requirement clearly needs custom controls. It is the shortest path to a complete, accessible editing workflow, and it shows the team which interactions actually need customizing before anything is rebuilt. Moving between modes is a configuration change. Nothing about how the document is opened, represented, or saved changes with it. All three examples below load `/contract.docx`. Copy the [tracked-changes fixture](/fixtures/tracked-changes.docx) to your app's public directory under that name, or update the document URL. ## Built-in [#built-in] Surfaces you say nothing about keep their historical defaults. The toolbar, comments, context menu, link popover, content controls, and the loading overlay render; search and the ruler are opt-in, so add `ui: { search: true }` or `ui: { ruler: true }` when you want them. The built-in toolbar keeps the Search button visible, but disables it until you opt in. The one thing this mode does need is a mount target for the toolbar. SuperDoc cannot guess where in your layout that belongs, so a toolbar with nowhere to go does not render. ```html
``` ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; // Built-in: SuperDoc renders the chrome. // // Surfaces you say nothing about keep their historical defaults. The toolbar is // the one that needs a mount target, because SuperDoc cannot guess where in // your layout it belongs. const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', ui: { toolbar: { container: '#toolbar' }, // The default toolbar renders a Search button regardless, and it opens // the shared find/replace surface. Leaving `search` off gives a control // that is visible, enabled, and does nothing when clicked. search: true, }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` Choose this for document editors, review screens, and internal workflows where the standard experience already fits. You still control document mode, the current user, integrations, theme, file lifecycle, and export. [See the built-in UI overview](/editor/built-in-ui/overview). ## Hybrid [#hybrid] Most production integrations land here. Replace the one surface your product needs to own and keep the rest. ```html
``` ```ts import { SuperDoc } from 'superdoc'; import type { BorrowedSuperDocUI, CommentsSlice, SelectionCapture, SelectionSlice } from 'superdoc/ui'; import 'superdoc/style.css'; const panelElement = document.querySelector('#comments-panel'); const composerText = document.querySelector('#comment-text'); const composerSubmit = document.querySelector('#add-comment'); if (!panelElement || !composerText || !composerSubmit) { throw new Error('The comments panel is missing.'); } const panel = panelElement; const commentText = composerText; const addComment = composerSubmit; // The capture taken while text was selected. Creating a comment needs a // document address rather than a live DOM range, so it is taken when the // selection exists and used later when the composer is submitted. let capturedSelection: SelectionCapture | null = null; /** * The application's own comments surface, in place of the built-in one. * * Clicking a row focuses that comment and scrolls the document to it, which * is the behavior the built-in panel provided before `ui.comments: false` * turned it off. * * The observer fires on every change, including the one a click here causes, * so the rows are rebuilt underneath the button the user just pressed. Each * row carries its comment id and focus is restored afterwards; without that, * keyboard and screen-reader users are returned to the top of the document * after every activation. */ function renderCommentPanel(ui: BorrowedSuperDocUI, slice: CommentsSlice) { const focusedId = document.activeElement instanceof HTMLElement && panel.contains(document.activeElement) ? document.activeElement.dataset.commentId : undefined; panel.replaceChildren( ...slice.items.map((comment) => { const row = document.createElement('button'); row.type = 'button'; row.dataset.commentId = comment.id; // A comment can carry no text. Falling back to an empty string would // leave a focusable control that assistive technology cannot name. row.textContent = comment.text || 'Comment without text'; row.addEventListener('click', () => { ui.comments.setActive(comment.id); void ui.comments.scrollTo(comment.id); }); return row; }), ); if (focusedId) { panel.querySelector(`[data-comment-id="${CSS.escape(focusedId)}"]`)?.focus(); } } // Hybrid: SuperDoc and the application split the chrome. // // Three keys are named here and every other surface keeps its default. The // application renders its own comments panel, so the built-in one is turned // off; the toolbar is given a mount target; and search is opted into, because // it is off by default and the toolbar's Search button needs it. `ui` keys are // independent, so naming these three says nothing about the rest. const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', ui: { toolbar: { container: '#toolbar' }, comments: false, // The default toolbar's Search button opens this surface. Without it the // control still renders and clicking it does nothing. search: true, }, // Rendering and permission are separate decisions. Turning off the built-in // comments interface does not stop this panel from writing, so the policy is // stated rather than inferred from `ui`. `readOnly: false` is the default; // it is written out because the point of the pair is that `ui` never // decides it. `allowResolve: false` permits replies while forbidding resolve. // // `readOnly: true` is deliberately not the example here: it refuses tracked // change accept and reject as well as comment writes, so it takes the review // workflow with it. Reach for it when the whole surface should not mutate // the document, not to make one panel non-writing. interaction: { comments: { readOnly: false, allowResolve: true } }, onReady: ({ superdoc: readySuperDoc }) => { // The controller stays available for the surfaces the application owns. const ui = readySuperDoc.ui; // Capture while the selection exists; the composer is submitted later, // after focus has moved to the textarea and the selection is gone. // // Submit needs both halves: something to attach the comment to, and // something to say. Gating on the capture alone leaves an enabled button // whose click returns silently, which reads as a broken control. const syncComposer = () => { addComment.disabled = !capturedSelection || commentText.value.trim().length === 0; }; const trackSelection = (selection: SelectionSlice) => { if (!selection.empty) capturedSelection = ui.selection.capture(); syncComposer(); }; commentText.addEventListener('input', syncComposer); addComment.addEventListener('click', async () => { if (!capturedSelection || !commentText.value.trim()) return; const receipt = await ui.comments.createFromCapture(capturedSelection, { text: commentText.value.trim() }); if (!receipt.success) return; commentText.value = ''; capturedSelection = null; syncComposer(); }); trackSelection(ui.selection.getSnapshot()); ui.selection.observe(trackSelection); renderCommentPanel(ui, ui.comments.getSnapshot()); ui.comments.observe((slice) => renderCommentPanel(ui, slice)); }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` The panel starts empty against the fixture above, which carries no comments. Select text and add one through your own composer, or open a DOCX that already has threads, to see it populate. [Build a custom comments UI](/editor/custom-ui/comments) covers creation, replies, and resolution on the same handle. Each `ui` key is independent, so a partial object is additive: naming `comments` says nothing about the toolbar. Turn off only the surfaces you have actually rebuilt, because a surface you disable without replacing leaves the reader with less than SuperDoc would have given them. This is also where the difference between rendering and permission matters most. `ui` decides what SuperDoc draws; `interaction` decides what a person may do. Your own comments panel is not bound by the built-in one's absence, so state the policy rather than inferring it. The loading overlay is the one surface where turning it off transfers a responsibility rather than just removing pixels. `ui: { loading: false }` stops SuperDoc drawing the overlay it shows while a document opens. The overlay also masks the document underneath while it renders, so without it your UI owns that window. Keep your own loading state up until `onReady`, and around a replacement await `superdoc.replaceFile(...)` rather than assuming it returns instantly. A replacement reopens the document, so the built-in overlay returns for it unless you have turned it off. In React, `renderLoading` and `ui.loading` are independent. `renderLoading` is your loading UI and SuperDoc hides it once the instance reports ready; `ui.loading` controls SuperDoc's own. Pass `ui={{ loading: false }}` alongside `renderLoading` so the two do not appear one after the other. The two comment policies answer different questions. `readOnly` refuses every write, including create, reply, and delete. `allowResolve` refuses only the resolve and reopen transition, leaving replies alone. Both default to permissive, so a panel that should not write needs `readOnly: true` said out loud. `readOnly` reaches further than its name suggests: it also refuses tracked-change accept and reject. Their command state reports `reason: 'document-readonly'` while it is set, and direct `ui.trackChanges.accept()` or `reject()` calls return `false`. Reviewers keep reading and commenting rules but lose the ability to decide changes, so reserve it for surfaces that should not mutate the document at all rather than using it to make one comment panel non-writing. ## Fully custom [#fully-custom] `ui: false` turns off every built-in surface at once. ```html
``` ```ts import { SuperDoc } from 'superdoc'; import type { CommandState } from 'superdoc/ui'; import 'superdoc/style.css'; const boldButton = document.querySelector('#bold')!; // Fully custom: the application renders every control. // // `ui: false` turns off all built-in chrome at once. It removes presentation // only: editing, the Document API, `interaction`, `surfaces`, and // `superdoc.ui` all keep working, which is what makes this viable without // giving up the editor underneath. const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', ui: false, // Still enforced with no built-in UI to enforce it in. Policy is not a // property of the chrome that happens to render it. interaction: { comments: { readOnly: true } }, onReady: ({ superdoc: readySuperDoc }) => { const bold = readySuperDoc.ui.commands.get('bold'); const render = (state: CommandState) => { boldButton.disabled = !state.enabled; // A toggle's pressed state has to reach assistive technology, not only // a class name. `aria-pressed` announces it and drives the styling in // the markup beside this file, so there is one source of truth. boldButton.setAttribute('aria-pressed', String(state.active)); }; render(bold.getState()); bold.observe(render); boldButton.addEventListener('click', () => bold.execute()); }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` It removes presentation and nothing else. Editing still works, the Document API still works, `interaction` and `surfaces` still apply, and `superdoc.ui` still reports state and runs commands. That invariant is what makes a fully custom interface possible without giving up the editor underneath it. Choose this when the surrounding experience is part of the product's differentiation: a focused contract approval screen, a form-like document workflow, or controls that must match an established design system. `superdoc/ui` exposes a framework-neutral controller and `superdoc/ui/react` adds React providers and hooks over it. Both 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 mode 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()`. > **Do not rebuild the document canvas (note)** > > A custom UI means you build the controls and workflow around SuperDoc. The Editor still owns DOCX rendering, layout, > selection, and editing behavior. [Configure the Editor](/editor/platform/configuration) explains how `ui`, `interaction`, and `surfaces` divide the configuration between them.