# Overview > Understand how an agent uses SuperDoc to edit a DOCX, then choose a starting point. An agent can use SuperDoc to read a Word document and make changes from an instruction. For example, you can ask it to replace a term in a contract and save the result as a new DOCX. The model decides what to do. SuperDoc provides the tools that read and change the document. You can connect those tools to a coding agent you already use, or add them to an agent in your own application. ## How an agent edits a document [#how-an-agent-edits-a-document] A tool is an operation the agent can request, such as opening a file or replacing text. The agent sends arguments to the tool and receives a result it can use to decide what to do next. > **Diagram:** The agent sends an instruction and tool results to a model provider over the network. The model chooses a tool call. SuperDoc tools run in a local or server process, read and change an open document, and save a DOCX when asked. For the contract example: 1. You ask the agent to replace a term and save a copy. 2. The agent uses SuperDoc tools to open the file and find the text. 3. The model chooses an edit. SuperDoc applies it and reports the result. 4. The agent saves the edited DOCX. You open the copy and check the change. A successful tool call means the operation ran. You still need to check that the edit matches your request. ## Choose where to start [#choose-where-to-start] ### Use a coding agent you already have [#use-a-coding-agent-you-already-have] Connect SuperDoc to Claude Code, Claude Desktop, Cursor, or Windsurf through MCP, a protocol that lets an agent call external tools. The client starts the SuperDoc MCP server on your machine and passes tool calls to it. Start with [Connect a coding agent](/agents/mcp/connect), then make one edit to a sample document. You do not need to write an agent loop or choose a model provider in SuperDoc. ### Build an agent into your application [#build-an-agent-into-your-application] Use `@superdoc/sdk` for Node.js or `superdoc-sdk` for Python. Your application opens the document, sends requests to your model provider, runs the tools it selects, and decides when to save. [Build an agent](/agents/build/build-an-agent) introduces the toolkit and a document-editing loop. This path gives you control over the model, instructions, and application workflow. If your code already knows the edit to make, you can use the [Node.js SDK](/agents/automation/node-sdk), [Python SDK](/agents/automation/python-sdk), or [CLI](/agents/automation/cli) directly without a model. ## Where the code runs [#where-the-code-runs] The MCP server and these SDKs run in a local or server process. They edit the document without mounting the Editor UI. Your agent or application sends the model its instructions and tool results, which can contain document text. The SuperDoc Editor runs in the browser. Use it when a person needs to see or review the document. In a web application, keep the Node.js SDK on your server and call it through your application endpoints; use the Editor in the browser. Do not import the browser `superdoc` package into a Node.js API route to run these workflows. For the complete engine and package map, see [How SuperDoc works](/resources/how-superdoc-works). ## Start with one document [#start-with-one-document] [Connect a coding agent over MCP](/agents/mcp/connect) to open a sample, make a tracked edit, and inspect the saved copy. If you are building your own agent, begin with [Build an agent](/agents/build/build-an-agent). --- # 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. // // Read `target`, not `selectionTarget`: `target` is always a `TextTarget` // (one segment per paragraph the selection touches), which is what lets a // selection spanning multiple paragraphs attach as one record. // `selectionTarget` collapses a multi-paragraph selection to its first/last // endpoints and cannot express that. const capture = superdoc.ui.selection.capture(); const target = capture?.status === 'ready' ? capture.target : null; // Anchoring is one or more non-empty text ranges — one segment per // paragraph — in the main document **body**. Check every part of that // before writing, rather than handling rejections: // // - Every segment must be non-empty. // - `story` is per-target: a non-body target (header, footer, footnote, // endnote, textbox) is rejected outright, since the adapter resolves // paragraphs against the main document part only. // - 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 a // table-cell or section boundary still throws, and nothing in the public // selection shape reports that ahead of time, so the catch below is the // only place that case can be handled. if (!doc || !target) return; if (target.coordinateSpace === 'tracked') return; if (target.segments.length === 0) return; if (target.segments.some((segment) => segment.range.start === segment.range.end)) return; if (target.story !== undefined && target.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 segments are not contiguous paragraphs (or cross a // table-cell/section boundary), when a segment overlaps a content control, // a hyperlink, or a simple field (`w:fldSimple`, code and cached result // alike — a complex field's cached result text, outside any wrapper, is // the one field-adjacent case that IS attachable), and when a 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)); } } ``` 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 — one per paragraph it spans, all sharing the same tag when the anchor covers multiple paragraphs. 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, TextTarget } 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'; } }; // `anchor.target` is a plain `SelectionTarget` for a single-paragraph anchor // (unchanged since v1), or a `TextTarget` for one spanning multiple // paragraphs — one control per segment, all sharing the clicked tag. A single // clicked control is always confined to one paragraph, so match its range // against ANY ONE of the anchor's segments rather than the whole anchor's // start-to-end span. const controlMatchesAnchor = (anchor: SelectionTarget | TextTarget, control: SelectionTarget) => { if (control.start.kind !== 'text' || control.end.kind !== 'text') return false; if (control.start.blockId !== control.end.blockId) return false; if (storyKey(anchor.story) !== storyKey(control.story)) return false; const controlBlockId = control.start.blockId; const controlStart = control.start.offset; const controlEnd = control.end.offset; if (anchor.kind === 'text') { return anchor.segments.some( (segment) => segment.blockId === controlBlockId && segment.range.start === controlStart && segment.range.end === controlEnd, ); } if (anchor.start.kind !== 'text' || anchor.end.kind !== 'text') return false; return ( anchor.start.blockId === controlBlockId && anchor.start.offset === controlStart && anchor.end.offset === controlEnd ); }; 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 (!controlMatchesAnchor(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. Same-tag controls are grouped by document position into the > anchor's segments, so a control carrying that alias and a colliding tag near the real anchor can be absorbed as an > extra segment (or, if it breaks the contiguous run, excluded and resolved as a separate, wrong match) — either way, > 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 requires one or more non-empty text ranges, in visible coordinates, anchored in the main document **body**. A single-paragraph target is a `SelectionTarget`, unchanged since v1. A target spanning multiple paragraphs is a `TextTarget` with one segment per paragraph: the segments must be contiguous paragraphs in document order, and none of the boundary they cross may be a table cell, a block-level content control, or a section — a target that crosses any of those is rejected. Within a paragraph, a segment may freely cross bold, italic, colour, or character-style boundaries — the anchor preserves each run's own formatting rather than requiring the whole segment to sit in one run — but a segment that overlaps an existing content control, a hyperlink, or a `w:fldSimple` field is rejected, since anchoring inside one of those would either nest content controls or land in text that isn't real anchorable content. A `w:fldSimple` is rejected in full, code and cached result alike; a complex field (`w:fldChar`/`w:instrText`) is different — only its instruction-code runs are rejected, and its cached result text is ordinary anchorable content, same as any other run. The adapter resolves every 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 — it returns the same `SelectionTarget | TextTarget` union, narrowing to `TextTarget` only when the anchor spans more than one paragraph. 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. 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. HTML and Markdown projection maps serve a different kind of citation: they map a UTF-16 range in serialized output back to a public tracked-coordinate `TextTarget`. They do not create a Word bibliography citation. Keep the map with its `evaluatedRevision`, and [reproject after a mutation](/document-api/output-projections#map-output-back-to-document-content) before using the target for a comment, link, or application record. --- # 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. ## Follow comments through HTML or Markdown [#follow-comments-through-html-or-markdown] Detailed HTML and Markdown projections include one annotation record per in-scope comment. A discontinuous comment keeps one public ID and can have several source or output ranges. The record is `partiallyEmitted` when only some segments survive the chosen scope. Review mode also affects anchors. A comment attached only to inserted content is `omitted` in the original view; one attached only to deleted content is `omitted` in the final view. An omitted record has no output range and reports why. Do not search nearby serialized text and invent a replacement anchor. Use the record's tracked `sourceTarget` when it is available, keep it with the projection's `evaluatedRevision`, and reproject after a mutation. See [Project HTML and Markdown](/document-api/output-projections) for the source-map and citation round-trip contract. --- # 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. --- # Project HTML and Markdown > Choose a review view, scope document content, inspect fidelity, and map serialized output back to document targets. Use `projectHtml()` or `projectMarkdown()` when an application needs more than a display string. These asynchronous reads return serialized content together with the document revision, diagnostics, block ranges, annotation status, and an optional output-to-source map. Use `doc.capabilities.check({ operation: 'projectHtml' | 'projectMarkdown', input })` when a workflow needs the same structured support envelope used for rich writes. The check runs the detailed projector and returns its complete output in `projection`; it does not ask you to repeat a revision-sensitive read. Direct detailed projections and support-check projections both include the common fidelity `outcome` alongside `status`, `lossy`, and all diagnostics. Use [`getHtml()`](/document-api/reference/get-html) or [`getMarkdown()`](/document-api/reference/get-markdown) when only the compact string is needed. The compact methods use the same V2 projection rules but do not expose diagnostics or provenance metadata. Migrating is additive: ```ts const html = doc.getHtml({}); const projection = await doc.projectHtml({ reviewMode: 'final', includeSourceMap: true, }); const detailedHtml = projection.content; ``` In the browser facade, await both forms because browser reads may cross the worker boundary. The headless `DocumentApi` keeps the compact getters synchronous for compatibility; the detailed methods are asynchronous in every host. ## Choose the review view [#choose-the-review-view] `reviewMode` controls which side of open tracked changes becomes content: | Mode | Content | | ---------- | --------------------------------------------------------------------------------------------------- | | `final` | Includes insertions and destinations; excludes deletions and move sources | | `original` | Includes deletions and move sources; excludes insertions and destinations | | `redline` | Includes representable before and after sides with semantic change carriers and annotation metadata | The rule applies to inline text and structural revisions such as paragraphs, list items, table rows and cells, and section boundaries. Formatting-only changes emit one content copy in redline with a semantic carrier. Moves share one logical change ID and identify source and destination sides separately. HTML uses semantic elements such as `` and `` where the HTML content model allows them. Revised table and list nodes carry change attributes when a wrapper would be invalid. Markdown uses the documented CommonMark/GFM subset and raw semantic HTML when Markdown syntax cannot represent a structural revision without ambiguity. ## Choose the story and scope [#choose-the-story-and-scope] Omitting `in` addresses the body story. Pass a supported `StoryLocator` to read a header, footer, footnote, or endnote. Within that story, omit `scope` for the whole story, pass a public block address for one block, or pass a contiguous `SelectionTarget` for a range: ```ts const result = await doc.projectMarkdown({ in: { kind: 'story', storyType: 'footnote', noteId: '2' }, reviewMode: 'original', scope: { kind: 'selection', start: { kind: 'text', blockId: 'paragraph-a', offset: 4 }, end: { kind: 'text', blockId: 'paragraph-b', offset: 12 }, coordinateSpace: 'tracked', }, includeSourceMap: true, }); ``` Text offsets are UTF-16 code units. A visible-coordinate selection counts the chosen rendered review view. A tracked-coordinate selection also addresses deletion-side text. The resolved scope in the result always records the canonical tracked target used for the projection. Range projection fails closed when a boundary would cut through a table, field, or another structure whose partial serialization would be misleading. Invalid or missing targets remain typed input/address errors; the projection does not clip them into a different range. ## Inspect status and fidelity [#inspect-status-and-fidelity] Check `outcome`, `status`, `lossy`, and `diagnostics` before consuming `content`. `outcome` distinguishes preserved, warning-bearing, simplified, and rejected projections using the same vocabulary as rich input checks: | Status | Meaning | | --------- | -------------------------------------------------------------------------------------------------- | | `success` | No lossy diagnostic or fatal error | | `warning` | Usable output exists, and at least one diagnostic records a lossy fallback or placeholder | | `failed` | A source, scope, or representation condition prevented a truthful projection; output data is empty | Diagnostic codes and structured fields are the stable integration surface. Message prose can change. Each diagnostic names the source construct, disposition, story, applicable public IDs, and an output range when one exists. Diagnostics do not include document excerpts or raw package markup. The projection contract preserves semantic document content; it is not a DOCX layout renderer or a complete Word-file round trip: | Source construct | HTML projection | Markdown projection | | ------------------------------------------------ | --------------------------------------------------- | ----------------------------------------------------- | | Paragraphs, headings 1-6, baseline marks | Semantic HTML | CommonMark/GFM syntax; raw `` for underline | | Lists with exactly resolved Word labels | Nested lists with explicit visible-label carriers | Native syntax when exact; otherwise raw semantic HTML | | Missing or unresolved list labels | Readable `[list label unavailable]` placeholder | The same raw-HTML placeholder | | Rectangular, simple tables | Semantic table | GFM table | | Row/column spans or structurally rich tables | Semantic table with spans | Raw semantic HTML table | | Safe links and visible field results | Link or visible result | Link or visible result | | Images with a public resolvable URL | Image element | Markdown image | | Package-only media, drawings, OLE, embedded data | Readable deterministic placeholder and warning | Readable deterministic placeholder and warning | | Math, content controls, unsupported field shape | Preserved readable content or diagnosed placeholder | Preserved readable content or diagnosed placeholder | | Section, page, and column breaks | Semantic marker or diagnosed placeholder | Semantic marker or diagnosed placeholder | Exact Word numbering is resolved per effective list level, including legal numbering and overrides. HTML emits the visible label explicitly instead of relying on a browser's list counter. Markdown uses native list syntax only when it can reproduce that label and separator; custom, Roman, alphabetic, legal, tab-suffixed, or no-suffix labels can use the documented raw-HTML list carrier. This is a deterministic semantic representation, not a claim of Word visual parity. Use [`extract()`](/document-api/reference/extract) when the application needs the structured SDM snapshot rather than a serialized review view. Keep the DOCX itself when the workflow requires package parts, layout, macros, embedded objects, or Word-specific semantics that the construct matrix diagnoses or replaces. ## Map output back to document content [#map-output-back-to-document-content] Every detailed result contains `blocks`. Block ranges cover the complete serialized carrier and use UTF-16 offsets into `content`. A block with `identity: 'public'` includes the ID accepted by block APIs. A structurally emitted table, row, or cell that only had a positional source locator reports `identity: 'unavailable'` instead of presenting that locator as a stable public ID. Nested block ranges can overlap. Set `includeSourceMap: true` for fine-grained citations. Text entries map escaped output payload ranges to public `TextTarget` values in tracked coordinates. HTML/Markdown delimiters are intentionally unmapped. Synthetic list labels and placeholders have output ranges but no invented text target. ```ts const projection = await doc.projectHtml({ reviewMode: 'redline', includeSourceMap: true, }); const outputOffset = projection.content.indexOf('termination'); const entry = projection.sourceMap?.entries.find( (candidate) => candidate.kind === 'text' && candidate.output.start <= outputOffset && outputOffset < candidate.output.end, ); if (entry?.kind === 'text') { const current = await doc.info({}); if (current.revision !== projection.evaluatedRevision) { throw new Error('Projection map is stale; project the document again.'); } await doc.comments.create( { text: 'Review this source passage.', target: entry.source }, { expectedRevision: projection.evaluatedRevision }, ); } ``` `sourceMap.outputCoordinateSpace` is `utf16`; `sourceMap.sourceCoordinateSpace` is `tracked`. Escaped output remains atomic: for example, the full HTML entity `&` can map to one source `&` code unit. Store `evaluatedRevision` with every saved map. Any mutation makes the map and its block ranges stale, even when a familiar paragraph ID still exists. ## Handle comments and tracked-change annotations [#handle-comments-and-tracked-change-annotations] `annotations` is present even when the fine source map is omitted. Each comment or tracked change reports its public ID, public block IDs when available, output ranges, side, and one of these statuses: | Annotation status | Meaning | | ------------------ | --------------------------------------------------------------------------------- | | `emitted` | All representable anchored content for the chosen view and scope was emitted | | `partiallyEmitted` | Only part of a discontinuous or scope-clipped anchor survived | | `omitted` | The view removed the anchor, or its construct could not be represented truthfully | A comment on inserted content is omitted in `original`; a comment on deleted content is omitted in `final`. In `redline`, both sides are represented when their structure permits it. An omitted annotation has no output range and uses `omittedReason: 'reviewMode'` or `'unsupported'`. Do not guess an anchor by searching nearby rendered text; use its `sourceTarget` when present or project a view in which the anchor is emitted. ## Compatibility notes [#compatibility-notes] V2 accepts the deprecated `unflattenLists` option on `getHtml()` for source compatibility but ignores it. Omitted, `true`, and `false` all produce canonical nested lists. Detailed `projectHtml()` does not accept the option. See the generated [`projectHtml()`](/document-api/reference/project-html) and [`projectMarkdown()`](/document-api/reference/project-markdown) references for the exact input and result schemas. --- # 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. For HTML, Markdown, or `SDFragment` replacement, see [insert HTML and Markdown](/document-api/rich-content). Rich replacement uses the same fresh-target and revision-guard rules described here. To replace every block in the main body, pass the explicit body target. This is a Document API content mutation: it keeps the open DOCX package and its terminal page setup while replacing the body blocks in one undo step. ```ts const body = { kind: 'story', storyType: 'body' } as const; const receipt = await doc.replace( { target: body, type: 'markdown', value: '# Replacement\n\nNew body.' }, { changeMode: 'direct', expectedRevision }, ); ``` The body target also accepts `text` or a preconverted `content` fragment. It is main-body-only and direct-mode-only. Tracked whole-body replacement fails with `CAPABILITY_UNSUPPORTED`; use a selection or block target when the replacement must remain a review suggestion. To open a different DOCX package, use the editor's file replacement flow instead. 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. --- # Insert HTML and Markdown > Convert, inspect, and apply bounded HTML or Markdown content to an open DOCX. The Document API accepts HTML and Markdown as structured input. You can inspect the canonical fragment first, or pass the source directly to `insert()` or `replace()`. HTML and Markdown are not Word package fragments. SuperDoc preserves the supported structure listed below, normalizes representation details, and reports anything it drops or downgrades. ## Convert before applying [#convert-before-applying] Use `htmlToFragment()` or `markdownToFragment()` when your application needs to inspect diagnostics or reuse the canonical fragment: ```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 converted = await doc.htmlToFragment({ html: '

Scope

Review this clause.

', }); const fatal = converted.diagnostics.find((diagnostic) => diagnostic.severity === 'error'); if (fatal) throw new Error(`HTML conversion failed: ${fatal.message}`); const insertInput = { type: 'html', value: '

Scope

Review this clause.

', } as const; const checked = await doc.capabilities.check({ operation: 'insert', input: insertInput, options: { changeMode: 'tracked' }, }); if (checked.operation !== 'insert') { throw new Error(`Unexpected support-check operation: ${checked.operation}`); } if (!checked.supported || !checked.guard) { throw new Error(`Rich insert is not supported: ${checked.failure?.message ?? checked.outcome}`); } const insertReceipt = await doc.insert(insertInput, { changeMode: 'tracked', supportCheck: checked.guard, }); if (!insertReceipt.success) { throw new Error(`Rich insert failed: ${insertReceipt.failure?.message ?? 'unknown failure'}`); } const match = await doc.query.match({ select: { type: 'text', pattern: 'Existing clause' }, require: 'exactlyOne', }); const clause = match.items[0]; if (!clause || clause.matchKind !== 'text') throw new Error('The clause was not found.'); const replaceReceipt = await doc.replace( { target: clause.target, type: 'markdown', value: '**Replacement clause** with a [reference](https://example.com/policy).', }, { changeMode: 'tracked', expectedRevision: match.evaluatedRevision, }, ); if (!replaceReceipt.success) { throw new Error(`Rich replace failed: ${replaceReceipt.failure?.message ?? 'unknown failure'}`); } console.log(checked.outcome, insertReceipt.outcome, replaceReceipt.conversion); }, }); window.addEventListener('beforeunload', () => { superdoc.destroy(); }); ``` Each result contains: * `fragment`: the canonical structured content. * `lossy`: `true` when conversion normalized, downgraded, or dropped source content. * `diagnostics`: ordered, source-located conversion findings. A warning-bearing result can still be applied. An error means there is no safe rich fragment to apply. Check diagnostic `severity` and `disposition` instead of treating every lossy result as a failure. ## Check an exact workflow before applying [#check-an-exact-workflow-before-applying] Use `doc.capabilities()` for a cheap synchronous snapshot of general operation availability. Use `doc.capabilities.check()` when the answer must account for exact HTML or Markdown, the current target, the requested change mode, and the current document revision. A check does not mutate package bytes, history, tracked changes, or the public revision. A supported write that would change the document returns a guard. Pass it to the unchanged rich `insert()` or `replace()` request. The write fails closed when the source, format, target, placement, story, nesting policy, mode, analysis result, or document revision no longer matches. The guard digest detects accidental reuse; it is not a security signature. The common `outcome` is `preserved`, `preserved-with-warnings`, `simplified`, `rejected`, `no-op`, `invalid-target`, or `outdated`. Read the complete conversion diagnostics and final mutation receipt before choosing application-specific fallback behavior. ## Insert or replace raw source [#insert-or-replace-raw-source] Pass `value` with `type: 'html'` or `type: 'markdown'` to convert and apply in one call. Rich insert supports a selection, a block with `before` or `after`, a ref, or no target for append. Rich replace accepts a selection, paragraph or heading block, whole table block, ref, or the explicit `{ kind: 'story', storyType: 'body' }` target. The body target replaces all main-body blocks in one direct mutation while retaining the destination DOCX package and terminal section properties. It does not replace the DOCX file. Tracked mode is supported for selection and block workflows, but tracked whole-body replacement returns `CAPABILITY_UNSUPPORTED`. Use `dryRun: true` to resolve and preflight without creating IDs, history, or document changes. Pair a mutation with `expectedRevision` when it depends on an earlier read. Successful raw-source receipts include the final `outcome` and a `conversion` report. They may also include created entity identities, source-path effects, affected stories, text-range shifts, and a transaction ID. Check `success` before using any success-only field. ## Supported inbound constructs [#supported-inbound-constructs] | Source construct | Result | | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Paragraphs, headings, bold, italic, underline, strike, hard breaks | Preserved as canonical blocks and runs | | Ordered and unordered lists, including bounded nesting and ordered starts | Preserved with canonical list levels | | Tables with a complete leading header row and column spans | Preserved; explicit widths and broad table styling are not | | Safe `http`, `https`, and `mailto` links | Preserved as external hyperlinks | | Horizontal rules | Preserved as durable horizontal-rule blocks | | Safe bounded color, font, spacing, indentation, cell shading, and padding | Normalized to typed canonical properties | | Code, block quotes, task markers, and unsupported inline forms | Downgraded or dropped with diagnostics | | Scripts, event handlers, unsafe URLs, raw images, nested tables, row spans, malformed or over-limit input | Sanitized with warnings when safe structure remains; otherwise rejected | The limits are deliberate. Arbitrary CSS, embedded active content, and full DOCX package fidelity are outside the HTML/Markdown contract. ## Projection and persistence [#projection-and-persistence] Direct changes are immediately part of the document. This inbound API creates review markup that can be accepted or rejected through the tracked-changes API. Detailed outbound HTML and Markdown reads can then project `redline`, `final`, or `original` views. Save and reopen the DOCX before treating a mutation workflow as complete. Standalone outbound conversion of an `SDFragment` to HTML or Markdown is not part of this inbound API. Document reads have a separate [HTML and Markdown projection contract](/document-api/output-projections), including compact string getters and detailed reads with review, fidelity, and provenance metadata. For target discovery, start with [query content](/document-api/query-content). For failure handling, see [receipts and errors](/document-api/receipts-and-errors). The generated [HTML conversion reference](/document-api/reference/html-to-fragment), [insert reference](/document-api/reference/insert), and [replace reference](/document-api/reference/replace) list the complete 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/track-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. ## Read final, original, or redline output [#read-final-original-or-redline-output] Use `projectHtml()` or `projectMarkdown()` when a workflow needs a serialized review view rather than the tracked-change catalog. `final` emits accepted-side content, `original` emits the before-side, and `redline` identifies representable before/after content with the same logical change IDs returned here. The view applies to structural paragraph, list, and table changes as well as inline text. Moves distinguish source and destination; formatting-only changes emit one content copy with an annotation carrier. The projection result records annotations removed by the selected view instead of silently dropping their identity. Source targets and maps use tracked coordinates and are valid only at `evaluatedRevision`. Read the [HTML and Markdown projection guide](/document-api/output-projections) before saving a map for a later review action. ## 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. --- # 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. --- # Discuss a document with comments > Add a thread to selected text, reply, and resolve it without changing the passage. Use a comment to ask a question or discuss a passage. Use [tracked changes](/editor/track-changes) when you want to propose an edit to the passage itself. Both work with SuperDoc's built-in UI or your own controls. ## Try a comment thread [#try-a-comment-thread] Expand the Editor and click `September 30, 2026` to open its comment. Add a reply, then choose the checkmark to resolve the thread. Click the date again to see its **Resolved** status and choose **Reopen comment**. > **Interactive editor: Discuss the delivery date** > > Sample: [open the fixture](/fixtures/comments-sample.docx). > > Preset: `comments`. > > Comment thread: open the delivery-date comment, reply, resolve, and reopen it. The messages and status change without editing the passage. Layout and permission experiments are hidden; zoom and expansion remain available. > > Local DOCX selection: disabled. The discussion changes; the date in the document does not. To start another thread, select a different passage, right-click, and choose **Comment**. ## Add comments to your project [#add-comments-to-your-project] Start with the [Quickstart](/editor/quickstart). Comments are enabled by default, so keep its Editor, `/sample.docx`, and export button. Provide the current user's name and email using the [user configuration](/editor/configuration#set-the-startup-configuration) before creating comments. Select a passage and add a comment from the context menu. Its author should match the configured user. You do not need a collaboration server for one person to comment on a document and export it. ## Follow the thread lifecycle [#follow-the-thread-lifecycle] The first comment starts a **thread** anchored to the selected document text. Replies belong to that thread; they do not need another selection. | Action | What changes | | ------------------- | -------------------------------------------------------------------- | | Reply | Adds a message to the same discussion. | | Resolve | Marks the thread as finished but keeps its messages. | | Reopen | Makes a resolved thread active again. | | Delete your comment | Removes your message; deleting your root comment removes its thread. | Resolving a thread is not deleting it. Hiding the comments panel does not remove comments from the DOCX either. ## Save the discussion [#save-the-discussion] Comments, replies, authors, and resolved status are stored in the DOCX. Export using the Quickstart's **Export DOCX** button, then open the exported file in Word or load it back into SuperDoc. Confirm that the thread and its reply remain, including the resolved status you chose. The embedded demo only changes its in-browser document. Your application uses [Load and save](/editor/load-and-save-documents) to persist the exported bytes. Reloading the original sample is not reopening your saved discussion. ## Choose how people interact [#choose-how-people-interact] Keep the built-in UI unless your application needs to own the thread list or composer: * [Configure built-in comments](/editor/built-in-ui/comments) for layout and available actions. * [Build a custom comments panel](/editor/custom-ui/comments) for application-owned markup and selection handling. * [Use Document API comments](/document-api/comments) to create, update, or delete comments in code. Comment settings control browser interaction, not trusted authorization. Your backend still decides who can access and save the document. Comment actions are separate from permission to accept or reject tracked changes. --- # Configure the Editor > Set how the Editor starts, then find the configuration options your integration needs. Continue with the `/sample.docx` project from the [Quickstart](/editor/quickstart). Add a current user and choose how the Editor starts. ## Set the startup configuration [#set-the-startup-configuration] Add the matching import and `startupOptions` object at the top of your Quickstart file: `src/main.ts` in Vanilla or `src/App.tsx` in React, outside the component. These options name the author and start the Editor in suggesting mode: **Vanilla — `Startup options`** ```ts import type { Config } from 'superdoc'; export const startupOptions = { documentMode: 'suggesting', user: { name: 'Jordan Lee', email: 'jordan@example.com', }, } satisfies Partial; ``` **React — `Startup options`** ```tsx import type { SuperDocEditorProps } from '@superdoc/react'; export const startupOptions = { documentMode: 'suggesting', user: { name: 'Jordan Lee', email: 'jordan@example.com', }, } satisfies Partial; ``` In Vanilla, spread `startupOptions` into the object passed to `new SuperDoc()`. In React, spread it onto `SuperDocEditor` as `{...startupOptions}`. Keep the callbacks and export code from Quickstart. Reload the application and change `September 1, 2026` to `October 1, 2026`. The edit should appear as a tracked change instead of replacing the date directly. The `user` value identifies the author of the tracked change. This example uses a fixed user so it runs without authentication. `satisfies` checks the field names and values during typechecking. ## Find an option [#find-an-option] Choose a group, then choose a field. Each entry shows what it changes, its type, and its default. Some fields link to a guide with a complete example. Expand **API details** for the generated description. ### Essentials | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `selector` | `string \| HTMLElement` | — | Required | Choose the element where the Editor mounts. | The selector or element to mount the SuperDoc into. | — | | `document` | `DocumentSource \| null` | — | Optional | Open a document from a URL, File, Blob, or collaboration source. | Document to open. Pass a URL, file, byte source, or structured source. Use a structured document carrying `collaboration` for collaboration, or a structured source for other metadata. Omit it to open a blank DOCX. | [Load and save documents](/editor/load-and-save-documents) | | `documentMode` | `"editing" \| "viewing" \| "suggesting"` | `'editing'` | Optional | Start in editing, suggesting, or viewing mode. | The mode of the document (default: 'editing'). | [Document modes](/editor/document-modes) | | `user` | `{ color?: string; id?: null \| string; name?: null \| string; email?: null \| string; image?: null \| string; }` | — | Optional | Identify the current user for collaboration and tracked changes. | The current user of this SuperDoc. Typed as `AwarenessUser` (an extension of `User` with the optional `color` field) so consumers can pass an explicit awareness color and have the runtime honor it as an override - `SuperDoc#assignUserColor()` skips its hash-based assignment when `user.color` is already set. | — | ### Document | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `viewing` | `{ comments?: boolean; trackedChanges?: "original" \| "markup" \| "final"; }` | `{ comments: false, trackedChanges: 'original' }` | Optional | Choose what comments and tracked changes viewers see. | What review information is shown when `documentMode` is `viewing`. | [Document modes](/editor/document-modes) | | `role` | `"editor" \| "viewer" \| "suggester"` | — | Optional | Limit which document modes the current user can enter. | The role of the user in this SuperDoc. | — | | `allowSelectionInViewMode` | `boolean` | `false` | Optional | Let viewers select text without editing. | When `documentMode` is `'viewing'`, allow the user to make text selections even though editing is disabled. Defaults to `false`. Forwarded to the underlying editor as `options.allowSelectionInViewMode`. | [Document modes](/editor/document-modes) | | `superdocId` | `string` | — | Optional | Set an ID for this Editor instance. | The ID of the SuperDoc. | — | | `password` | `string` | — | Optional | Open an encrypted DOCX with its password. | Password for encrypted DOCX files. Forwarded during document load. | — | | `documents` | `Document[]` | — | Optional | Load documents through the legacy multi-document field. | Documents to load. | — | | `fieldContext` | `{ fileName?: null \| string; fullPath?: null \| string; currentUser?: null \| { name?: string \| null; initials?: string \| null; address?: string \| null; }; }` | — | Optional | Provide V2 field values for one initial document; use Document.fieldContext for multiple documents. | Default V2 field values for one initial document. For multiple documents, set Document.fieldContext on each entry. | — | | `users` | `User[]` | — | Optional | Provide the people available for mentions. | All users of this SuperDoc (can be used for "@"-mentions). | — | | `colors` | `string[]` | — | Optional | Provide awareness colors for users. | Colors to use for user awareness. | — | | `title` | `string` | — | Optional | Set the fallback filename used when exporting. | Fallback filename for `export()` when `exportedName` is omitted. | — | ### Interface | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `ui` | `false \| { toolbar?: false \| true \| ToolbarConfig; comments?: false \| true \| CommentsConfig; contextMenu?: false \| true \| ContextMenuConfig; loading?: boolean; search?: false \| true \| SearchConfig; linkPopover?: false \| true \| LinkPopoverConfig; ruler?: false \| true \| RulerConfig; contentControls?: false \| true \| ContentControlsConfig; }` | — | Optional | Choose which built-in interface parts SuperDoc renders. | Which built-in interface SuperDoc renders. Omit it to keep SuperDoc's historical rendering: comments, the context menu, content-control chrome, and mode-aware hyperlink activation are on; search and the ruler are opt-in; and the toolbar renders once it has somewhere to mount. That profile is not symmetrical, and omitting this field reproduces it exactly. Pass `false` when the application owns the interface. SuperDoc then renders no controls, chrome, dialogs, or popovers, while the document, the Document API, and `superdoc.ui` keep working — so a custom UI drives the same commands the built-in one would have. Pass an object to choose per surface. An omitted key keeps that surface's default rather than following its siblings, so `{ comments: false }` disables comments and changes nothing else. | [Choose your interface](/editor/who-renders-the-ui) | | `interaction` | `{ comments?: { level?: CommentInteractionLevel; }; trackedChanges?: { allowDecisions?: boolean; }; }` | — | Optional | Set what people can do through Editor interactions. | Client-side interaction policy. Independent of `ui`, so it still applies when the application renders its own UI. This is not an authorization boundary. | [Choose your interface](/editor/who-renders-the-ui) | | `surfaces` | `{ resolver?: null \| (request: SurfaceRequest) => SurfaceResolution \| null \| undefined; dialog?: { closeOnEscape?: boolean; closeOnBackdrop?: boolean; maxWidth?: string \| number; }; floating?: { placement?: SurfaceFloatingPlacement; width?: string \| number; maxWidth?: string \| number; maxHeight?: string \| number; closeOnEscape?: boolean; closeOnOutsidePointerDown?: boolean; autoFocus?: boolean; }; }` | — | Optional | Configure dialogs and floating overlays. | Shared configuration for dialogs and floating overlays, including ones opened through `superdoc.openSurface()`. Stays active under `ui: false`. | [Dialogs and surfaces](/editor/dialogs-and-surfaces) | | `uiDisplayFallbackFont` | `string` | — | Optional | Set the font used by SuperDoc interface elements. | The font-family to use for all SuperDoc UI surfaces (toolbar, comments UI, dropdowns, tooltips, etc.). This ensures consistent typography across the entire application and helps match your application's design system. The value should be a valid CSS font-family string. Example (system fonts): uiDisplayFallbackFont: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' Example (custom font): uiDisplayFallbackFont: '"Inter", Arial, sans-serif' | — | ### Behavior | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `fieldUpdatePolicy` | `"refreshOnOpen"` | — | Optional | Refresh supported unlocked V2 fields when opening a local editable document. | Refresh supported unlocked V2 fields on local editable open. Omission preserves imported caches. | — | | `hyperlinks` | `false \| { onActivate?: (context: HyperlinkActivationContext) => HyperlinkActivationResult \| null \| undefined; }` | — | Optional | Choose what happens when a person activates a hyperlink. | Hyperlink activation behavior. By default, editable links open the built-in hyperlink editor in Editing or Suggesting mode. In Viewing mode, and for links outside editable text, SuperDoc follows the URL or document anchor. Pass `false` to suppress activation in every mode. A configured `onActivate` handler stays active with `ui: false`, allowing a custom interface to handle hyperlinks. | [Hyperlinks](/editor/built-in-ui/hyperlinks) | | `trackChanges` | `{ enabled?: boolean; replacementMode?: "grouped" \| "separate"; authorColors?: TrackChangesAuthorColorsConfig; semanticColors?: TrackChangesSemanticColorsConfig; visible?: boolean; }` | `{ enabled: true, replacementMode: 'grouped' }` | Optional | Configure replacement review and tracked-change colors. | Configure replacement review and tracked-change colors. Use `viewing.trackedChanges` to choose what a viewer sees and `interaction.trackedChanges` to allow or prevent review decisions. | [Track changes](/editor/track-changes) | | `isLocked` | `boolean` | — | Optional | Set the initial shared lock metadata. | Initial shared lock metadata. This value does not make the document read-only. Use `documentMode` or interaction policy to restrict editing in the client. | — | | `lockedBy` | `{ id?: null \| string; name?: null \| string; email?: null \| string; image?: null \| string; }` | — | Optional | Identify the user who locked the Editor. | User associated with the initial shared lock metadata. | — | | `viewOptions` | `{ layout?: "print" \| "web"; }` | — | Optional | Set DOCX-compatible document view options. | Document view options (OOXML ST_View compatible). | — | | `contained` | `boolean` | `false` | Optional | Keep the Editor inside a fixed-height scrolling container. | Enable contained mode for fixed-height container embedding. SuperDoc supports two layout modes, and the host element's height requirement differs between them: - Natural (default, `false`): the Editor grows to the document's full height and the page scrolls. The host needs no height. Setting one does not constrain the document or enable internal scrolling, because SuperDoc leaves overflow visible in this mode, though application CSS on the host can still clip what is drawn. - Contained (`true`): SuperDoc propagates `height: 100%` through its DOM tree and scrolls the document internally, so multi-page documents stay inside the host. This mode requires the host to have a definite height (for example `height: 400px`); without one there is nothing for the percentage heights to resolve against. A toolbar mounted through `Config.toolbar` or `modules.toolbar.selector` is never part of this calculation. Placed as a sibling of the host, its height adds to the host's: a 400px host with a 40px toolbar occupies 440px in total. Placed inside the host, it consumes part of the 400px instead. | [Responsive layout](/editor/built-in-ui/responsive-layout) | | `zoom` | `{ initial?: number; mode?: "manual" \| "fit-width"; fitWidth?: SuperDocFitWidthOptions; }` | `{ initial: 100, mode: 'manual' }` | Optional | Set the initial zoom and fit-to-width behavior. | Zoom behavior: the initial zoom level and optional fit-width policy. See `SuperDocZoomConfig`. | [Responsive layout](/editor/built-in-ui/responsive-layout) | | `measurementUnit` | `"in" \| "cm"` | `'in'` | Optional | Set the ruler and measurement unit. | Starting measurement unit for rulers and measurement fields (Word's "measurement units" preference). Defaults to `'in'` (Word's en-US default). Change it at runtime with `setMeasurementUnit()`. See `SuperDocMeasurementUnit`. | [Ruler](/editor/built-in-ui/ruler) | ### Integrations | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `modules` | `{ trackChanges?: TrackChangesModuleConfig; }` | — | The trackChanges module path is deprecated. Use the top-level trackChanges field. | Configure document features that do not belong to the built-in interface. | Modules to load. | — | | `permissionResolver` | `(params: { permission: string; role: string; isInternal: boolean; defaultDecision: boolean; comment: null \| object; trackedChange: null \| object; currentUser: null \| User; superdoc: null \| SuperDocClass; }) => boolean \| undefined` | — | Optional | Customize client-side permission decisions. | Customize client-side permission decisions. This is not an authorization boundary. When both resolver spellings are present, this field takes precedence over the deprecated `modules.comments.permissionResolver` field. | — | | `extensions` | `SuperDocExtension>[]` | — | Optional | Add extensions created with `defineSuperDocExtension`. | SuperDoc v2 extensions created with `defineSuperDocExtension`. These extensions activate without an `editorVersion` or `editorIntegration` selector. Each extension owns isolated storage, named events, commands, anchors, and render-only decorations, and mutates the document exclusively through the guarded Document API (`ctx.doc.*`). This is the v2 replacement for the v1/ProseMirror `editorExtensions` path; the two are not interchangeable. Extension arrays are mount-time config: changing the array reference requires a remount to take effect. | — | | `handleImageUpload` | `(file: { lastModified: number; name: string; webkitRelativePath: string; size: number; type: string; arrayBuffer: () => Promise; bytes: () => Promise>; slice: (start: number \| undefined, end: number \| undefined, contentType: string \| undefined) => Blob; stream: () => ReadableStream>; text: () => Promise; }) => Promise` | — | Optional | Store images inserted into the document. | The function to handle image uploads. | — | | `cspNonce` | `string` | — | Optional | Apply a Content Security Policy nonce to SuperDoc runtime styles. | Content Security Policy nonce for SuperDoc runtime styles. Editors that share a document must use the same nonce. | [Secure integration](/editor/secure-integration) | | `licenseKey` | `string` | — | Optional | Set the client-visible license identity sent with document-open telemetry. | Client-visible license identity sent with document-open telemetry. | [License](/editor/license) | | `telemetry` | `{ enabled: boolean; endpoint?: string; metadata?: Record; }` | `{ enabled: true }` | Optional | Configure telemetry sent when a DOCX becomes ready. | Document-open telemetry settings. Enabled by default. | [Telemetry](/editor/telemetry) | | `proofing` | `ProofingConfig` | — | Optional | Configure spelling and grammar checks. | Proofing / spellcheck configuration. | [Add proofing](/editor/platform/proofing) | | `fonts` | `{ bundled?: false \| true \| string[] \| Record \| "baseline" \| "full"; families?: FontFamilyConfig[]; map?: Record; assetBaseUrl?: string; resolveAssetUrl?: (context: import("@superdoc/font-system").FontAssetUrlContext) => string; assetUrl?: string \| (context: import("@superdoc/font-system").FontAssetUrlContext) => string; }` | — | Optional | Configure document fonts and font asset loading. | Font system configuration. The reviewed fallback pack ships in the optional `@superdoc-dev/fonts` package: pass `superdocFonts` (bundler) or the `SuperDocFonts` global from its `superdoc-fonts.min.js` browser build (CDN). To self-host, set `fonts.assetBaseUrl` (e.g. `/fonts/` or a CDN URL) or `fonts.resolveAssetUrl` for signed/versioned hosting. Core ships no document families, so with none configured the toolbar shows the baseline and documents render with system fonts. It does ship one built-in face: a core-symbol provider requested whenever the document contains symbol or dingbat glyphs it covers, such as the • bullet, even with no `fonts` configuration. | — | | `workerUrls` | `{ document?: string \| URL; collaboration?: string \| URL; reviewIndex?: string \| URL; }` | — | Optional | Load browser workers from same-origin URLs. | Optional same-origin URLs for v2's browser worker assets. Configure these when the application and SuperDoc bundle are served from different origins. Omitted entries keep SuperDoc's bundled worker URLs. | [Secure integration](/editor/secure-integration) | ### Lifecycle | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `onReady` | `(params: { superdoc: SuperDocClass; }) => void` | — | Optional | Enable document actions after the Editor is ready. | Callback when the SuperDoc is ready. Receives a wrapper carrying the live SuperDoc instance. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onContentError` | `(params: { error: unknown; editor: Editor; documentId: string; file: null \| File \| Blob; }) => void` | — | Optional | Handle document import and content errors. | Called when the editor cannot read or update document content. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onException` | `(params: SuperDocExceptionPayload) => void` | — | Optional | Handle SuperDoc runtime exceptions. | Callback when SuperDoc emits an `exception` event. The payload is a union of runtime shapes (store init, restore failure, editor lifecycle, built-in toolbar, hyperlink activation, structured diagnostic). Narrow with `'stage' in params` (store init), `'code' in params` (editor), `'itemName' in params` (toolbar), `'source' in params` (hyperlink), or `'diagnosticCode' in params` (structured diagnostic) before reading shape-specific fields. A structured diagnostic (`SuperDocExceptionDiagnosticPayload`, `diagnosticCode` one of `PARSE_ERROR` \| `RENDER_ERROR` \| `UNSUPPORTED_FEATURE` \| `PERFORMANCE_ERROR`) can accompany a legacy exception payload. SuperDoc filters unsupported internal records. For translated package and readiness records, it emits at most one structured diagnostic for each `(documentId, generation, internalCode)` tuple. It also suppresses a generic boot diagnostic when a more specific package diagnostic describes the same failure. A single incident can therefore raise 0..N structured diagnostics. The `unzip`, `parse`, `render`, and `export` stages are populated today; `layout` is reserved for future coverage. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onEditorCreate` | `(params: { editor: Editor; }) => void` | — | Optional | Run code after an editor is created. | Callback after an editor is created. Receives a wrapper carrying the editor. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onSourceComplete` | `() => void` | — | Optional | Run code when the document is ready for diff capture. | Callback when the v2 document source reaches source-complete posture and diff.capture is safe to call. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onSourceSignalsComplete` | `() => void` | — | Optional | Run code after source signals finish building. | Callback when v2 source signals finish building (fires after onSourceComplete; diff.capture is synchronously safe). | [Lifecycle and events](/editor/lifecycle-and-events) | | `onCommentsUpdate` | `(params: { type: string; comment?: Comment; changes?: { key: string; commentId: string; fileId?: string \| null; }[]; pendingSelection?: null \| SelectionInfo; }) => void` | — | Optional | React when comments change. | Callback when comments are updated. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onContentControlClick` | `(params: { target: ContentControlRef; source: "pointer"; }) => void` | — | Optional | React when a person selects a content control. | Callback when someone clicks inside a content control. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onAwarenessUpdate` | `(params: { states: AwarenessState[]; added: number[]; removed: number[]; superdoc: SuperDocClass; }) => void` | — | Optional | React when collaboration awareness changes. | Callback when awareness is updated. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onLocked` | `(params: { isLocked: boolean; lockedBy: null \| User; }) => void` | — | Optional | React when the Editor locks or unlocks. | Callback when the SuperDoc is locked or unlocked. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onPdfDocumentReady` | `() => void` | — | Optional | Run code when a PDF document is ready. | Callback when the PDF document is ready. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onSidebarToggle` | `(isOpened: boolean) => void` | — | Optional | React when the sidebar opens or closes. | Callback when the sidebar is toggled. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onCollaborationReady` | `(params: { editor: Editor; }) => void` | — | Optional | Enable shared-document actions when collaboration is ready. | Callback when collaboration is ready. Receives a wrapper carrying the editor. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onEditorUpdate` | `(params: { editor?: Editor; sourceEditor?: Editor; surface: "body" \| "header" \| "footer"; headerId: null \| string; sectionType: null \| string; }) => void` | — | Optional | React after document content changes. | Callback when document is updated. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onTrackedChangesBulkDecision` | `(params: { documentId: null \| string; decision: "accept" \| "reject"; requestedCount: number; successfulCount: number; permissionDeniedCount: number; }) => void` | — | Optional | React after Accept All or Reject All finishes, including permission-denied counts. | Callback after an Accept All or Reject All tracked-change decision. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onCommentsListChange` | `(params: { isRendered: boolean; }) => void` | — | Optional | React when the comments list is rendered. | Called when the built-in comments list is rendered or removed. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onPaginationUpdate` | `(params: { totalPages: number; superdoc: SuperDocClass; }) => void` | — | Optional | Read the page count after a layout update. | Called after each pagination layout pass with the current page count. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onZoomChange` | `(params: { zoom: number; mode: "manual" \| "fit-width"; }) => void` | — | Optional | React when the zoom level changes. | Callback when the zoom level changes. Fires for every zoom source: `setZoom()`, the toolbar zoom control, and fit-width adjustments. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onViewportChange` | `(params: { availableWidth: number; documentWidth: number; fitZoom: number; }) => void` | — | Optional | React when fit-to-width measurements change. | Callback when the implied fit changes (rounded fit zoom or base page width); pixel-level width jitter does not fire it, and `getViewportMetrics()` always reads latest. Registered before the first emit. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onPageMarginsChange` | `(params: { documentId: string; editorVersion: 2; sectionId: string; sectionIndex: number; side: "left" \| "right"; value: number; pageMargins: { top?: number; right?: number; bottom?: number; left?: number; }; }) => void` | — | Optional | React after a ruler drag changes a section margin. | Callback after a ruler drag changes the active section's left or right page margin. | [Ruler](/editor/built-in-ui/ruler) | | `onPageCountKnown` | `(payload: { pageCount: number; generation: number; }) => void` | — | Optional | Read the page count before paint. | Experimental callback fired when paginated layout changes the page count. Runs before paint. `generation` identifies the layout pass. Does not fire in web layout. | [Lifecycle and events](/editor/lifecycle-and-events) | | `onFontsChanged` | `(payload: { source?: "initial" \| "diagnostic-settle" \| "config-change" \| "late-load" \| "render-change"; loadSummary?: null \| FontLoadSummary; report?: FontResolutionRecord[]; missingFonts?: string[]; documentFonts?: string[]; documentFontOptions?: DocumentFontOption[]; }) => void` | — | Optional | Receive final font loading and substitution results. | Called after initial font resolution and whenever substitution or font availability changes. The payload includes the current report, missing fonts, load summary, and the reason for the update. Use `superdoc.fonts.onReport()` for the same subscription at runtime. | [Lifecycle and events](/editor/lifecycle-and-events) | ### Advanced | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `diagnostics` | `{ history?: import("./diagnostics.js").InteractionHistoryConfig; }` | — | Optional | Configure bounded local Interaction History for bug reports. | Configure bounded in-memory Interaction History for browser debugging. | — | | `isDev` | `boolean` | — | Optional | Enable development behavior for this instance. | Whether the SuperDoc is in development mode. | — | | `disablePiniaDevtools` | `boolean` | — | Optional | Disable Pinia and Vue devtools for this instance. | Disable Pinia/Vue devtools plugin setup for this SuperDoc instance (useful in non-Vue hosts). | — | | `layoutEngineOptions` | `{ flowMode?: "paginated" \| "semantic"; trackedChanges?: object; virtualization?: { enabled?: boolean; window?: number; overscan?: number; }; showBookmarks?: boolean; showFormattingMarks?: boolean; paintHud?: boolean; }` | — | Optional | Override page layout and rendering behavior. | Layout engine overrides passed through to DocumentRendererRuntime (page size, margins, virtualization, zoom, debug label, etc.). | [Performance](/editor/performance-and-large-documents) | | `isInternal` | `boolean` | — | Optional | Set whether the current user creates and reviews internal comments. | Whether the current user is internal. This affects comment visibility, new-comment metadata, and the default permission decision. It is not an authorization boundary. | — | | `isDebug` | `boolean` | — | Optional | Enable debug behavior. | Whether to enable debug mode. | — | | `workerStartupTimeoutMs` | `number` | `30000` | Optional | Set how long the document worker may take to start. | Budget for the document worker to start up, in milliseconds (default: 30000). Measured from worker spawn, so it covers script download, parsing, evaluation, and the worker's first response to SuperDoc. Raise it when a large worker chunk is served over a slow connection or a cold dev-server cache; lower it to fail faster. Worker load errors are reported immediately and do not wait for this budget. Must be a finite positive number no greater than 2147483647, the platform timer ceiling above which a delay would fire immediately. | [Performance](/editor/performance-and-large-documents) | | `useLayoutEngine` | `boolean` | — | Optional | Pass or omit layout engine options when a DOCX editor opens. | Whether `layoutEngineOptions` are passed when a DOCX editor opens. Set to `false` to omit `layoutEngineOptions` and use CSS fallback styling for the initial non-default zoom. This does not select a different DOCX renderer. `viewOptions.layout` separately selects print or web layout. | — | ## Update a running Editor [#update-a-running-editor] Configuration sets the starting state. After `onReady`, use a runtime method when a value needs to change. | Change | Runtime method | | ------------ | ------------------------------ | | Open a DOCX | `replaceFile()` | | Switch modes | `setDocumentMode()` | | Change zoom | `setZoom()` or `setZoomMode()` | Changing the object passed to `new SuperDoc()` does not update a running Vanilla instance. In React, `documentMode` updates as a prop. Access other runtime methods through `editorRef.current?.getInstance()`. If a Vanilla setting has no runtime method, call `destroy()` on the current instance before creating its replacement. In React, remount `SuperDocEditor` by changing its `key`; the wrapper destroys the old instance. Keep React configuration objects such as `user` and `ui` outside the component or memoize them. Changing their object identity recreates the Editor; changing `documentMode` does not. ## Continue to document modes [#continue-to-document-modes] [Compare editing, suggesting, and viewing](/editor/document-modes), then choose what viewers see when a document has comments or tracked changes. For deployment-specific settings, choose [Telemetry](/editor/telemetry) or [License](/editor/license). --- # Open dialogs and floating surfaces > Open temporary application UI over the Editor and handle every way it can finish. Use a surface when your application needs temporary UI over the Editor. Choose a dialog when the user must respond before returning to the document. Choose a floating surface when the document should remain usable. ## Try both surface modes [#try-both-surface-modes] 1. Open **confirmation**. Continue submits it. Cancel, Escape, or the backdrop closes it. 2. Open **inspector** and keep editing the document. Open it again to replace the first inspector. > **Live example: compare a dialog and a floating surface** > > Open confirmation to see a modal dialog finish as `submitted` or `closed`. Open inspector twice to see the first floating surface finish as `replaced` while the new inspector stays open. The floating inspector leaves the document usable. The status line reports each lifecycle result. The status line reports `submitted`, `closed`, or `replaced`. Destroying the Editor produces the remaining outcome, `destroyed`. ## Choose the right positioning API [#choose-the-right-positioning-api] | Need | API | | ----------------------------- | ----------------------------------------------------------------------- | | A modal decision | `openSurface({ mode: 'dialog' })` | | A fixed, non-modal helper | `openSurface({ mode: 'floating' })` | | UI that follows selected text | [Selection and viewport APIs](/editor/custom-ui/selection-and-viewport) | A floating surface stays in one of the Editor viewport's placement slots. It does not follow a document range. Use the selection and viewport APIs for a selection toolbar, AI prompt, or other text-anchored UI. ## Open a dialog [#open-a-dialog] This standalone Vanilla recipe uses `public/sample.docx` from the [Quickstart](/editor/quickstart). Replace the body of `index.html` with: ```html

Opening document…

``` Replace `src/main.ts` below. This example mounts plain DOM in the surface, but `render()` can mount your framework instead. ```ts import { SuperDoc, type SurfaceOutcome } from 'superdoc'; import 'superdoc/style.css'; const actions = document.querySelector('#surface-actions'); const openButton = document.querySelector('#open-confirmation'); const status = document.querySelector('#surface-status'); if (!actions || !openButton || !status) throw new Error('The surface controls are incomplete.'); const reportDocumentError = ({ error }: { error: unknown }) => { console.error('Could not open the document.', error); if (openButton.disabled) status.textContent = 'Could not open the document. Reload to try again.'; }; const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', onContentError: reportDocumentError, onException: reportDocumentError, onReady: () => { openButton.disabled = false; status.textContent = ''; }, }); type ConfirmationResult = Readonly<{ action: 'continue' }>; export const confirmInEditor = async (message: string): Promise => { const handle = superdoc.openSurface({ 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 = 'Continue'; const cancelAction = () => close('cancel'); const confirmAction = () => resolve({ action: 'continue' }); cancel.addEventListener('click', cancelAction); confirm.addEventListener('click', confirmAction); container.append(text, cancel, confirm); return { destroy() { cancel.removeEventListener('click', cancelAction); confirm.removeEventListener('click', confirmAction); }, }; }, }); const outcome: SurfaceOutcome = await handle.result; switch (outcome.status) { case 'submitted': return outcome.data?.action === 'continue'; case 'closed': return false; case 'replaced': return false; case 'destroyed': return false; } }; openButton.addEventListener('click', async () => { actions.inert = true; try { const confirmed = await confirmInEditor('Continue with this action?'); status.textContent = confirmed ? 'Confirmed.' : 'No action taken.'; } catch (error) { console.error('Could not open the confirmation.', error); status.textContent = 'Could not open the confirmation. Try again.'; } finally { actions.inert = false; openButton.focus({ preventScroll: true }); } }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` Choose **Open confirmation**, then **Continue** or **Cancel**. The status reports your choice; it does not change the DOCX. SuperDoc owns the dialog shell, position, and focus behavior. Your `render()` callback owns the content. It receives an empty `container` plus `resolve()` and `close()`. Return `{ destroy() {} }` to remove listeners or unmount a framework root when the surface closes. ## Handle every outcome [#handle-every-outcome] `handle.result` resolves once. `resolve(data)` produces `submitted`. `close(reason)`, Escape, and backdrop clicks produce `closed`. Opening another surface in the same mode produces `replaced` for the previous handle. Destroying the Editor produces `destroyed`. Dialog and floating modes have separate active slots, so one of each can be open. Opening a second surface in either mode replaces only the surface in that mode. Invalid requests throw synchronously. Normal lifecycle outcomes resolve `handle.result`; they do not reject it. ## Use the interaction defaults deliberately [#use-the-interaction-defaults-deliberately] Dialogs trap focus, restore it when they close, and close on Escape or backdrop clicks by default. Floating surfaces default to the top-right, focus their first control, close on Escape, and stay open after outside pointer events. The dialog's trap covers keystrokes inside its own backdrop. Controls your application renders outside the Editor viewport are not part of it, so moving focus to one takes the reader out of the dialog and Escape stops closing it. While a dialog is open, make the rest of your interface unreachable — `inert` on the surrounding region is enough — and do not rely on the trap alone to contain focus. The trap also needs something to hold. It cycles between the first and last enabled focusable elements inside the backdrop, so a dialog whose `render()` adds none — text-only content, or controls that are all disabled — has no cycle to enforce and Tab leaves it on the first press. The shell itself takes initial focus but does not retain it. Give every dialog at least one enabled control, such as a confirm or close button, even when its content is only a message. Give every surface a visible `title`, `ariaLabel`, or `ariaLabelledBy`. Override the close and focus defaults only when the workflow needs different behavior. These options control interaction, not authorization or transaction safety. Configure `surfaces` for shared defaults or intent resolution. Direct `openSurface()` calls do not need a resolver. ## Verify the lifecycle [#verify-the-lifecycle] Open a confirmation and complete it each way. Open the inspector twice and confirm the first handle reports `replaced`. Confirm focus returns to the control that opened a dialog. Next, [apply your product theme to the Editor UI](/editor/theming). --- # Choose a document mode > Choose how the Editor handles edits and what appears in viewing mode. The [Configuration](/editor/configuration) guide starts `/sample.docx` in `suggesting` mode. Compare it with `editing` and `viewing`, then choose the behavior your application needs. ## Try each mode [#try-each-mode] Expand the Editor. Try the replacement in Editing, then reset the sample. Switch to Suggesting and replace `30 days` with `60 days`. Switch to Viewing, then use Changes to compare Original, Markup, and Final against that same proposal. > **Interactive editor: Try document modes** > > Sample: [open the fixture](/fixtures/document-modes.docx). > > Preset: `document-modes`. > > Try the same edit in each mode. Editing changes the document directly and is the default. Suggesting records a tracked change. After making a suggestion, switch to Viewing and use Changes to choose Original, Markup, or Final for the same proposal. > > Local DOCX selection: disabled. | Mode | What happens | Use it when | | ------------ | ---------------------------------- | ------------------------------------------------- | | `editing` | The text changes directly. | Changes should become part of the document. | | `suggesting` | The edit becomes a tracked change. | Another person should review the proposed change. | | `viewing` | Editing is disabled. | A person should read without changing the DOCX. | `editing` is the default. Modes change Editor behavior in the browser. They do not decide who can open or save the document. ## Apply the mode to your project [#apply-the-mode-to-your-project] Keep the Quickstart page, styles, and `/sample.docx`. Replace `src/main.ts` in Vanilla or `src/App.tsx` in React with the matching example below. It keeps the author and export button, and adds **Switch to viewing** after the document opens. **Vanilla — `src/main.ts`** ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const exportButton = document.querySelector('#export-docx'); const viewingButton = document.createElement('button'); viewingButton.type = 'button'; viewingButton.textContent = 'Switch to viewing'; viewingButton.disabled = true; if (!exportButton) throw new Error('The export button is missing.'); exportButton.before(viewingButton); const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', documentMode: 'suggesting', user: { name: 'Jordan Lee', email: 'jordan@example.com' }, viewing: { comments: true, trackedChanges: 'markup', }, onReady: () => { exportButton.disabled = false; viewingButton.disabled = false; }, onContentError: ({ error }) => console.error('SuperDoc could not open the document.', error), onException: ({ error }) => console.error('SuperDoc could not open the document.', error), }); viewingButton.addEventListener('click', () => { superdoc.setDocumentMode('viewing'); viewingButton.disabled = true; }); 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; } }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` **React — `src/App.tsx`** ```tsx import { useRef, useState } from 'react'; import { SuperDocEditor, type DocumentMode, type SuperDocRef } from '@superdoc/react'; import '@superdoc/react/style.css'; const user = { name: 'Jordan Lee', email: 'jordan@example.com' }; function reportDocumentError({ error }: { error: unknown }) { console.error('SuperDoc could not open the document.', error); } export default function App() { const editorRef = useRef(null); const exportingRef = useRef(false); const [ready, setReady] = useState(false); const [exporting, setExporting] = useState(false); const [documentMode, setDocumentMode] = useState('suggesting'); async function exportDocument() { if (exportingRef.current) return; exportingRef.current = true; setExporting(true); try { await editorRef.current?.getInstance()?.export({ exportType: ['docx'], exportedName: 'sample-edited' }); } catch (error) { console.error('SuperDoc could not export the document.', error); } finally { exportingRef.current = false; setExporting(false); } } return (
setReady(true)} onContentError={reportDocumentError} onException={reportDocumentError} viewing={{ comments: true, trackedChanges: 'markup', }} />
); } ``` Vanilla calls `setDocumentMode()` on the ready Editor. React updates the `documentMode` prop. Neither change remounts the Editor. Change the effective date while suggesting, then select **Switch to viewing**. The proposal should remain visible, but typing should no longer change the document. **Export DOCX** should still download the document with the proposal. ## Choose how tracked changes appear [#choose-how-tracked-changes-appear] Viewing stays read-only. Its `trackedChanges` option changes how proposals appear without accepting or rejecting them. For the `30 days` to `60 days` proposal in the demo: | `trackedChanges` | What appears in viewing mode | Use it to | | ---------------- | ------------------------------------------------------ | -------------------------------------------- | | `original` | `30 days`, without change marks. | Show the document before the proposal. | | `markup` | `30 days` deleted and `60 days` inserted, both marked. | Show exactly what the proposal changes. | | `final` | `60 days`, without change marks. | Preview the document as if it were accepted. | `original` is the default. These options only change the display. The proposal remains in the DOCX. Set `viewing.comments` to `true` to show comment anchors and threads. Comments are hidden by default. To change a mounted viewer, call `superdoc.setViewingOptions({ trackedChanges: 'final' })` after `onReady`. Omitted options keep their current values. For review controls, see [Track changes](/editor/track-changes). For comment threads, see [Comments](/editor/comments). ## Continue to load and save [#continue-to-load-and-save] [Load and save a DOCX](/editor/load-and-save-documents) to connect the same project to your storage. --- # Control DOCX export > Download an edited DOCX, return its bytes, or package related files. Export the document you have edited, then choose what your application does with the file. The browser Editor exports DOCX; it does not convert the document to PDF or HTML through this method. Use the running Editor from the [Quickstart](/editor/quickstart). The examples below belong in your export button's handler, after `onReady`. Keep the button disabled while export is running and show any rejected promise as an error. In Vanilla, `superdoc` is the instance you created. In React, obtain it inside the handler before using any example: ```ts const superdoc = editorRef.current?.getInstance(); if (!superdoc) return; ``` ## Download the edited document [#download-the-edited-document] ```ts await superdoc.export({ exportedName: 'sample-edited' }); ``` This starts a download named `sample-edited.docx`. Open it and confirm that your edit is present. The filename does not need an extension, and DOCX is already the default format. For a runnable save, edit, restore, and export flow, use the [version-history example](https://go.superdoc.dev/examples/version-history). Its **Export current DOCX** button downloads the document currently open in the Editor. ## Return bytes without downloading [#return-bytes-without-downloading] Set `triggerDownload: false` when your application needs the file instead of a browser download: ```ts const docx = await superdoc.export({ triggerDownload: false }); ``` For one document without additional files, the result is a DOCX `Blob`. Export does not upload it or confirm a save. Use the [Load and save guide](/editor/load-and-save-documents) to send those bytes to your backend and report success only after storage confirms the write. ## Check comments and tracked changes [#check-comments-and-tracked-changes] Export preserves comments by default. The Quickstart's default v2 worker runtime does not support `commentsType: 'clean'`: export rejects instead of returning a copy without comments. If you need final text, finish the [review workflow](/editor/track-changes) before exporting. The v2 Editor does not apply `isFinalDoc` during export; do not use that option as a review decision. ## Include related files [#include-related-files] To deliver the DOCX alongside another file, supply a `Blob` and its filename. This example creates its own small metadata file; it does not require an audit service: ```ts const metadata = new Blob([JSON.stringify({ document: 'sample-edited' })], { type: 'application/json' }); const bundle = await superdoc.export({ exportedName: 'sample-edited', additionalFiles: [metadata], additionalFileNames: ['metadata.json'], triggerDownload: false, }); ``` The result is a ZIP `Blob` containing `sample-edited.docx` and `metadata.json`. Keep the two additional-file arrays in the same order, with one filename for each file. Omit `triggerDownload: false` to download `sample-edited.zip`. ## Choose options for the next export [#choose-options-for-the-next-export] | Option | Default | Effect | | ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | | `exportedName` | Editor title | Name the DOCX or ZIP without its extension. | | `triggerDownload` | `true` | Download the result; set it to `false` to return a `Blob` or ZIP without downloading. | | `commentsType` | `'external'` | Preserve comments. The default v2 worker runtime rejects `'clean'`. | | `additionalFiles` / `additionalFileNames` | Empty arrays | Include related files in a ZIP. | `exportType` defaults to `['docx']`, the browser Editor's supported export format. The v2 Editor does not apply `fieldsHighlightColor` during export. Use [Version history](/editor/version-history) when your application needs to retain each exported DOCX as a saved version. --- # Resolve document fonts > See how each DOCX font resolves, then provide licensed files for missing families. The theme from the previous page styles controls around the document. Document fonts are separate. A DOCX stores family names such as Aptos, but may not contain the corresponding font files. Continue with `/sample.docx` from the [Quickstart](/editor/quickstart). It requests Aptos for body text and Aptos Display for headings. Start without adding a `fonts` configuration. ## Start with the available fonts [#start-with-the-available-fonts] By default, SuperDoc keeps each font name from the DOCX. It uses an embedded font when the document contains one and its embedding permissions allow it. Otherwise, the browser uses an installed system font with that name. With no font provider configured, SuperDoc does not fetch a replacement for an unavailable document family. One built-in provider is the exception. SuperDoc always registers a core-symbol face and requests it when the document contains symbol or dingbat characters it covers — including the `•` bullet in the Quickstart sample. That request happens with no `fonts` configuration at all, so the package's bundled font assets must stay reachable under your Content Security Policy even before you add a provider. It supplies those glyphs only; it never substitutes a document family. If the requested font is unavailable, the browser paints with its fallback. The text remains editable and export keeps the original DOCX font name, but different glyph widths can change line and page breaks. | Your document and audience | Recommended path | | ------------------------------------------------------ | --------------------------------------------------------------------- | | The DOCX contains an eligible embedded font | Open the document without font configuration. | | Every supported device has the requested font | Use the system font and verify each supported environment. | | The font is proprietary or is not installed everywhere | Host licensed web-font files and register each required face. | | You intentionally accept a different typeface | Register that font, map the DOCX family to it, and verify pagination. | The font names shown in the built-in toolbar are choices, not font files bundled with `superdoc`. ## See the resolution path [#see-the-resolution-path] Compare what happens to the Aptos body text in the Quickstart document. The DOCX name stays the same while the available provider changes. > **Interactive model: how a document font resolves** > > | Scenario | DOCX requests | Provider | SuperDoc resolves | DOCX exports | Diagnostic | > | --- | --- | --- | --- | --- | --- | > | System font | Aptos | Installed Aptos | Aptos | Aptos | reason: `as_requested`; loadStatus: `unloaded`; systemAvailability: `available`; missing: `false` | > | Hosted font | Aptos | /fonts/aptos-regular.woff2 | Aptos | Aptos | reason: `registered_face`; loadStatus: `loaded`; missing: `false` | > | Unavailable font | Aptos | No usable Aptos face | Aptos | Aptos | reason: `as_requested`; loadStatus: `unloaded`; systemAvailability: `unavailable`; missing: `true` | For an available system font, `loadStatus: 'unloaded'` does not mean the font is missing. It means SuperDoc did not load a registered font asset. `systemAvailability` reports whether the browser can use the system face. ## Host a proprietary font [#host-a-proprietary-font] Host a proprietary font only when its license permits web delivery. For the Quickstart document, place the licensed Aptos files under `public/fonts`, then register the families and faces the document uses. Each tab is the Quickstart file with `fonts` added, so the export button the fidelity checklist relies on keeps working: **Vanilla — `src/main.ts`** ```ts import { SuperDoc, type Config } from 'superdoc'; import 'superdoc/style.css'; const exportButton = document.querySelector('#export-docx'); if (!exportButton) throw new Error('The export button is missing.'); const documentFonts = { families: [ { family: 'Aptos', faces: [ { source: '/fonts/aptos-regular.woff2', weight: 400, style: 'normal' }, { source: '/fonts/aptos-bold.woff2', weight: 700, style: 'normal' }, ], }, { family: 'Aptos Display', faces: [{ source: '/fonts/aptos-display.woff2', weight: 400, style: 'normal' }], }, ], } satisfies NonNullable; const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', fonts: documentFonts, onReady: () => { exportButton.disabled = false; }, onContentError: ({ error }) => { console.error('SuperDoc could not open the document.', error); }, 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; } }); ``` **React — `src/App.tsx`** ```tsx import { useRef, useState } from 'react'; import { SuperDocEditor, type SuperDocConfig, type SuperDocRef } from '@superdoc/react'; import '@superdoc/react/style.css'; const documentFonts = { families: [ { family: 'Aptos', faces: [ { source: '/fonts/aptos-regular.woff2', weight: 400, style: 'normal' }, { source: '/fonts/aptos-bold.woff2', weight: 700, style: 'normal' }, ], }, { family: 'Aptos Display', faces: [{ source: '/fonts/aptos-display.woff2', weight: 400, style: 'normal' }], }, ], } satisfies NonNullable; export default function App() { const editorRef = useRef(null); const exportingRef = useRef(false); const [ready, setReady] = useState(false); const [exporting, setExporting] = useState(false); async function exportDocument() { if (exportingRef.current) return; exportingRef.current = true; setExporting(true); try { await editorRef.current?.getInstance()?.export({ exportType: ['docx'], exportedName: 'sample-edited' }); } catch (error) { console.error('SuperDoc could not export the document.', error); } finally { exportingRef.current = false; setExporting(false); } } return (
console.error('SuperDoc could not open the document.', error)} onException={({ error }) => console.error('SuperDoc could not open the document.', error)} onReady={() => setReady(true)} ref={editorRef} />
); } ``` The `Config['fonts']` type behind `documentFonts` checks the configuration while preserving its specific values. `family` matches the logical name stored in the DOCX. Because the names match, no mapping is needed. Each `source` identifies one physical face. Register regular, bold, italic, and bold italic when the document uses them; one face does not make the others available. The sample's bullet markers are set in `Symbol` by its numbering definition, so the report also lists a `Symbol` row. That family ships with most desktop systems and is not one you would normally host; if it reports `missing: true` on a target device, map it to a face you can provide rather than registering a licensed `Symbol` file. A font loaded by your application's `@font-face` CSS can also be available to the browser. Prefer `fonts.families` for document fonts so SuperDoc knows which source and face descriptors to load before it measures the document. SuperDoc registers these providers at startup and loads required faces before measuring when it can. A missing or blocked asset still allows the document to open, but the report identifies the fallback. ## Check what rendered [#check-what-rendered] Subscribe after the Editor is ready. `fonts.onReport()` immediately replays the current report when one exists, then reports changes such as a late font load. Save this helper as `src/font-report.ts`; it takes a SuperDoc instance, so both frameworks use the same file: ```ts import type { SuperDoc } from 'superdoc'; export function observeDocumentFonts(superdoc: SuperDoc) { return superdoc.fonts.onReport(({ report = [] }) => { for (const font of report) { console.log({ logicalFamily: font.logicalFamily, physicalFamily: font.physicalFamily, reason: font.reason, loadStatus: font.loadStatus, systemAvailability: font.systemAvailability, exportFamily: font.exportFamily, missing: font.missing, // Face-level rows repeat a family per weight/style; without this, a failed bold is // indistinguishable from the regular that loaded beside it. face: font.face, // A substitution can load cleanly and still reflow the document. `evidence.lineBreakSafe` // is the only field that separates a metric-safe substitute from a visual-only one. evidence: font.evidence, }); } }); } ``` Import it where you create the Editor and keep the returned function for teardown. These are additions to the complete files above, not replacements — the export button and error handlers they already set up stay as they are. Vanilla subscribes from `onReady` and unsubscribes on unload: ```ts // src/main.ts — additions to the file you already have. import { observeDocumentFonts } from './font-report'; let stopFontReport: (() => void) | undefined; // Inside the existing onReady, alongside `exportButton.disabled = false`: stopFontReport = observeDocumentFonts(superdoc); // The Quickstart has no teardown listener yet; add one: window.addEventListener('beforeunload', () => { stopFontReport?.(); }); ``` React subscribes from the same callback and releases the handle when the component unmounts: ```tsx // src/App.tsx — additions to the file you already have. import { useEffect } from 'react'; import { observeDocumentFonts } from './font-report'; const stopFontReport = useRef<(() => void) | undefined>(undefined); useEffect(() => () => stopFontReport.current?.(), []); // Inside the existing onReady, alongside `setReady(true)`: const instance = editorRef.current?.getInstance(); if (instance) stopFontReport.current = observeDocumentFonts(instance); ``` Use these fields to decide whether the document needs another font provider: | Field | What it tells you | | -------------------- | --------------------------------------------------------------------------------- | | `logicalFamily` | Font family requested by the DOCX. | | `physicalFamily` | Family SuperDoc resolved for measurement and paint. | | `reason` | Why that physical family was selected. | | `loadStatus` | State of a registered font asset. | | `systemAvailability` | Whether an unregistered system face is available. | | `exportFamily` | Font family that export preserves in the DOCX. | | `missing` | Whether SuperDoc has confirmed that the requested face lacks a faithful provider. | | `face` | Weight and style on face-level rows; absent on family-level rows. | Act on `missing: true`, not on a transient `loadStatus`. A system face with `systemAvailability: 'unknown'` is unresolved, but SuperDoc does not call it missing without evidence. When `reason` is `as_requested` and `missing` is `true`, `physicalFamily` remains the requested name. The browser chose a fallback, but does not report that fallback's family to SuperDoc. Use `superdoc.fonts.getReport()` when you only need the current snapshot. ## Map an intentional substitute [#map-an-intentional-substitute] Use a mapping when the font you can provide has a different family name from the one stored in the DOCX. This example substitutes Inter for both Aptos families, so place `inter-regular.woff2` and `inter-bold.woff2` in `public/fonts` alongside the Aptos files before continuing — the mapping resolves to a browser fallback if those assets are missing. Change only the `documentFonts` value in the file you already have: it registers Inter and maps both DOCX families onto it. Everything else — including the `observeDocumentFonts` wiring from the previous section — stays as it is, because the fidelity checklist below reads the report that wiring produces. The snippets show the whole file for context. **Vanilla — `src/main.ts`** ```ts import { SuperDoc, type Config } from 'superdoc'; import 'superdoc/style.css'; const exportButton = document.querySelector('#export-docx'); if (!exportButton) throw new Error('The export button is missing.'); const documentFonts = { families: [ { family: 'Inter', faces: [ { source: '/fonts/inter-regular.woff2', weight: 400, style: 'normal' }, { source: '/fonts/inter-bold.woff2', weight: 700, style: 'normal' }, ], }, ], map: { Aptos: 'Inter', 'Aptos Display': 'Inter', }, } satisfies NonNullable; const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', fonts: documentFonts, onReady: () => { exportButton.disabled = false; }, onContentError: ({ error }) => { console.error('SuperDoc could not open the document.', error); }, 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; } }); ``` **React — `src/App.tsx`** ```tsx import { useRef, useState } from 'react'; import { SuperDocEditor, type SuperDocConfig, type SuperDocRef } from '@superdoc/react'; import '@superdoc/react/style.css'; const documentFonts = { families: [ { family: 'Inter', faces: [ { source: '/fonts/inter-regular.woff2', weight: 400, style: 'normal' }, { source: '/fonts/inter-bold.woff2', weight: 700, style: 'normal' }, ], }, ], map: { Aptos: 'Inter', 'Aptos Display': 'Inter', }, } satisfies NonNullable; export default function App() { const editorRef = useRef(null); const exportingRef = useRef(false); const [ready, setReady] = useState(false); const [exporting, setExporting] = useState(false); async function exportDocument() { if (exportingRef.current) return; exportingRef.current = true; setExporting(true); try { await editorRef.current?.getInstance()?.export({ exportType: ['docx'], exportedName: 'sample-edited' }); } catch (error) { console.error('SuperDoc could not export the document.', error); } finally { exportingRef.current = false; setExporting(false); } } return (
console.error('SuperDoc could not open the document.', error)} onException={({ error }) => console.error('SuperDoc could not open the document.', error)} onReady={() => setReady(true)} ref={editorRef} />
); } ``` SuperDoc registers Inter, follows each `map` entry, and loads the required faces before initial measurement. The mapping changes measurement and paint for this document. It does not rename the fonts in the DOCX; export still preserves Aptos and Aptos Display. Use `superdoc.fonts.add()`, `superdoc.fonts.map()`, and `superdoc.fonts.preload()` instead when a user chooses a provider after the document opens. `preload()` accepts logical DOCX family names and follows the active mapping. An arbitrary substitute can change layout even when it loads successfully. Prefer the original font or a substitute whose metrics you have evaluated for the documents you support. ## Verify font fidelity [#verify-font-fidelity] * Confirm every report row has the expected `physicalFamily`, `missing: false`, **and** positive evidence that the face resolved: a registered face at `loadStatus: 'loaded'`, or a pass-through face at `systemAvailability: 'available'`. Both halves are needed. `missing: false` alone is not enough, because the report withholds `missing` while availability is `checking` or `unknown`; a loaded face alone is not enough either, because a non-metric `category_fallback` loads successfully and still reports `missing: true`. * Rows whose `reason` is `bundled_substitute` or `category_fallback` also carry `evidence`. Check `evidence.lineBreakSafe` on those: such a row can report `loadStatus: 'loaded'` and `missing: false` while the runtime marks it `verdict: 'visual_only'` with `lineBreakSafe: false` — Cooper Black resolving to Caprasimo is one. Those rows render, but their advances do not preserve line breaks, so treat them as a layout change to review rather than a passing fidelity check. Every other reason, including the `custom_mapping` this page's Inter mapping produces, leaves `evidence` undefined; verify those with the line and page break comparison below. * Exercise every weight and style your documents use; a loaded regular face does not verify bold or italic. * Compare line and page breaks in SuperDoc and Word after changing a provider or mapping. * Export and reopen the DOCX. The original logical family should remain selected. * For cross-origin assets, allow the font origin in CORS and Content Security Policy rules. Continue with [Track changes](/editor/track-changes) to let reviewers propose and decide edits without immediately changing the accepted document. --- # SuperDoc Editor > Understand the challenge of editing DOCX on the web and how SuperDoc approaches it with an OOXML-backed document model. SuperDoc is a document editor you embed in your web application. It lets people open, edit, and export DOCX files, using the document's underlying format as the foundation for editing. ## A document is more than its text [#a-document-is-more-than-its-text] A Word document can contain styles, page sections, headers, footers, comments, and tracked changes. Those details are stored in OOXML: the XML parts, relationships, and assets packaged inside a DOCX file. Bringing that document into a web editor is not just a matter of displaying its text. The editor must represent those details while people work, then write their changes back to a DOCX. When an editor converts DOCX into HTML or another editing format, it has to translate between two document models. Details that the editing model cannot represent may be changed or dropped when the DOCX is rebuilt for export. ## Edit the document, not an HTML conversion [#edit-the-document-not-an-html-conversion] SuperDoc reads the OOXML package into editable document state. The browser renders a view of that state; edits update it, and export writes the changes back into the OOXML package. The browser DOM is a view of the document, not the source used to reconstruct it. This avoids a DOCX-to-HTML-to-DOCX conversion step and keeps document structure at the center of editing. For the engine architecture, see [How SuperDoc works](/resources/how-superdoc-works). ## The Editor in your application [#the-editor-in-your-application] The Editor runs in the browser and needs no SuperDoc server to render or edit a document. It provides built-in controls for editing and review. You can configure those controls or build your own UI through the public APIs. People edit through the interface; application code can read and change the same open document through the Document API. Your application decides where documents come from, who can access them, and where changes are saved. ## What changed in v2? [#what-changed-in-v2] SuperDoc v1 used ProseMirror as its authoritative browser editing model. V2 uses OOXML-backed document state instead, with the same document engine and Document API contract across browser and supported headless workflows. This changes how application code interacts with the document: use the public Document API rather than ProseMirror state or commands. If you already use v1, start with [Migrate from v1](/editor/migrate-from-v1/overview). ## Open your first document [#open-your-first-document] [Follow the Quickstart](/editor/quickstart) to open a sample DOCX, make an edit, and download the result. SuperDoc is open source under AGPLv3. A commercial license is available for proprietary applications. See [Licensing](/resources/license) for the requirements of each option. --- # Configure the Editor license > Set the browser Editor license identity. Set the license key when you create the Editor. When telemetry is enabled, the browser Editor sends it with document-open events. It is client-visible configuration, not a secret or an authorization credential. ```ts const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', licenseKey: import.meta.env.VITE_SUPERDOC_LICENSE_KEY, }); ``` When telemetry is enabled and you omit the key, document-open events use the community and evaluation identity. Browser build variables remain visible to the person running the application. Keep document access, collaboration credentials, and service authorization separate from the license identity. This page covers Editor configuration. See [Licensing](/resources/license) for the open-source and commercial terms and the licensing contact. Continue with [Telemetry](/editor/telemetry) to make the Editor's network behavior explicit. --- # Handle lifecycle and events > Understand when the Editor is ready, what counts as an edit, and when to release it. Your application needs to know when the document is usable, when it changes, and when to release the Editor. Add those states to the [Quickstart](/editor/quickstart) before connecting storage or custom controls. ## Follow the lifecycle [#follow-the-lifecycle] Select a stage to see its signal and application state. This is a simulated preview, not a live Editor. > **Interactive model: the Editor lifecycle in your application** > > The preview moves `/sample.docx` through the application states that matter to a user. > > 1. **Mount — `new SuperDoc()`:** Show a loading state. Keep document actions disabled while the DOCX opens. > 2. **Ready — `onReady`:** Enable document actions. The document is available. Enable Export and run document queries. > 3. **Edit — `onEditorUpdate`:** Mark the document unsaved. Mark changes to document content as unsaved. Moving the cursor does not count as an edit. > 4. **Export — `export()`:** Download a copy. Export downloads a DOCX. Changes remain unsaved in your application until your backend stores them. > 5. **Unmount — `destroy()`:** Release the Editor. Call destroy() when the route or component that owns the Editor unmounts. > > **Load fails — `onContentError / onException`:** Show a useful error. Keep document actions disabled. Show a retry path instead of an empty mount point. ## Connect the signals [#connect-the-signals] In the Vanilla Quickstart, replace the export button and mount point with this markup. Keep the script tag: ```html Opening…
``` Replace `src/main.ts` with the example below. Keep the styles and `/sample.docx`; no backend is needed. Copy any user or mode options you chose on the previous pages into this configuration. ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; function requireElement(selector: string) { const element = document.querySelector(selector); if (!element) throw new Error(`${selector} not found.`); return element; } const status = requireElement('#editor-status'); const exportButton = requireElement('#export-docx'); let isReady = false; let hasUnsavedChanges = false; let isExporting = false; let isUnmounted = false; function showLoadError(error: unknown) { console.error('SuperDoc error', error); if (isReady || isUnmounted) return; status.value = 'Could not open the document'; exportButton.disabled = true; } const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', onReady: () => { if (isUnmounted) return; isReady = true; status.value = 'Ready'; exportButton.disabled = isExporting; }, onEditorUpdate: () => { if (isUnmounted) return; hasUnsavedChanges = true; status.value = 'Unsaved changes'; }, onContentError: ({ error }) => showLoadError(error), onException: ({ error }) => showLoadError(error), }); async function exportDocument(): Promise { if (!isReady || isExporting || isUnmounted) return; isExporting = true; exportButton.disabled = true; try { await superdoc.export({ exportedName: 'sample-edited' }); if (!isUnmounted) status.value = hasUnsavedChanges ? 'Unsaved changes' : 'Ready'; } catch (error) { if (!isUnmounted) status.value = 'Export failed. Try again.'; console.error('Could not export the document.', error); } finally { isExporting = false; if (!isUnmounted) exportButton.disabled = !isReady; } } exportButton.addEventListener('click', exportDocument); export function unmountEditor(): void { isUnmounted = true; exportButton.disabled = true; exportButton.removeEventListener('click', exportDocument); superdoc.destroy(); } ``` `onReady` means the document is available for queries and export. `onEditorUpdate` reports document edits, not cursor movement or pagination. Downloading a copy does not save changes to your application, so it does not clear the **Unsaved changes** status. ## Release what you own [#release-what-you-own] Call `unmountEditor()` when the owning route or component unmounts. It removes the button listener and calls `destroy()`. The React Quickstart's `SuperDocEditor` component handles its own Editor cleanup. For a temporary panel, use `on()` and `off()` with the same function. This fragment assumes the ready `superdoc` instance from the example above: ```ts import type { SuperDocZoomPayload } from 'superdoc'; const onZoom = ({ zoom }: SuperDocZoomPayload): void => { console.log('Zoom:', zoom); }; superdoc.on('zoomChange', onZoom); // When the panel closes: superdoc.off('zoomChange', onZoom); ``` ## Check the flow [#check-the-flow] Reload the page. **Export DOCX** should stay disabled until the document opens. Edit the effective date, then export. The browser should download `sample-edited.docx`, while the status remains **Unsaved changes**. Temporarily change `/sample.docx` to a missing URL. The page should show a load error and keep **Export DOCX** disabled. ## Go deeper [#go-deeper] * [Load and save documents](/editor/load-and-save-documents) connects the Editor to your backend storage. * [Configure the Editor](/editor/configuration) lists all startup callbacks and their payload types. ## Export recent Interaction History [#export-recent-interaction-history] The browser Editor keeps a bounded **Interaction History** in memory by default. When an edit looks wrong, your application can request a snapshot without waiting for an exception: ```ts const history = superdoc.diagnostics.getSnapshot(); const report = JSON.stringify(history); // Let the customer review and share the report through your bug-report flow. superdoc.diagnostics.clear(); ``` The history records recent input attempts, commands, mutation receipts, selection changes, render progress, and lifecycle failures. Collaboration sessions also record remote changes observed by this browser. A snapshot is detached from the running recorder; capturing it does not query the worker or document. You can also request one inside `onException`. Configure retention when creating the Editor: ```ts const superdoc = new SuperDoc({ selector: '#editor', diagnostics: { history: { enabled: true, maxEvents: 500, maxBytes: 1_048_576, captureContent: false, }, }, }); ``` These are the defaults. The oldest events are removed when either limit is reached. `maxEvents` accepts 1–10,000; `maxBytes` accepts 1–16,777,216 and measures retained serialized UTF-16 payloads, excluding object overhead. Invalid limits use the defaults. Individual events and nested payloads are also capped; snapshots report eviction, truncation, and capture-failure counts. Set `enabled: false` to disable capture. By default, the recorder selects metadata fields and excludes document text and error messages. `captureContent: true` allows bounded text carried by observed events and API calls; treat those reports as document content. Metadata can include document and transaction identifiers, so review reports before sharing. This recorder does not upload or persist reports. Capture stops when the instance is destroyed; the history belongs to that instance and does not survive a page reload. Capture failures are isolated from editor operations. History is diagnostic evidence, not a complete replay or audit log. Selection events may be coalesced, older events may be evicted, and each collaboration peer records its own observation order. --- # Load and save a DOCX > Load a DOCX from your application and save the edited file back to your backend. The [Quickstart](/editor/quickstart) downloads your edits. To keep them when someone returns, load and save the DOCX through your application API: 1. Fetch the DOCX from your API as a `Blob`. 2. Open and edit the `Blob` in SuperDoc. 3. Export the edited DOCX as a new `Blob` and send it back to your API. SuperDoc handles the DOCX in the browser. Your application owns the API and storage. ## 1. Add a document endpoint [#1-add-a-document-endpoint] The examples use `/api/documents/sample`. This is an application route, not a SuperDoc route. | Request | Your endpoint must | | ------- | --------------------------------------------------------------------------- | | `GET` | Return the current DOCX bytes | | `PUT` | Store the request body and return a success status after the write finishes | The Quickstart does not create this endpoint. Add it to your backend with the sample DOCX as its initial document. Proxy `/api/documents/sample` from Vite to your backend, or change `endpoint` below to your API URL. For a different origin, allow GET and PUT through CORS. Keep storage credentials and access checks in the backend. Restart Vite after adding the proxy. Check that the GET response contains DOCX bytes, not your application's HTML page: a fallback page can return `200` and still fail to open as a document. ## 2. Add a save action [#2-add-a-save-action] For Vanilla, replace the export button and Editor container in `index.html` with the following markup. Keep the `` tag: ```html Opening…
``` The React example below renders its own controls. ## 3. Load and save the document [#3-load-and-save-the-document] Replace `src/main.ts` in Vanilla or `src/App.tsx` in React. Keep the Quickstart styles and React entry point. Copy any user, mode, or viewing options you chose on the previous pages into this Editor configuration; the examples otherwise use the default editing mode. **Vanilla — `src/main.ts`** ```ts import { DOCX, SuperDoc } from 'superdoc'; import 'superdoc/style.css'; function requireElement(selector: string) { const element = document.querySelector(selector); if (!element) throw new Error(`${selector} not found.`); return element; } const saveButton = requireElement('#save-docx'); const status = requireElement('#document-status'); const endpoint = '/api/documents/sample'; let isReady = false; let editRevision = 0; let superdoc: SuperDoc | undefined; function showOpenError(error: unknown) { console.error('Could not open the document.', error); if (isReady) return; status.value = 'Could not open the document. Reload to try again.'; saveButton.disabled = true; } try { const response = await fetch(endpoint); if (!response.ok) throw new Error(`Could not load the document: ${response.status}`); const docx = new Blob([await response.arrayBuffer()], { type: DOCX }); superdoc = new SuperDoc({ selector: '#editor', document: docx, onReady: () => { isReady = true; status.value = 'Ready'; saveButton.disabled = false; }, onEditorUpdate: () => { editRevision += 1; status.value = 'Unsaved changes'; }, onContentError: ({ error }) => showOpenError(error), onException: ({ error }) => showOpenError(error), }); } catch (error) { showOpenError(error); } saveButton.addEventListener('click', async () => { if (!superdoc || !isReady) return; const savedRevision = editRevision; saveButton.disabled = true; status.value = 'Saving…'; try { const editedDocx = await superdoc.export({ exportType: ['docx'], triggerDownload: false, }); if (!(editedDocx instanceof Blob)) throw new Error('Expected one DOCX file.'); const saveResponse = await fetch(endpoint, { method: 'PUT', headers: { 'content-type': DOCX }, body: editedDocx, }); if (!saveResponse.ok) throw new Error(`Could not save the document: ${saveResponse.status}`); status.value = editRevision === savedRevision ? 'Saved' : 'Unsaved changes'; } catch (error) { status.value = 'Save failed. Try again.'; console.error('Could not confirm the document was saved.', error); } finally { saveButton.disabled = false; } }); window.addEventListener('beforeunload', () => superdoc?.destroy()); ``` **React — `src/App.tsx`** ```tsx import { useEffect, useRef, useState } from 'react'; import { SuperDocEditor, type SuperDocRef } from '@superdoc/react'; import '@superdoc/react/style.css'; const endpoint = '/api/documents/sample'; const docxType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; export default function App() { const editorRef = useRef(null); const editRevisionRef = useRef(0); const savingRef = useRef(false); const [document, setDocument] = useState(); const [loadError, setLoadError] = useState(false); const [ready, setReady] = useState(false); const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState(''); useEffect(() => { const controller = new AbortController(); void fetch(endpoint, { signal: controller.signal }) .then(async (response) => { if (!response.ok) throw new Error(`Could not load the document: ${response.status}`); setDocument(new Blob([await response.arrayBuffer()], { type: docxType })); }) .catch((error: unknown) => { if (!controller.signal.aborted) { setLoadError(true); console.error(error); } }); return () => controller.abort(); }, []); function showOpenError({ error }: { error: unknown }) { console.error('Could not open the document.', error); setReady(false); setLoadError(true); } async function saveDocument() { if (savingRef.current) return; const superdoc = editorRef.current?.getInstance(); if (!superdoc) return; const savedRevision = editRevisionRef.current; savingRef.current = true; setSaving(true); setSaveStatus('Saving…'); try { const editedDocx = await superdoc.export({ exportType: ['docx'], triggerDownload: false, }); if (!(editedDocx instanceof Blob)) throw new Error('Expected one DOCX file.'); const response = await fetch(endpoint, { method: 'PUT', headers: { 'content-type': docxType }, body: editedDocx, }); if (!response.ok) throw new Error(`Could not save the document: ${response.status}`); setSaveStatus(editRevisionRef.current === savedRevision ? 'Saved' : 'Unsaved changes'); } catch (error) { setSaveStatus('Save failed. Try again.'); console.error('Could not confirm the document was saved.', error); } finally { savingRef.current = false; setSaving(false); } } if (loadError) return

Could not open the document. Reload to try again.

; if (!document) return

Opening document…

; return ( <> {saveStatus} { editRevisionRef.current += 1; setSaveStatus('Unsaved changes'); }} onException={showOpenError} onReady={() => setReady(true)} ref={editorRef} /> ); } ``` Both examples: * fetch the DOCX before mounting the Editor; * enable saving after `onReady`; * call `export({ triggerDownload: false })` to receive the edited `Blob`; * show **Saved** only after the `PUT` succeeds. Edits made during that request remain unsaved. The revision counter tracks local edits only. This is manual saving, not autosave or protection against another user overwriting the file. ## Use another source or destination [#use-another-source-or-destination] | Task | Use | | ------------------------------ | ---------------------------------------------------------- | | Open a public or signed URL | `document: url` | | Open a file selected by a user | `document: file` | | Switch the mounted Editor | `await superdoc.replaceFile(nextDocx)` | | Download instead of saving | `await superdoc.export({ exportedName: 'sample-edited' })` | Fetch the file yourself and pass a `Blob` when the request needs custom headers. ### Preview: check a document replacement [#preview-check-a-document-replacement] `replaceDocument()` is a source-preview API, not part of the published Quickstart release yet. It keeps the mounted Editor and reports whether the requested document opened. `replaceFile()` remains available with its existing raw return value. After fetching the next DOCX as a `Blob`, use the ready `superdoc` instance from your application: ```ts async function switchDocument(nextDocx: Blob): Promise { const result = await superdoc.replaceDocument(nextDocx); if (!result.ok) { throw new Error(result.detail ?? result.reason ?? 'Could not open the next document.'); } } ``` Save or discard current edits before switching. Disable document actions while awaiting this function and handle rejections in your application's error UI. Update the active document name only after it succeeds. Do not use `onReady` to decide whether a replacement succeeded: recovery can make the previous document ready again. A failed replacement does not guarantee that the previous document survived. Custom controls can call `superdoc.ui.document.replaceDocument(nextDocx)` for the same typed result. ## Verify the round trip [#verify-the-round-trip] Change the effective date from `September 1, 2026` to `October 1, 2026`. Select **Save DOCX**, then reload the page. The new date should still be present. Then make your endpoint reject a save before writing. Confirm **Save failed** appears and your edits remain available to retry. A network failure can happen after the server stores the file, so an error alone does not prove nothing was saved. ## Choose your interface [#choose-your-interface] [Choose your interface](/editor/who-renders-the-ui) to keep SuperDoc's built-in controls or replace the ones your application needs to own. Loading and saving stay the same. For more storage options, see [Version history](/editor/version-history) and [Export options](/editor/export-options). --- # Tune performance for large documents > Keep page rendering responsive, choose a layout, and measure representative DOCX workflows. SuperDoc virtualizes paginated documents by default. Keep that baseline until measurements from representative DOCX files show a specific scrolling, memory, loading, or worker-startup problem. ## Tune the page window deliberately [#tune-the-page-window-deliberately] The default paginated window is five pages with one overscan page. Set it explicitly only when comparing a measured alternative: ```ts const superdoc = new SuperDoc({ selector: '#editor', document: '/large-contract.docx', layoutEngineOptions: { virtualization: { enabled: true, window: 5, overscan: 1, }, }, onPaginationUpdate: ({ totalPages }) => { pageCount.textContent = String(totalPages); }, }); ``` A larger window can reduce repaints during fast scrolling while keeping more pages mounted. A smaller window reduces mounted work but can make navigation less smooth. Change one value at a time and measure on the devices and documents the product supports. ## Choose print or web layout [#choose-print-or-web-layout] Paginated print layout preserves page boundaries and uses page virtualization. Web layout reflows semantic content to the container and does not provide headers, footers, rulers, or page-count updates. Use `viewOptions: { layout: 'web' }` with `layoutEngineOptions: { flowMode: 'semantic' }` for the continuous surface. [Build a responsive Editor layout](/editor/built-in-ui/responsive-layout) compares the two layouts and their host sizing requirements. ## Control worker startup [#control-worker-startup] Keep the default 30-second `workerStartupTimeoutMs` unless measurements show that the worker bundle needs more time to download or evaluate. Use `workerUrls` when the application must serve the document, collaboration, and review-index module workers from explicit same-origin URLs. Worker load errors fail immediately; increasing the timeout does not repair a missing file, blocked Content Security Policy, or invalid worker response. See [Secure integration](/editor/secure-integration) for the browser boundary. ## Measure the whole task [#measure-the-whole-task] Test opening, first interaction, scrolling, editing, collaboration, and export with representative DOCX files. Record document size and page count with the result so a regression can be reproduced. > **Verification target (success)** > > On the slowest supported device, open a representative large DOCX, scroll from start to end, edit text on distant > pages, and export. No action should lose document state or leave controls permanently busy. --- # Open and edit your first DOCX > Install the browser Editor, open a sample DOCX, and export your first edit. Open a sample document, change its effective date, and download the edited DOCX. This example runs in the browser without a backend. ## 1. Create a project [#1-create-a-project] With Node.js 22.12 or newer and pnpm installed, create a Vite project and install SuperDoc: Choose Vanilla or React. Use the same framework for the remaining steps. **Vanilla — `Terminal`** ```sh pnpm create vite@latest superdoc-quickstart --template vanilla-ts cd superdoc-quickstart pnpm add superdoc ``` **React — `Terminal`** ```sh pnpm create vite@latest superdoc-quickstart --template react-ts cd superdoc-quickstart pnpm add @superdoc/react ``` ## 2. Add the sample document [#2-add-the-sample-document] Download the sample into your project's `public` directory and rename it to `sample.docx`: [Download the sample document](/fixtures/getting-started.docx): One-page statement of work · DOCX Vite serves `public/sample.docx` at `/sample.docx`. ## 3. Prepare the page [#3-prepare-the-page] Replace the generated files below. Vanilla mounts SuperDoc into `#editor`; the React wrapper creates that container for you. **Vanilla — `index.html`** ```html SuperDoc vanilla quickstart
``` **React — `src/main.tsx`** ```tsx import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; import './index.css'; const root = document.querySelector('#root'); if (!root) throw new Error('The React root is missing.'); createRoot(root).render( , ); ``` For React, also replace `src/index.css` so Vite's starter layout does not constrain the Editor: ```css html, body, #root { min-height: 100%; margin: 0; } ``` ## 4. Open the document [#4-open-the-document] Replace `src/main.ts` in Vanilla or `src/App.tsx` in React with the matching code below. It opens the document and enables export when the Editor is ready: **Vanilla — `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; }, onContentError: ({ error }) => { console.error('SuperDoc could not open the document.', error); }, 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; } }); ``` **React — `src/App.tsx`** ```tsx import { useRef, useState } from 'react'; import { SuperDocEditor, type SuperDocRef } from '@superdoc/react'; import '@superdoc/react/style.css'; function reportDocumentError({ error }: { error: unknown }) { console.error('SuperDoc could not open the document.', error); } export default function App() { const editorRef = useRef(null); const exportingRef = useRef(false); const [ready, setReady] = useState(false); const [exporting, setExporting] = useState(false); async function exportDocument() { if (exportingRef.current) return; exportingRef.current = true; setExporting(true); try { await editorRef.current?.getInstance()?.export({ exportType: ['docx'], exportedName: 'sample-edited' }); } catch (error) { console.error('SuperDoc could not export the document.', error); } finally { exportingRef.current = false; setExporting(false); } } return (
setReady(true)} ref={editorRef} />
); } ``` Run the project: ```bash pnpm dev ``` Open the local URL printed in the terminal. You should see the statement of work. `onReady` enables **Export DOCX** after the document opens, so the button cannot export before the Editor is ready. If the document does not appear, check that `/sample.docx` opens at the same local address. The example also logs document-opening errors to the browser console through `onContentError` and `onException`. ## 5. Make an edit and export [#5-make-an-edit-and-export] Change the effective date from `September 1, 2026` to `October 1, 2026`. Then select **Export DOCX**. The browser downloads `sample-edited.docx`. Open `sample-edited.docx` in Word or another DOCX editor. Confirm that the effective date is `October 1, 2026` and that the title, service list, milestone table, and signatures keep their formatting. `export()` downloads a new file. It does not overwrite `public/sample.docx` or save changes to a server. Reloading this example opens the original sample again. The complete projects are available for [Vanilla](https://go.superdoc.dev/examples/vanilla) and [React](https://go.superdoc.dev/examples/react). ## Continue to configuration [#continue-to-configuration] [Configure the Editor](/editor/configuration) to add user information and choose a document mode. Keep this project, including its export button and `/sample.docx`, for the next guide. --- # Control a document review workflow > Separate proposing edits, deciding changes, and saving the reviewed document. Start with the [tracked-changes example](/editor/track-changes). Once people can propose and decide edits, your application needs to decide which actions each person should have and when the result is saved. ## Separate the three responsibilities [#separate-the-three-responsibilities] | Task | Editor behavior | Application responsibility | | ---------------- | -------------------------------------------- | ----------------------------------------- | | Propose an edit | `suggesting` records changes with an author. | Supply the current user's identity. | | Accept or reject | Review controls decide existing proposals. | Choose who may use those controls. | | Save the result | Export produces the current DOCX. | Authorize and persist the exported bytes. | These tasks do not require collaboration or a custom UI. A person can export a proposal for another reviewer to open later. Use [collaboration](/editor/collaboration) when they should work in the same shared document. ## Let a contributor propose without deciding [#let-a-contributor-propose-without-deciding] Keep the Quickstart project and replace `src/review-options.ts` from the Track changes guide with: ```ts import type { Config } from 'superdoc'; export const reviewOptions = { documentMode: 'suggesting', user: { name: 'Jordan Lee', email: 'jordan@example.com' }, interaction: { trackedChanges: { allowDecisions: false }, }, } satisfies Pick; ``` Keep spreading these options into the existing Vanilla configuration or React component. Make an edit: it should become a proposal, but accept and reject should be unavailable. Comment actions have their own configuration. For a reviewer who may decide changes, use `allowDecisions: true` when opening the document. Keep `suggesting` if the reviewer's own edits should also become proposals. Switching to `editing` would make new edits direct; it does not accept the proposals already present. Your application chooses these settings from its verified permissions. Browser controls are not an authorization boundary: protect document access and saving on your backend. A disabled button can also mean no change is selected or the document is read-only. ## Report partial bulk decisions [#report-partial-bulk-decisions] Accept All and Reject All can leave proposals undecided when `permissionResolver` allows only some decisions. Use `onTrackedChangesBulkDecision` to report what happened rather than announcing that every proposal was decided. For Vanilla, add `` to the page and create `src/report-review-decisions.ts`: ```ts import type { Config } from 'superdoc'; export const reportReviewDecisions: NonNullable = (result) => { const status = document.querySelector('#review-status'); if (!status) return; const action = result.decision === 'accept' ? 'Accepted' : 'Rejected'; const remaining = result.permissionDeniedCount > 0 ? ` ${result.permissionDeniedCount} left undecided.` : ''; status.value = `${action} ${result.successfulCount} changes.${remaining}`; }; ``` Import `reportReviewDecisions` into `src/main.ts` and set `onTrackedChangesBulkDecision: reportReviewDecisions` in the existing configuration. In React, use the same callback payload to update status state. The payload contains `documentId`, `decision`, `requestedCount`, `successfulCount`, and `permissionDeniedCount`. `requestedCount` equals `successfulCount + permissionDeniedCount`. These counts describe permission-filtered decisions, not a save confirmation. The same payload is available through the `tracked-changes:bulk-decision` event. ## Hand off the reviewed document [#hand-off-the-reviewed-document] Decide the intended proposals, then export through your existing [load and save flow](/editor/load-and-save-documents). If your workflow requires all proposals to be decided, inspect the remaining changes before calling the document final. Otherwise, tell the next reviewer that undecided proposals remain. Reopen the exported file and check that accepted text remains, rejected edits are gone, and any undecided proposals are still available. A clean-looking `final` view only hides markup; it does not finish the review. Report “Saved” only after your storage flow confirms the write. Use [Document API tracked changes](/document-api/tracked-changes) to inspect or decide proposals in code. Use the [custom review panel](/editor/custom-ui/tracked-changes) when your application needs to own the review queue and controls. --- # 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] Check [Telemetry](/editor/telemetry) separately: it is enabled by default, and its destination and metadata are configuration decisions. The [Editor license](/editor/license) is client-visible identity, not an access credential. Use HTTPS, restrictive CORS, and a Content Security Policy that permits only the scripts, workers, connections, images, and fonts your deployment needs. Avoid unversioned third-party runtime dependencies, and decide whether Editor assets are self-hosted before production. ## Pass a nonce to runtime styles [#pass-a-nonce-to-runtime-styles] Leave `cspNonce` unset unless the page's CSP requires a nonce for ` ``` ## Fit to the container [#fit-to-the-container] Replace `src/main.ts` to fit the document to its container and refit after fullscreen changes: ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const shell = document.querySelector('#editor-shell'); const fullscreen = document.querySelector('#fullscreen'); const status = document.querySelector('#layout-status'); if (!shell || !fullscreen || !status) throw new Error('The responsive editor shell is incomplete.'); let ready = false; const showError = ({ error }: { error: unknown }) => { console.error('Could not open the document.', error); status.textContent = 'Could not open the document. Reload to retry.'; }; const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', contained: true, onContentError: showError, onException: showError, onReady: () => { ready = true; fullscreen.disabled = !document.fullscreenEnabled; status.textContent = document.fullscreenEnabled ? '' : 'Fullscreen is unavailable in this browser.'; }, zoom: { mode: 'fit-width', fitWidth: { min: 40, max: 100, padding: 24 }, }, ui: { toolbar: { container: '#toolbar', responsiveTo: 'container', }, comments: { layout: 'auto' }, }, }); const toggleFullscreen = async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await shell.requestFullscreen(); status.textContent = ''; } catch (error) { console.error('Could not change fullscreen mode.', error); status.textContent = 'Could not change fullscreen mode. You can keep editing here.'; } }; const refit = () => { fullscreen.textContent = document.fullscreenElement === shell ? 'Exit fullscreen' : 'Fullscreen'; if (ready) 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. In `superdoc@2.12.0`, automatic fitting can shrink a DOCX to the minimum zoom instead of filling its container. If you encounter this, use manual zoom (`zoom: { mode: 'manual' }`) and let readers adjust it. Remove the `setZoomMode('fit-width')` call from `refit()` too. Manual zoom does not refit when the container changes size. `responsiveTo: 'container'` measures the toolbar mount instead of the browser window. Lower-priority controls move into the overflow menu when space becomes tight. `comments.layout: 'auto'` lets the review UI move between the sidebar and inline threads. Auto layout measures the nearest Editor ancestor with a width and derives when to switch. Override that behavior only when another element defines the available space or your application needs a fixed breakpoint: ```ts const config = { ui: { comments: { layout: 'auto', responsive: { target: '#editor-shell', breakpoint: 1200, }, }, }, }; ``` `target` accepts a CSS selector or an `HTMLElement`. Below `breakpoint`, measured in CSS pixels, comments render inline. ## Reflow document content [#reflow-document-content] Fit-to-width preserves print pages and scales them. To remove visible page boundaries and rewrap DOCX content when the Editor container changes width, select web layout instead: ```ts const viewOptions = { layout: 'web' } as const; ``` Define this before the constructor and add `viewOptions` to its configuration, then narrow the window. Text wraps to the host width instead of shrinking the printed page. No layout-engine option is required. Web layout does not show headers, footers, the ruler, or page-count updates. Keep print layout when those matter. 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. ## Check the layout [#check-the-layout] Narrow the window: print pages should shrink, toolbar controls should move into overflow, and the fullscreen button should stay visible as you scroll. Enter and exit fullscreen to check that the document fits again. If the browser rejects the request, the status explains the failure without blocking editing. Continue with [Loading UI](/editor/built-in-ui/loading) to choose what people see while a document opens. --- # Show the ruler > Show the horizontal ruler and let people adjust a section's left and right page margins. ## Try the ruler [#try-the-ruler] Expand the Editor and click in the document to activate its section. Drag either margin handle to reflow the page, then switch between inches and centimeters without changing the margins. > **Interactive editor: Adjust page margins** > > Sample: [open the fixture](/fixtures/ruler-sample.docx). > > Preset: `ruler`. > > Ruler controls available in the interactive Editor: > > - **Ruler — `ui.ruler`:** show or hide the horizontal ruler. Click in the document to activate a section, then drag its handles to change the left and right page margins. > - **Measurements — `measurementUnit`:** display Editor measurements in inches or centimeters. Switching units does not change the margins. > > Local DOCX selection: disabled. The ruler follows the active section. In Viewing mode it remains visible but read-only. Web layout hides it. ## Show the ruler [#show-the-ruler] These standalone examples use `public/sample.docx` from the [Quickstart](/editor/quickstart). Replace `src/main.ts` or `src/App.tsx` to enable the ruler: **Vanilla — `src/main.ts`** ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', ui: { ruler: true, }, measurementUnit: 'in', }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` **React — `src/App.tsx`** ```tsx import { SuperDocEditor, type SuperDocEditorProps } from '@superdoc/react'; import '@superdoc/react/style.css'; const editorConfig = { ui: { ruler: true, }, measurementUnit: 'in', } satisfies Pick; export default function App() { return ; } ``` For Vanilla, replace the contents of `` in `index.html` with: ```html
``` `ui.ruler` controls the built-in surface. Show or hide it later with `toggleRuler()`. `measurementUnit` is an Editor-wide preference, so it stays at the top level rather than inside `ui.ruler`. Set the initial unit to inches or centimeters, then change it with `setMeasurementUnit()`. ## Configure the ruler [#configure-the-ruler] Choose a field to see its type, default, and a configuration fragment you can copy. ### Ruler | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `ui.ruler` | `false \| true \| { container?: string \| HTMLElement; }` | `false` | Optional | Show the horizontal ruler, or provide a container for an external mount. | Built-in ruler. Disabled by default. | — | ### Measurements | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `measurementUnit` | `"in" \| "cm"` | `'in'` | Optional | Display measurements across this Editor in inches or centimeters. | Starting measurement unit for rulers and measurement fields (Word's "measurement units" preference). Defaults to `'in'` (Word's en-US default). Change it at runtime with `setMeasurementUnit()`. See `SuperDocMeasurementUnit`. | — | ### Events | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `onPageMarginsChange` | `(params: { documentId: string; editorVersion: 2; sectionId: string; sectionIndex: number; side: "left" \| "right"; value: number; pageMargins: { top?: number; right?: number; bottom?: number; left?: number; }; }) => void` | — | Optional | Run application code after a ruler drag changes a section margin. | Callback after a ruler drag changes the active section's left or right page margin. | — | `onPageMarginsChange` reports the changed side and the section's current margins in inches. Use the [Document API sections reference](/document-api/reference/sections/) for other page setup changes. Continue with [Responsive layout](/editor/built-in-ui/responsive-layout) to fit the Editor to its container and adapt its chrome when space becomes tight. --- # Search and replace document text > Enable document-aware search, move through matches, and choose which search controls SuperDoc renders. Use the built-in search surface to find text across the open document without leaving the Editor. ## Try search and replace [#try-search-and-replace] Expand the Editor and open Search from the toolbar. The sample has three short pages built for these checks: 1. Search for `Client` and confirm that there are eight matches. 2. Move through the results with Previous and Next, and watch the Editor scroll between pages. 3. Turn on Match case and confirm that the count changes to seven. 4. Turn Match case off. Expand the replacement row with the arrow beside Find, enter `Customer`, then choose **Replace**. 5. Search for `Legacy` with **Tracked deletions** set to **Exclude** and confirm that there are no matches. 6. Set **Tracked deletions** to **Include**, reopen Search, and search for `Legacy` again. The pending deletion is the only match. > **Interactive editor: Try search and replace** > > Sample: [open the fixture](/fixtures/search-sample.docx). > > Preset: `search`. > > Search configurations available in the interactive Editor: > > - **Mode — `documentMode`:** Editing allows replacement. Search remains available in Viewing, but replace controls are hidden. > - **Replace controls — `ui.search.replaceControls`:** choose Show or Hide. Hide removes the built-in replacement row in every document mode. > - **Tracked deletions — `ui.search.includeTrackedDeletions`:** choose Exclude or Include. The sample has one pending deletion containing `Legacy`. > > The three-page fixture has eight case-insensitive `Client` matches and seven case-sensitive matches. `Legacy` has zero matches when tracked deletions are excluded and one when they are included. Moving between results scrolls the Editor to each match. Changing a Search startup option recreates the Editor from its current DOCX. Document edits and document mode remain; the active search resets. > > Local DOCX selection: disabled. Set **Replace controls** to **Hide**. Search remains available, but the replacement row disappears. The demo reloads the current document when you change either option because they configure Search at startup. Document edits remain, while the active search resets. Switch to Viewing after the search. The matches and navigation controls remain available, while the replace controls disappear because Viewing cannot change the document. ## Enable built-in search [#enable-built-in-search] These standalone examples use `public/sample.docx` from the [Quickstart](/editor/quickstart). Replace `src/main.ts` or `src/App.tsx` to enable the search surface: **Vanilla — `src/main.ts`** ```ts import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', ui: { toolbar: { container: '#toolbar' }, search: true, }, }); window.addEventListener('beforeunload', () => superdoc.destroy()); ``` **React — `src/App.tsx`** ```tsx import { SuperDocEditor, type SuperDocEditorProps } from '@superdoc/react'; import '@superdoc/react/style.css'; const editorConfig = { ui: { search: true, }, } satisfies Pick; export default function App() { return ; } ``` For Vanilla, replace the contents of `` in `index.html` with the toolbar and Editor mounts below: ```html
``` `ui: { search: true }` connects the toolbar Search button and `Ctrl+F` or `Command+F` to the same document search. Without it, the browser keeps its native page search shortcut. Open Search with both methods. Search for `Client` and confirm that each method shows the same match count and active result. ## Configure the search surface [#configure-the-search-surface] Choose a group, then choose a field. Each entry shows its type, default, and a configuration fragment you can copy. ### Behavior | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `replaceControls` | `boolean` | `true` | Optional | Show replace controls (default: true). This changes the built-in UI only; it does not authorize or disable `superdoc.ui.search.replace()`. | Show replace controls (default: true). This changes the built-in UI only; it does not authorize or disable `superdoc.ui.search.replace()`. | — | | `includeTrackedDeletions` | `boolean` | `false` | Optional | Include text from pending tracked deletions in each search (default: false). | Include text from pending tracked deletions in each search (default: false). | — | ### Position & size | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `floating.placement` | `"top-right" \| "top-left" \| "bottom-right" \| "bottom-left" \| "top-center" \| "bottom-center"` | `'top-right'` | Optional | Position preset (default: `'top-right'`). Explicit insets override it. | Position preset (default: `'top-right'`). Explicit insets override it. | — | | `floating.top` | `string \| number` | — | Optional | Top inset in pixels or as a CSS length. | Top inset in pixels or as a CSS length. | — | | `floating.right` | `string \| number` | — | Optional | Right inset in pixels or as a CSS length. | Right inset in pixels or as a CSS length. | — | | `floating.bottom` | `string \| number` | — | Optional | Bottom inset in pixels or as a CSS length. | Bottom inset in pixels or as a CSS length. | — | | `floating.left` | `string \| number` | — | Optional | Left inset in pixels or as a CSS length. | Left inset in pixels or as a CSS length. | — | | `floating.width` | `string \| number` | `420` | Optional | Surface width in pixels or as a CSS length. | Surface width in pixels or as a CSS length. | — | | `floating.maxWidth` | `string \| number` | — | Optional | Maximum surface width in pixels or as a CSS length. | Maximum surface width in pixels or as a CSS length. | — | | `floating.maxHeight` | `string \| number` | — | Optional | Maximum surface height in pixels or as a CSS length. | Maximum surface height in pixels or as a CSS length. | — | ### Focus | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `floating.autoFocus` | `boolean` | `true` | Optional | Focus the find input when the surface opens (default: true). | Focus the find input when the surface opens (default: true). | — | | `floating.closeOnOutsidePointerDown` | `boolean` | `false` | Optional | Close the surface when a pointer press occurs outside it (default: false). | Close the surface when a pointer press occurs outside it (default: false). | — | ### Text | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `strings.findPlaceholder` | `string` | `'Find'` | Optional | Input placeholder for the find field. | Input placeholder for the find field. | — | | `strings.replacePlaceholder` | `string` | `'Replace'` | Optional | Input placeholder for the replace field. | Input placeholder for the replace field. | — | | `strings.noResults` | `string` | `'No results'` | Optional | Text shown when there are no matches. | Text shown when there are no matches. | — | | `strings.previousMatchTitle` | `string` | `'Previous match (Shift+Enter)'` | Optional | Tooltip for the previous-match button. | Tooltip for the previous-match button. | — | | `strings.nextMatchTitle` | `string` | `'Next match (Enter)'` | Optional | Tooltip for the next-match button. | Tooltip for the next-match button. | — | | `strings.closeTitle` | `string` | `'Close (Escape)'` | Optional | Tooltip for the close button. | Tooltip for the close button. | — | | `strings.replace` | `string` | `'Replace'` | Optional | Replace button text. | Replace button text. | — | | `strings.replaceAll` | `string` | `'All'` | Optional | Replace-all button text. | Replace-all button text. | — | | `strings.toggleReplaceTitle` | `string` | `'Toggle replace'` | Optional | Tooltip for the button that expands or collapses replace controls. | Tooltip for the button that expands or collapses replace controls. | — | | `strings.matchCase` | `string` | `'Aa'` | Optional | Match case toggle text. | Match case toggle text. | — | | `strings.ignoreDiacritics` | `string` | `'ä≡a'` | Optional | Ignore diacritics toggle text. | Ignore diacritics toggle text. | — | | `strings.regex` | `string` | `'.*'` | Optional | Regex toggle text. | Regex toggle text. | — | | `strings.invalidPattern` | `string` | `'Invalid pattern'` | Optional | Inline error shown when the regex pattern is invalid or unsafe. | Inline error shown when the regex pattern is invalid or unsafe. | — | ### Accessibility | Field | Type | Default | Status | Summary | API details | Guide | | --- | --- | --- | --- | --- | --- | --- | | `strings.findAriaLabel` | `string` | `'Find text'` | Optional | Accessible label for the find input. | Accessible label for the find input. | — | | `strings.replaceAriaLabel` | `string` | `'Replace text'` | Optional | Accessible label for the replace input. | Accessible label for the replace input. | — | | `strings.previousMatchAriaLabel` | `string` | `'Previous match'` | Optional | Accessible label for previous match button. | Accessible label for previous match button. | — | | `strings.nextMatchAriaLabel` | `string` | `'Next match'` | Optional | Accessible label for next match button. | Accessible label for next match button. | — | | `strings.closeAriaLabel` | `string` | `'Close find and replace'` | Optional | Accessible label for close button. | Accessible label for close button. | — | | `strings.toggleReplaceAriaLabel` | `string` | `'Toggle replace'` | Optional | Accessible label for toggle replace button. | Accessible label for toggle replace button. | — | | `strings.matchCaseAriaLabel` | `string` | `'Match case'` | Optional | Accessible label for match case toggle. | Accessible label for match case toggle. | — | | `strings.ignoreDiacriticsAriaLabel` | `string` | `'Ignore diacritics'` | Optional | Accessible label for ignore diacritics toggle. | Accessible label for ignore diacritics toggle. | — | | `strings.regexAriaLabel` | `string` | `'Use regular expression'` | Optional | Accessible label for the regex toggle. | Accessible label for the regex toggle. | — | `replaceControls: false` changes only the built-in UI. It does not disable `superdoc.ui.search.replace()` or enforce a permission. Keep application-owned replacement controls synchronized with `canReplace` from `superdoc.ui.search.observe()`. Search also supports match case and regular expressions. Invalid regular expressions show an error instead of running a partial search. Finding a pending deletion does not restore, accept, or reject it. It only adds that text to the current search results. Search highlights and the active match are temporary Editor state. Replacements change the document, so use the save flow from [Load and save](/editor/load-and-save-documents) to persist them. Use [custom search controls](/editor/custom-ui/search) when your application should render the search surface. Use [Document API queries](/document-api/query-content) when code needs document targets instead of a visual search session. --- # Connect two editors > Run the local example and watch an edit appear in another browser. Connect Alex and Sam to the same document. Start with the working example, then look at the configuration that connects them. ## Try it here [#try-it-here] The editors connect automatically. Expand the demo, then change Monday to Friday in Alex's document. Watch Sam's document update. Alex's cursor is blue; Sam's is green. This sample is temporary; do not enter private information. > **Live collaboration demo:** Two real editors connect automatically in a temporary room. Expand the collapsed preview, change Monday to Friday in Alex’s editor, and watch Sam’s editor update. Alex’s cursor is blue; Sam’s is green. The demo requires a configured collaboration server; when unavailable, use the local example below. Hosted rooms keep edits temporarily for reconnect and expire after 15 minutes. Use sample text only. ## 1. Start the example [#1-start-the-example] Download or clone the [collaboration example](https://go.superdoc.dev/examples/collaboration). It includes the browser app, a sample DOCX, and a local Hocuspocus server. You need Node.js 22.12 or newer and pnpm 11. In the example's `collaboration` directory, run: ```bash pnpm install --ignore-scripts pnpm dev ``` The example pins SuperDoc 2.11.0 and Hocuspocus 2.15.3. The install command skips dependency lifecycle scripts. Keep the terminal running: it serves the app on port `5173` and the collaboration connection on port `1234`. > **Local example only (note)** > > This server has no authentication or persistent storage. Use the included sample, not private documents. Restarting > the server clears its rooms. ## 2. Open Alex and Sam's editors [#2-open-alex-and-sams-editors] Open these addresses in separate tabs, in this order: | Editor | Address | What happens | | ------ | ---------------------------------------------- | ------------------------------------ | | Alex | `http://localhost:5173/?mode=create&user=Alex` | Creates a room from the sample DOCX. | | Sam | `http://localhost:5173/?user=Sam` | Joins Alex's room. | Wait for Alex's tab to show `Connected.` before opening Sam's. Then wait for Sam's tab to show the same status. Place the browser windows side by side so you can see both documents. ## 3. Edit together [#3-edit-together] In Alex's editor, change Monday to Friday. Watch the delivery date update in Sam's editor without reloading. Now type a reply in Sam's editor. It appears in Alex's editor too. Both editors can send and receive changes; neither is a read-only preview of the other. ## Configure your application [#configure-your-application] The connection belongs to the document. Both editors use the same server and room ID; the creator uses `create`, and everyone else uses `join`. > **Preview API (note)** > > The `collaboration` configuration and typed connection failures below require the upcoming SuperDoc release. They are > available in the current source, not in the pinned 2.11.0 example. Run that example unchanged to try the behavior. ```ts import { SuperDoc, type DocumentCollaborationConfig } from 'superdoc'; import 'superdoc/style.css'; const collaboration = { providerType: 'hocuspocus', documentId: 'example-room', serverUrl: 'ws://127.0.0.1:1234', roomMode: 'join', } satisfies DocumentCollaborationConfig; const superdoc = new SuperDoc({ selector: '#editor', document: { url: '/sample.docx', collaboration }, user: { name: 'Sam', email: 'sam@example.com' }, }); ``` Provide an `#editor` element and serve your DOCX at `/sample.docx`. Use these settings when integrating the preview API into your application, not as a patch to the pinned example. | Setting | Its job | | ------------------------------ | ---------------------------------------------------------------------------- | | `providerType` and `serverUrl` | Connect to the Hocuspocus server you started. | | `documentId` | Put both editors in the same room. | | `roomMode` | Create the room once, then join it. Alex uses `'create'`; Sam uses `'join'`. | The `mode` and `user` URL parameters belong to this example, not SuperDoc. The app reads them to choose the room operation and display name. Both editors supply the sample DOCX. The creator uses it to initialize the room. The joiner reads the room's shared content; supplying the file again does not overwrite it. The example shows `Connected.` when `onCollaborationReady` fires. That means initial synchronization and editor readiness have completed, not that the server has saved the document. Call `destroy()` when your owning route or component unmounts to release the connection; the standalone example does this when the page closes. ## Reopen or start over [#reopen-or-start-over] To reopen the existing room, use a join address such as `http://localhost:5173/?user=Alex`. Reloading the original `mode=create` address tries to create the room again and fails. To start over, restart the example server, then open the create address followed by the join address. If the example shows `Connection failed.`, inspect the error reported by `onException`. Check that the server is still running, both editors use the same `documentId`, and the creator reached `Connected.` before the joiner opened. A failed join is not a reason to overwrite the room with a local file. ## Show who is editing [#show-who-is-editing] Next, [add presence and cursors](/editor/collaboration/presence-and-awareness) so Alex and Sam can identify each other while they work. --- # Control access to a room > Check credentials and document permissions before an editor joins a shared room. Decide who may open a shared document. A room ID identifies it; it does not grant access. A cursor's display name is not proof of identity. This walkthrough adds authorization to the local example; it does not require persistent storage. Edit Alex's document, then connect Sam to see the same edits. Connect Taylor: the server rejects the request because Taylor has no permission for this room. > **Live access demo:** Alex opens a temporary shared document automatically. Edit it, then connect Sam to receive the same edits. Connect Taylor: the server rejects the request because Taylor has no permission for this room. These are simulated identities with public test credentials, checked by a real server. The demo reports access denied only after server confirmation, not for every connection failure. Demo edits are not saved. If the server is unavailable, follow the local example below. ## Let the server decide [#let-the-server-decide] | Browser | Collaboration server | Result | | --------------------------------------- | ------------------------------------------------------- | ------------------------------------ | | Sends a credential and requests a room. | Verifies the credential and checks access to that room. | Allows the connection or rejects it. | Keep both checks on the server. Hiding a document link or disabling an editor control does not protect the room. ## 1. Enable the access example [#1-enable-the-access-example] Use the [local collaboration example](/editor/collaboration/connect-two-editors). Stop its development command, then run: ```bash COLLABORATION_DEMO_AUTH=1 VITE_COLLABORATION_DEMO_AUTH=1 pnpm dev ``` The first flag enables server checks. The second lets the browser select a test credential for each example user. To keep the storage from the previous guide too, add `COLLABORATION_STORAGE_DIR=.collaboration-data` to the same command. If `example-room` is already saved, open both Alex and Sam with their join addresses below, not `mode=create`. > **Public test credentials only (note)** > > This example simulates signed-in users with fixed, public credentials. Anyone can select Alex's credential. It teaches > the connection checks, not a production login system. Use only the sample document. ## 2. Allow Alex and Sam [#2-allow-alex-and-sam] For a new room, open `http://localhost:5173/?mode=create&user=Alex`. If `example-room` is already saved, open `http://localhost:5173/?user=Alex` instead. Wait for `Connected.`, then open `http://localhost:5173/?user=Sam`. Type in Alex's editor and confirm that Sam receives the edit. The server allows both credentials to access `example-room`. ## 3. Reject Taylor [#3-reject-taylor] Open `http://localhost:5173/?user=Taylor`. Taylor's credential is recognized, but it has no room permissions. The page should show `Connection failed.` and keep **Export DOCX** disabled. Taylor must not receive Alex's shared edits. This is different from an unknown credential, which the server also rejects. Knowing who someone is does not mean they can open every document. ## Check the credential and room together [#check-the-credential-and-room-together] The example's `demo-access.ts` supplies Hocuspocus's `onAuthenticate` hook: ```ts import type { Configuration, onAuthenticatePayload } from '@hocuspocus/server'; // Public fixtures for the local walkthrough, not production credentials. const exampleProviderRoom = 'sd2/v2.1/example-room'; const sessions = new Map([ ['demo-alex', { userId: 'alex', rooms: [exampleProviderRoom] }], ['demo-sam', { userId: 'sam', rooms: [exampleProviderRoom] }], ['demo-taylor', { userId: 'taylor', rooms: [] }], ]); export async function authenticateRoom({ token, documentName }: Pick) { const session = sessions.get(token); if (!session || !session.rooms.includes(documentName)) { throw new Error('Access denied'); } return { userId: session.userId }; } export const demoAccess = { onAuthenticate: authenticateRoom } satisfies Pick; ``` For the pinned SuperDoc 2.11.0 example, `documentId: 'example-room'` reaches Hocuspocus as `sd2/v2.1/example-room`. The hook checks that exact provider room name, not just whether the token is valid. Keep this mapping with your document permissions; do not authorize by matching only a suffix. Returning allows the connection; throwing rejects it. This example grants the same room access to creators and joiners; it does not implement separate create, read-only, or edit roles. In the [preview configuration API](/editor/collaboration/connect-two-editors#configure-your-application), send the browser credential through `document.collaboration.token`. It is separate from `user`, which supplies display identity for the editor. ## Handle a rejected connection [#handle-a-rejected-connection] The preview API adds `collaborationReason` to connection failures reported by `onException`. Add this callback to your editor configuration: ```ts import type { Config } from 'superdoc'; const onException: NonNullable = (failure) => { if ('collaborationReason' in failure) { switch (failure.collaborationReason) { case 'access-denied': console.error('Check your sign-in and permission to open this document.'); break; case 'connection-failed': case 'sync-timeout': console.error('Could not connect. Check your connection and try again.'); break; } return; } console.error(failure.error); }; ``` `access-denied` means the provider explicitly rejected the connection. It does not distinguish an invalid credential from missing room permission. A timeout is not proof of denied access, and a worker startup failure is not a collaboration rejection. Do not parse error messages or show the server's raw rejection text to users. The pinned 2.11.0 example reports only `Connection failed.`; keep its existing error handling until you upgrade. The embedded demo confirms rejection with its example server. ## Connect your application's sign-in flow [#connect-your-applications-sign-in-flow] Replace the public credential map with server-side validation of your application's session or access token. Look up the verified user's permission for the requested document. Do not accept a browser-supplied user ID as proof of identity. On the browser side, replace `src/demo-credentials.ts` and its name-based selection with a credential obtained through your authenticated application. Never put production secrets in a `VITE_` variable, source file, or URL. Use HTTPS and secure WebSockets outside local development. Returning `userId` from this server hook does not make the browser's cursor label trustworthy. Keep authorization tied to the verified server identity, not awareness names or colors. Protect the source DOCX download and export storage too; room authorization does not secure separate file endpoints. Rechecking access after token expiry or permission changes needs an explicit server policy, not just this initial connection check. Next, [run a collaboration server](/editor/collaboration/run-a-server) to review provider choices and deployment responsibilities. --- # Understand collaboration > Let people edit the same document together, using your existing editor UI. Without collaboration, two people opening the same DOCX are editing separate copies. With collaboration, they work on a shared document: an edit in one person's editor appears in the other's. ## One document, two editors [#one-document-two-editors] Alex changes a delivery date. Sam sees the new date without reloading the document or opening another file. > **Illustration: two editors, one shared document.** Alex changes the delivery date to Friday. A provider carries the change through their shared room, and Sam sees Friday in the other editor. Both people can edit. Both people can edit. SuperDoc synchronizes their document changes while each person keeps their own view and selection. ## Connect through a room [#connect-through-a-room] A **room** connects the editors working on one shared document. Your application gives that room an ID so each editor knows which document to join. Opening the same file URL in two browsers does not connect them. A **provider** carries changes between the connected editors. You can run a collaboration server yourself or use a hosted service. The next guide starts with a local Hocuspocus server. The first editor creates the room from a DOCX. The second joins that room. From then on, they work with its shared content. Sharing edits is separate from reviewing them. Use [tracked changes](/editor/track-changes) when edits should remain proposals, or [comments](/editor/built-in-ui/comments) for discussion. Collaboration carries the shared document changes; it does not decide which proposals to accept. ## Keep the UI you already built [#keep-the-ui-you-already-built] Collaboration works with built-in controls, custom controls, or a mix of both. You add a connection to the document; you do not need to rebuild your toolbar or review panels. Your application still decides who can access the document and how it is saved. Those are server responsibilities, separate from showing another person's edits. ## Insert bookmark anchors [#insert-bookmark-anchors] In a V2 collaboration room, `doc.bookmarks.insert()` supports a text range within one paragraph of the document body, including a zero-length range for an insertion-point bookmark. Wait for `collaboration-ready` before calling it. The bookmark's start and end offsets are preserved in `bookmarks.get()` and `bookmarks.list()`, on connected peers, and when exporting and reopening the DOCX. Use a unique bookmark name and a valid range in the target paragraph. Insertion across paragraphs is not supported; it returns `CROSS_BLOCK_MATCH` without creating a bookmark. The same single-paragraph range and point behavior is supported in solo editing. ## Try it with two editors [#try-it-with-two-editors] [Connect two editors](/editor/collaboration/connect-two-editors) walks you through creating a room, joining from another tab, and watching an edit appear in both. Start there, then add presence and persistent storage. --- # Initialize a shared document > Create a room from a DOCX once, then join it for every later editing session. Your application has a DOCX that Alex and Sam need to edit together. Choose one place to create its shared room. After that, everyone—including the creator—joins the existing room. Start with the [local two-editor example](/editor/collaboration/connect-two-editors). Keep its Hocuspocus server running throughout this walkthrough; it stores rooms only in memory. ## Create once, then join [#create-once-then-join] | First open | Another person arrives | Return to the document | | -------------------------------------- | ------------------------------------------------ | -------------------------------------------------- | | Alex **creates** a room from the DOCX. | Sam **joins** the room and receives its content. | Alex **joins** the same room, including its edits. | | `roomMode: 'create'` | `roomMode: 'join'` | `roomMode: 'join'` | `create` is not “open or create.” It fails if the room already exists. `join` does not initialize a missing room from the supplied file. ## 1. Create the room from a DOCX [#1-create-the-room-from-a-docx] In the example, open `http://localhost:5173/?mode=create&user=Alex`. Alex's editor imports the sample DOCX into a new room and shows `Connected.` when it is ready. With the [preview configuration API](/editor/collaboration/connect-two-editors#configure-your-application), put the connection in the document's `collaboration` field. These are the creator's settings: ```ts import type { DocumentCollaborationConfig } from 'superdoc'; const createConnection = { providerType: 'hocuspocus', serverUrl: 'ws://127.0.0.1:1234', documentId: 'example-room', roomMode: 'create', } satisfies DocumentCollaborationConfig; ``` In your application's document configuration, use `collaboration: createConnection` for this first open. Leave the pinned example unchanged; its URL selects the same room operation. In your application, assign a stable room ID to each shared document. Store that association so future editors can find the same room. Do not generate a new ID on every page load or reuse one room for unrelated documents. The application chooses who initializes the room. Do not make every visitor try to create it. The collaboration server must still authorize access; knowing a room ID does not grant permission. ## 2. Join the existing room [#2-join-the-existing-room] After Alex's tab shows `Connected.`, open `http://localhost:5173/?user=Sam`. The server address and room ID stay the same; only the operation changes: ```ts const joinConnection = { ...createConnection, roomMode: 'join', } satisfies DocumentCollaborationConfig; ``` Use `collaboration: joinConnection` for later opens. The example still supplies the sample DOCX, but the joiner receives the room's shared content. Supplying the original file does not reset the room. Type a short sentence in Sam's editor and confirm that Alex sees it. ## 3. Reopen without resetting [#3-reopen-without-resetting] Keep Alex connected while you close Sam's tab and reopen `http://localhost:5173/?user=Sam`. His sentence should still be there. With Sam connected again, Alex can close his tab and return using `http://localhost:5173/?user=Alex`. Do not reuse Alex's original `mode=create` address to reopen the document. The person who created the room is now a joiner too. If creation reports that the room exists, reopen the intended document with `join`; do not overwrite it. If joining fails, check the connection, room ID, access, and whether initialization completed. A failed join is not permission to create a replacement from an older file. ## Choose where initialization belongs [#choose-where-initialization-belongs] The browser path above works when the first editor supplies the DOCX. Two other starting points have different owners: | Your application starts with… | Initialize through… | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A DOCX opened for its first shared editing session | The browser editor, as above. | | A local editor with unsaved changes that someone wants to share | [`upgradeToCollaboration()`](/editor/collaboration/upgrade-a-document), which creates a new room from the current document. | | A backend workflow that prepares the document before anyone opens it | The [Node.js SDK](/agents/automation/node-sdk), opening the DOCX with a collaboration target and explicit `roomMode: 'create'`. Browsers then join that room. | The Hocuspocus server transports shared state; it does not import a DOCX by itself. Server-side initialization still needs a SuperDoc client to read the file and create the document in the room. Choose one initialization path, not competing browser and backend creators. ## Keep the room after a restart [#keep-the-room-after-a-restart] Reopening this example proves that a new editor receives the existing room's edits. It does not prove durable saving. This server has no storage integration: restarting it clears its rooms, and a room can also be unloaded after its last editor leaves. Keep at least one editor connected during the walkthrough. `onCollaborationReady` is not a storage acknowledgment. To reopen after everyone leaves or the server restarts, your server needs persistent room storage. Next, [save and restore a room](/editor/collaboration/save-and-restore-a-room) so the document survives everyone leaving and the server restarting. --- # Show who is editing > Give collaborators names and cursor colors, then show who is connected. Alex and Sam can now edit the same document. Give each person a name so they can recognize the other person's cursor. Add a participant list when your application also needs to show who is here. ## Watch someone leave and return [#watch-someone-leave-and-return] Expand the demo and type in Sam's editor. Select **Disconnect Sam**: his name leaves the participant list and his cursor disappears from Alex's editor, but his edits remain. Select **Reconnect Sam** to bring him back into the same room. > **Live presence demo:** Alex and Sam edit a temporary shared document. The participant list comes from Alex’s awareness updates and includes Alex himself. Type in Sam’s editor, then disconnect Sam: his presence disappears while his edits remain in Alex’s document. Reconnect Sam to rejoin the same room. This requires a configured collaboration server; otherwise follow the local two-editor example. Hosted rooms keep edits temporarily for reconnect and expire after 15 minutes. Use sample text only. This is a temporary sample. Do not enter private information. Without the hosted demo, use the [local two-editor example](/editor/collaboration/connect-two-editors): close Sam's tab, then reopen his join address. ## Identify each person [#identify-each-person] The local example already supplies `user` when it creates each editor. In your application, use the display identity from the signed-in session. For example, Alex's configuration includes: ```ts const user = { id: 'alex', name: 'Alex', email: 'alex@example.com', }; ``` Pass this object as `user` in the editor configuration. SuperDoc displays remote cursors and names; you do not need to draw them yourself. Each person keeps their own selection and viewport. SuperDoc assigns cursor colors from a palette. To use your application's colors, provide the same palette to each editor: ```ts const colors = ['#1355ff', '#00853d', '#9333ea']; ``` Pass the array as `colors` in the editor configuration. A palette is not a guarantee of a unique color for every person. Keep names visible so color is not the only way to identify a collaborator. ## Show a participant list [#show-a-participant-list] **Awareness** is the live presence information exchanged during the session. Use `onAwarenessUpdate` to render that information in your own UI. You do not need this callback just to display the editor's remote cursors. The [collaboration example](https://go.superdoc.dev/examples/collaboration) includes this element in `index.html`: ```html
    ``` Its `src/participants.ts` renders each update using the public payload type: ```ts import type { SuperDocAwarenessUpdatePayload } from 'superdoc'; export function renderParticipants({ states }: SuperDocAwarenessUpdatePayload) { const list = document.querySelector('#participants'); if (!list) return; const items = states.map((participant) => { const item = document.createElement('li'); item.textContent = participant.name || 'Guest'; return item; }); list.replaceChildren(...items); } ``` In `src/main.ts`, import the handler: ```ts import { renderParticipants } from './participants'; ``` Then add `onAwarenessUpdate: renderParticipants` to the existing `new SuperDoc(...)` configuration. Open Alex and Sam's tabs: each list should show both names once. Close Sam's tab and check that Alex's list returns to one name. In V2, `states` includes the current user. Render the snapshot as supplied; do not append yourself again. Read fields such as `name` and `color` directly from each entry, not from `state.user`. Use presence `clientId` values only within the current editor session, not as account IDs. If a separate UI component subscribes later with `superdoc.on('awareness-update', listener)`, remove that same listener with `superdoc.off('awareness-update', listener)` when the component unmounts. Continue to call `superdoc.destroy()` when the editor itself unmounts. ## Presence is not document state [#presence-is-not-document-state] Leaving removes a person's live presence, not their edits. After an abrupt network loss, presence may take time to disappear; it is not an immediate connection-health check. The `users` configuration is a directory for people and mentions, not a list of connected participants. Neither that directory nor awareness grants access to a room. Keep permissions on the server, and do not use presence as an audit log or a saved indicator. ## Keep the shared document [#keep-the-shared-document] You can now show shared edits and the people making them. Next, [initialize a shared document](/editor/collaboration/initialize-a-document): choose who creates the room and how everyone reopens it. --- # Run a collaboration server > Choose a provider and connect room access, storage, and recovery before deployment. The [two-editor example](/editor/collaboration/connect-two-editors) already runs a Hocuspocus server. Keep it for local development. Before deployment, choose who operates the provider and how rooms are authorized, saved, and recovered. ## Run the server separately [#run-the-server-separately] The [collaboration example](https://go.superdoc.dev/examples/collaboration) pins Hocuspocus 2.15.3. Its `pnpm dev` command starts both the browser app and server. You do not need a second server process. To run only the server, stop `pnpm dev`, then run this from the example directory: ```bash pnpm exec tsx server.ts ``` The example listens on port `1234` and keeps rooms in memory. Restarting the process clears them. With no storage integration, unloading a room after its last editor disconnects also loses its state. Start the browser app separately with `pnpm exec vite`, or return to `pnpm dev` after stopping the standalone server. Use the [two-editor walkthrough](/editor/collaboration/connect-two-editors#2-open-alex-and-sams-editors) to check the connection. For storage, pass `COLLABORATION_STORAGE_DIR` to the server command. For the access example, pass `COLLABORATION_DEMO_AUTH=1` to the server command and `VITE_COLLABORATION_DEMO_AUTH=1` to the Vite command. The default commands enable neither storage nor access checks. ## Prepare for production [#prepare-for-production] ### Choose a provider [#choose-a-provider] Hocuspocus is the local example's default, not a requirement. The document's `collaboration` field accepts these targets through `DocumentCollaborationConfig` in the [preview API](/editor/collaboration/connect-two-editors#configure-your-application): | `providerType` | Room and connection | Authentication | | --------------- | --------------------------------------- | ----------------------------------------------- | | `'hocuspocus'` | `documentId` and `serverUrl` (or `url`) | `token` or string `params` | | `'y-websocket'` | `documentId` and `serverUrl` (or `url`) | String `params` forwarded to your server | | `'liveblocks'` | `documentId` or `roomId` | Exactly one of `authEndpoint` or `publicApiKey` | For Liveblocks, use an authenticated endpoint when access must be checked per room; a public key does not provide that check. Configure the chosen provider's server or service before pointing the editor at it. SuperDoc owns the browser connection and local shared state; pass connection settings, not an external `{ ydoc, provider }` pair. ### Authorize access [#authorize-access] The minimal server deliberately has no authentication or durable storage. Before deployment, authenticate the WebSocket connection, authorize each room, persist room updates, set connection and document limits, and define backup and recovery behavior in the server layer. [Control access to a room](/editor/collaboration/control-room-access) demonstrates credential validation and per-room permission checks. Your server owns authorization; browser controls and display identity do not enforce it. ### Initialize the document [#initialize-the-document] [Initialize a shared document](/editor/collaboration/initialize-a-document) explains who imports the DOCX, when to create or join, and how browser and backend initialization differ. The Hocuspocus server transports and stores shared state; it does not import the DOCX itself. ### Persist the room separately from DOCX files [#persist-the-room-separately-from-docx-files] [Save and restore a room](/editor/collaboration/save-and-restore-a-room) shows how to store binary Yjs state, restart the server, and reopen with edits intact. It also explains why DOCX exports and presence are separate from room storage. If your application starts with a local document, [upgrade it to collaboration](/editor/collaboration/upgrade-a-document) when someone invites another person to edit. --- # Save and restore a room > Keep collaborative edits after everyone leaves or the server restarts. Alex and Sam can reopen a room while another editor keeps it alive. Now make it survive everyone leaving and the server restarting. Use the [collaboration example](/editor/collaboration/connect-two-editors). This page adds local storage to its Hocuspocus server; the browser configuration stays the same. ## What should you save? [#what-should-you-save] A `Y.Doc` holds a local copy of the shared document state. Each client has its own copy; the provider exchanges Yjs updates between them. SuperDoc manages the browser's copy inside its worker. Your application supplies connection settings, not its own `Y.Doc`. | Data | Keep it for… | | --------------------- | ----------------------------------------------------------------- | | Binary Yjs room state | Reopening the same collaborative document, including its edits. | | Exported DOCX | Downloading, sharing, or keeping a file snapshot. | | Awareness | Showing who is here now. It is temporary, not saved room content. | Saving only DOCX snapshots does not persist the Yjs room. Saving room state does not automatically write a DOCX to your file storage. ## 1. Enable local storage [#1-enable-local-storage] Stop the example's development command. In the same directory, restart it with: ```bash COLLABORATION_STORAGE_DIR=.collaboration-data pnpm dev ``` The directory is created automatically and ignored by Git. It contains binary room snapshots, not DOCX files. Keep using this directory when restarting the server. This local example has no authentication. Use the sample document, not private information. The embedded docs demo remains temporary. Use the local server for this step: reconnecting the embedded demo cannot demonstrate a storage write or server restart. ## 2. Edit and wait for a save [#2-edit-and-wait-for-a-save] Open `http://localhost:5173/?mode=create&user=Alex`, then `http://localhost:5173/?user=Sam`. If this room already exists in your storage directory, use the join addresses for both people instead. Type “Saved after restart.” in Sam's editor. Wait for the server terminal to print `Room state saved.` after the edit. Hocuspocus schedules storage writes; a keystroke does not mean the room has been saved yet. Close both tabs. Stop the development command, then restart it with the same command and directory. ## 3. Restore the room [#3-restore-the-room] Open `http://localhost:5173/?user=Alex`, using `join`, not `create`. Confirm that “Saved after restart.” is still there. Join as Sam and make another edit to check that collaboration still works. Select **Export DOCX** to download a file containing the restored edits. Exporting is separate from saving the room. ## How the server saves and loads [#how-the-server-saves-and-loads] The example enables these hooks only when `COLLABORATION_STORAGE_DIR` is set: ```ts import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Configuration } from '@hocuspocus/server'; import { applyUpdate, encodeStateAsUpdate, type Doc } from 'yjs'; type RoomState = { document: Doc; documentName: string }; export function localStorage(directory: string) { mkdirSync(directory, { recursive: true }); const filename = (name: string) => join(directory, `${createHash('sha256').update(name).digest('hex')}.yjs`); return { async onLoadDocument({ document, documentName }: RoomState) { let state: Buffer; try { state = readFileSync(filename(documentName)); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw error; } applyUpdate(document, state); }, async onStoreDocument({ document, documentName }: RoomState) { const target = filename(documentName); // Synchronous writes serialize this single-process example; rename avoids exposing a partial snapshot. writeFileSync(`${target}.tmp`, encodeStateAsUpdate(document)); renameSync(`${target}.tmp`, target); console.log('Room state saved.'); }, } satisfies Pick; } ``` `encodeStateAsUpdate()` produces a complete state update for the current `Y.Doc`. `applyUpdate()` restores those bytes before clients synchronize. Store binary data, not `Y.Doc.toJSON()`. Only a missing file is treated as a new room. Unreadable or corrupt state must fail rather than silently replace saved content with the original DOCX. ## Before using production storage [#before-using-production-storage] This single-process example uses synchronous writes and a temporary-file rename. It does not provide multi-server coordination, backups, or a power-loss durability guarantee. Use your provider's storage integration for your database or object store, with authorization and a recovery policy. `onCollaborationReady` means the editor has synchronized and is ready. It is not a storage acknowledgment. Show “Saved” only when your storage flow confirms the changes it committed. Next, [control access to a room](/editor/collaboration/control-room-access) so only authorized users can connect to the saved document. --- # Upgrade a local document to collaboration > Create a new collaboration room from the DOCX already open in a local Editor. Use `upgradeToCollaboration()` when a single local DOCX is already open and the person decides to make that live Editor collaborative. The operation creates a new room from the current document and comments, then attaches the Editor to it in place. ## Create the room [#create-the-room] Wait until the local Editor is ready, then pass a supported collaboration target in create mode. This snippet uses the [preview configuration API](/editor/collaboration/connect-two-editors#configure-your-application): Here, `superdoc` is your existing Editor and `session.collaborationToken` comes from your application's authenticated session. Replace the example server address with your configured provider. The token must be authorized to create `contract-123`; this snippet does not provide a server or sign-in flow. ```ts await superdoc.upgradeToCollaboration({ collaboration: { providerType: 'hocuspocus', documentId: 'contract-123', serverUrl: 'wss://collaboration.example.com', token: session.collaborationToken, roomMode: 'create', }, }); ``` The promise resolves when the collaborative runtime is ready. The same Editor instance stays mounted, so application controls and lifecycle ownership remain in place. The operation supports one DOCX and a new v2 room. It does not join an existing room or merge the local document with remote content. If the target already exists, stop and let the application choose a different room or reopen the document with `roomMode: 'join'`. > **Verification target (success)** > > After the upgrade resolves, open a second Editor that joins the new room. An edit in either Editor should appear in > the other. ## Migrate existing collaboration data separately [#migrate-existing-collaboration-data-separately] Moving a final v1 recovery bundle into a v2 room is a server-side migration, not a live browser upgrade. The Node-only `superdoc/collaboration-upgrade-engine` subpath builds and validates provider-free upgrade artifacts without connecting to a room. It requires Node.js 20 or newer and should be paired with provider-specific read, write, and fresh-client validation. Start with [Migrate from v1](/editor/migrate-from-v1/overview) before planning that operation. For ordinary browser connections, use [Connect two editors](/editor/collaboration/connect-two-editors). --- # Add fields to a DOCX template > Turn document selections into tagged inline and block-level content controls. Use this workflow when your DOCX does not yet have fields your application can find. Wrap a client name in an inline field, then create a block field for a confidentiality clause. If your template already has fields, start with [Fill a DOCX template](/editor/content-controls/fill-a-docx-template). ## Add both field shapes [#add-both-field-shapes] Select the client name and add the inline field. Then place the caret on the empty line below **Confidentiality** and add the block field. > **Interactive editor: add template fields** > > Select “Acme Products, Inc.” and add an inline text field tagged `client.legalName`. Then place the caret on the empty line below Confidentiality and add a block rich-text field tagged `agreement.confidentiality`. The detected-fields list reports each field’s alias, tag, placement, and control type. Export downloads the authored DOCX. ## Create the fields [#create-the-fields] Use the [complete Vanilla TypeScript example](https://go.superdoc.dev/examples/content-controls?workflow=add) and choose **Add fields**. It includes the untagged agreement, selection controls, Editor setup, and export button. Pass the current selection target to `create.contentControl()`. Here, the inline call wraps a text selection. The block call targets an empty paragraph and replaces it with the supplied HTML content: ```ts import type { BrowserDocumentApi, SelectionTarget } from 'superdoc/ui'; export async function addClientNameField(doc: BrowserDocumentApi, selection: SelectionTarget) { return doc.create.contentControl({ kind: 'inline', controlType: 'text', tag: 'client.legalName', alias: 'Client legal name', at: selection, }); } export async function addConfidentialityField(doc: BrowserDocumentApi, caret: SelectionTarget) { return doc.create.contentControl({ kind: 'block', controlType: 'richText', tag: 'agreement.confidentiality', alias: 'Confidentiality clause', html: '

    Each party will protect confidential information with reasonable care.

    ', at: caret, }); } ``` `kind` controls placement. `controlType` controls field behavior: | Property | This example | Meaning | | ------------- | -------------------- | ------------------------------------------------ | | `kind` | `inline`, `block` | Where the control sits in the document structure | | `controlType` | `text`, `richText` | Which content-control operations apply | | `tag` | `client.legalName` | Application lookup and grouping key | | `alias` | `Client legal name` | Readable title stored in the DOCX | | `id` | Assigned on creation | One control occurrence in the document | The example obtains the selection from the Editor-owned UI controller after readiness. The helper needs a current selection target, not the selected text alone. Check the creation receipt before treating the field as available. ## Keep the fields in your template [#keep-the-fields-in-your-template] Export the DOCX from the standalone example and reopen it. Confirm that the client name is an inline text control and the confidentiality clause is a block rich-text control, with the tags and titles shown above. Keep this file as the template you fill later; reopening the original draft will not include the fields you just added. Continue with [Fill a DOCX template](/editor/content-controls/fill-a-docx-template) to update fields from application data. Use the [`create.contentControl()` reference](/document-api/reference/content-controls/create/) for every input shape. --- # Fill a DOCX template > Update every content-control occurrence that represents an application field, then export the DOCX. Use a prepared template when your application already knows the values to put in a document. This agreement has a client name in three places and an auto-renew checkbox. The fields are already present; your application fills them. ## Try the workflow [#try-the-workflow] Expand the Editor and change the client name. All three occurrences update. Then toggle auto-renew to change the checkbox in the document. > **Interactive editor: Fill the template** > > The application form updates real Word content controls in the service-agreement DOCX. > > - Client legal name (`client.legalName`): one form value updates 3 document occurrences. > - Auto-renew (`agreement.autoRenew`): the checkbox updates the Word checkbox control. > > The demo reports how many matching controls changed. Reset restores the prepared template, and Export DOCX downloads the filled document. ## Start with the prepared example [#start-with-the-prepared-example] Open the [complete Vanilla TypeScript example](https://go.superdoc.dev/examples/content-controls?workflow=fill) and choose **Fill fields**. It includes the template, form, occurrence arrows, Editor setup, and export button. The snippets below are from that project; they are not a separate Editor setup. In your own integration, keep the Editor from the [Quickstart](/editor/quickstart) and load a DOCX with content controls, such as the [service-agreement template](/fixtures/service-agreement-template.docx). Obtain `superdoc.activeEditor.doc` after `onReady` before calling the field helpers. The Quickstart's original sample does not contain these fields. ## Define the field map [#define-the-field-map] The client-name controls share the tag `client.legalName`. Their IDs differ because each ID identifies one occurrence. Changing a control does not automatically change others with the same tag; the example updates every match. Keep the tag and expected type together. For example, `client.legalName` expects a text control and `agreement.autoRenew` expects a checkbox.
    View the example's field map ```ts export const templateFields = [ { key: 'clientLegalName', label: 'Client legal name', tag: 'client.legalName', type: 'text', }, { key: 'clientAddress', label: 'Client address', tag: 'client.address', type: 'text', }, { key: 'effectiveDate', label: 'Effective date', tag: 'agreement.effectiveDate', type: 'text', }, { key: 'autoRenew', label: 'Auto-renew', tag: 'agreement.autoRenew', type: 'checkbox', }, ] as const; export type TemplateField = (typeof templateFields)[number]; export type TemplateFieldKey = TemplateField['key']; type TaggedContentControl = { readonly controlType: string; readonly properties: { readonly tag?: string }; }; export function hasCompatibleTemplateFields(items: readonly TaggedContentControl[]) { return templateFields.every((field) => items.some((item) => item.properties.tag === field.tag && item.controlType === field.type), ); } ```
    ## Update every occurrence [#update-every-occurrence] The example's `updateTextField()` and `updateCheckboxField()` helpers follow three steps: 1. Find matching controls with `selectByTag()`. 2. Check each control's type, then call `text.setValue()` or `checkbox.setState()` on its target. 3. Count updated, unchanged, and failed occurrences from the mutation receipts.
    View the typed update helpers ```ts import type { BrowserDocumentApi, ContentControlInfo } from 'superdoc/ui'; export type FieldUpdateResult = { failures: string[]; matched: number; unchanged: number; updated: number; }; type ControlType = 'checkbox' | 'text'; async function updateControls( doc: BrowserDocumentApi, tag: string, expectedType: ControlType, mutate: (control: ContentControlInfo) => Promise<{ success: boolean; failure?: { code?: string; message?: string } }>, ): Promise { let items: readonly ContentControlInfo[]; try { ({ items } = await doc.contentControls.selectByTag({ tag })); } catch (error) { return { failures: [error instanceof Error ? error.message : `Could not find controls for ${tag}.`], matched: 0, unchanged: 0, updated: 0, }; } const result: FieldUpdateResult = { failures: [], matched: items.length, unchanged: 0, updated: 0 }; for (const control of items) { if (control.controlType !== expectedType) { result.failures.push(`${control.id} is ${control.controlType}, not ${expectedType}.`); continue; } try { const receipt = await mutate(control); if (receipt.success) result.updated += 1; else if (receipt.failure?.code === 'NO_OP') result.unchanged += 1; else result.failures.push(receipt.failure?.message ?? `Could not update ${control.id}.`); } catch (error) { result.failures.push(error instanceof Error ? error.message : `Could not update ${control.id}.`); } } return result; } export function updateTextField(doc: BrowserDocumentApi, tag: string, value: string) { return updateControls(doc, tag, 'text', (control) => Promise.resolve(doc.contentControls.text.setValue({ target: control.target, value })), ); } export function updateCheckboxField(doc: BrowserDocumentApi, tag: string, checked: boolean) { return updateControls(doc, tag, 'checkbox', (control) => Promise.resolve(doc.contentControls.checkbox.setState({ target: control.target, checked })), ); } export function didUpdateEveryMatch(result: { failures: readonly string[]; matched: number }) { return result.matched > 0 && result.failures.length === 0; } export function describeUpdate(result: FieldUpdateResult) { if (result.failures.length > 0 && result.matched === 0) return 'The document could not be updated.'; if (result.matched === 0) return 'No matching controls.'; if (result.failures.length > 0) return `Updated ${result.updated} of ${result.matched} locations.`; if (result.updated === 0) return `${result.matched} locations already match.`; return `Updated ${result.updated} ${result.updated === 1 ? 'location' : 'locations'}.`; } ```
    Use the same lookup and receipt pattern for other supported field types. Check `controlType`, call the matching typed operation, and use the returned receipt to decide what the application reports. No matches means the template is missing the expected field. A locked or incompatible occurrence can fail after other occurrences have changed: these individual updates are not one atomic transaction. The example reports partial results and blocks export until failed fields are corrected. ## Export and check the result [#export-and-check-the-result] In the standalone example, choose **Export DOCX** after the updates finish. Reopen the file in SuperDoc or Word and check all three client names and the checkbox. Field tags remain in the DOCX for later updates. The embedded demo does not save to your server. Use [Load and save](/editor/load-and-save-documents) to persist the exported bytes in your application. Next, [add fields to your own template](/editor/content-controls/add-fields-to-a-docx-template). For an application-owned field list, see [custom content-control UI](/editor/custom-ui/content-controls). --- # Templates and fields > Connect application data to named regions in a DOCX, from a client name to a reusable clause. Suppose a client name appears three times in an agreement. Your application needs to update all three without searching for the old name or changing unrelated text. A **content control** marks a region of the document and gives it metadata your application can find. In these guides, a template field is a content control used for a value or clause. It stays part of the DOCX, whether you use built-in controls or a custom UI. Start with [Fill a DOCX template](/editor/content-controls/fill-a-docx-template). Its prepared agreement lets you change one client name and see all three occurrences update. You do not need to create fields or build a custom toolbar first. ## Choose a workflow [#choose-a-workflow] - [Fill a DOCX template](/editor/content-controls/fill-a-docx-template): Update prepared fields from application data and export the result. - [Add fields to a DOCX template](/editor/content-controls/add-fields-to-a-docx-template): Turn selected content into tagged inline and block-level fields. - [Replace clauses from your application](/editor/content-controls/replace-clauses-from-your-application): Use tagged block-level fields as application-managed clause slots. - [Lock template fields](/editor/content-controls/lock-template-fields): Keep a field, its contents, or both from being changed during normal DOCX editing. ## Choose what the region holds [#choose-what-the-region-holds] **Content-control shapes** | Shape | Wraps | Common use | | --- | --- | --- | | Inline | Part of a paragraph | Repeated values and typed inputs | | Block-level | Paragraphs or tables | Clause slots and document sections | | Repeating section | A collection of items | Line items and repeated records | Start with text fields. Add other controls when the document needs them: * **Typed inputs:** Use text, checkbox, date, and choice controls for values with known input behavior. * **Repeating records:** Add, clone, and remove repeating-section items for lists such as line items or questionnaire responses. * **Custom field UI:** Build a field list, navigation panel, or workflow around the controls in the open document. Use the [Document API reference](/document-api/reference/content-controls/) for the complete operation set. Use [custom content-control UI](/editor/custom-ui/content-controls) to connect document selection and navigation to your application. ## Identify a control [#identify-a-control] Use a tag as the application lookup key. The alias gives people a readable title, while the ID identifies one occurrence inside the document. | Word property | SuperDoc value | Use it for | | ------------- | ------------------ | ------------------------------------------------------------------------------------------- | | Tag | `properties.tag` | Connect one application field to every matching control. Multiple controls can share a tag. | | Title | `properties.alias` | Show a human-readable field name in Word or your application UI. | | ID | `id` | Address one occurrence of the field in the DOCX. | A shared tag groups controls for lookup. It does not synchronize their content. Your application decides how values flow between its data and each document occurrence. Content controls are stored as `w:sdt` elements in the DOCX. You do not need to edit that XML to use the Document API. --- # Lock template fields > Keep a content control, its contents, or both from being changed during normal DOCX editing. Content-control locks use the same two choices as Word. Protect the field wrapper when it must remain in the template, and protect its contents when people should not change the value. ## Try both locks [#try-both-locks] Expand the editor, toggle either lock, then click the client address and type. Use **Delete field** to test whether the control itself can be removed. > **Interactive editor: Lock a template field** > > The service-agreement DOCX contains one text control tagged `client.address`. The demo applies the same two locking choices that Word exposes: > > - **Content control cannot be deleted** protects the field wrapper. > - **Contents cannot be edited** blocks changes inside the field. > > Toggle either choice, then edit or delete the field. Reset restores the prepared document. ## Set the lock mode [#set-the-lock-mode] In the [runnable Fill example](https://go.superdoc.dev/examples/content-controls?workflow=fill), update the address, then select **Lock address after filling**. Export the DOCX to keep both the value and its lock. Clear the checkbox to allow edits again. Continue with the [filled template](/editor/content-controls/fill-a-docx-template). After `onReady`, pass `superdoc.activeEditor.doc`, the tag `client.address`, and a mode below to this helper. That tag identifies the single address control used in the demo. The helper requires exactly one match; `client.legalName` has three occurrences. It uses the types shipped with `superdoc`; no separate Document API package is needed. Find the field by tag and apply the matching DOCX lock: ```ts import type { BrowserDocumentApi, ContentControlInfo } from 'superdoc/ui'; export async function setTemplateFieldLock( doc: BrowserDocumentApi, tag: string, lockMode: ContentControlInfo['lockMode'], ) { const { items } = await doc.contentControls.selectByTag({ tag }); if (items.length !== 1) { throw new Error(`Expected one content control tagged "${tag}", found ${items.length}.`); } return doc.contentControls.setLockMode({ target: items[0].target, lockMode, }); } ``` | Content control cannot be deleted | Contents cannot be edited | `lockMode` | | --------------------------------- | ------------------------- | ------------------ | | Off | Off | `unlocked` | | On | Off | `sdtLocked` | | Off | On | `contentLocked` | | On | On | `sdtContentLocked` | Set a content lock after filling the field. `contentLocked` and `sdtContentLocked` reject text changes inside the control. Export with your existing [load and save flow](/editor/load-and-save-documents), then reopen the file and try the same edit or deletion. Check the lock on the saved control, not just the state of your application's checkbox. ## Keep authorization separate [#keep-authorization-separate] Locks travel with the DOCX and constrain normal editing in SuperDoc and Word. They do not authenticate users or decide who may access or save the file. Enforce those permissions in a trusted backend. See [Secure your integration](/editor/secure-integration). Use the [`contentControls.setLockMode()` reference](/document-api/reference/content-controls/set-lock-mode/) for the complete operation contract. --- # Replace clauses from your application > Replace a tagged block-level field from application-owned UI. Use a block-level content control as a clause slot. The DOCX owns where the clause appears. Your application owns the available clauses and chooses which content fills that slot. ## Try a clause slot [#try-a-clause-slot] Choose a clause below. The Editor replaces the paragraph inside the control tagged `agreement.confidentiality`. > **Interactive editor: Choose a confidentiality clause** > > The DOCX contains one block-level content control tagged `agreement.confidentiality`. > > - **Mutual:** Each party must protect the other party's confidential information and use it only to perform this agreement. > - **Recipient only:** The receiving party must protect the disclosing party's confidential information and use it only to perform this agreement. > - **Limited use:** The recipient may use confidential information only to evaluate and perform the services described in this agreement. > > Choosing an option replaces the paragraph inside the control. Reset restores the original clause. ## Replace the clause [#replace-the-clause] For a complete project, use the [Add fields example](https://go.superdoc.dev/examples/content-controls?workflow=add). Create the block field, then choose **Use mutual confidentiality clause**. The paragraph changes while its tag remains. Continue with an Editor that has finished loading, and use `superdoc.activeEditor.doc` as `doc`. The [clause sample](/fixtures/clause-library-sample.docx) already contains the required slot; the Quickstart sample does not. Create `src/replace-clause.ts` with this helper and call it from your clause picker: Find the slot by tag, require one block-level control, then replace its content: ```ts import type { BrowserDocumentApi } from 'superdoc/ui'; export async function replaceClause(doc: BrowserDocumentApi, tag: string, content: string) { const { items } = await doc.contentControls.selectByTag({ tag }); if (items.length !== 1) { throw new Error(`Expected one content control tagged "${tag}", found ${items.length}.`); } const [control] = items; if (control.kind !== 'block') { throw new Error(`Content control "${tag}" must be block-level.`); } return doc.contentControls.replaceContent({ target: control.target, content, format: 'text', }); } ``` `replaceContent()` changes the content inside the control. Its tag and ID remain attached to the slot. Inspect the mutation receipt before updating your application UI. In the current Editor, replacement content is rebuilt as text. Keep each replaceable clause to one paragraph. Your application can store approval state, versions, and clause metadata outside the DOCX. Export through your existing [load and save flow](/editor/load-and-save-documents). Reopen the saved DOCX and confirm that the chosen paragraph and its `agreement.confidentiality` tag remain. Continue with [Lock template fields](/editor/content-controls/lock-template-fields) to keep a clause slot or its contents from being removed. Use the [Document API reference](/document-api/reference/content-controls/) for every content-control operation. --- # Keep custom controls in sync > Render selection-aware controls and confirm what each command did. The Bold button from the [first control guide](/editor/custom-ui/controller-setup) changes as you move the selection. It does not inspect the document DOM. It renders command state from the Editor, runs the command, and handles the result. ## Follow one control through the loop [#follow-one-control-through-the-loop] Choose each sample selection below, then press **Bold** when it is available. This simulation isolates the state your control renders and the result it handles. The first control guide runs the same loop in a real Editor. > **Interactive model: watch one control follow the selection** > > The sample selection is simulated. Normal text reports `enabled: true` and `active: false`. Pressing Bold changes `active` to `true` and reports `{ success: true }`. Bold text starts with `active: true`. A locked heading reports `enabled: false`, `active: false`, and a disabled reason. State describes what the control should render; the execution result confirms what the command did. Every custom control repeats the same four steps: 1. Read the command's current state. 2. Render that state in your control. 3. Run the command from the user's action. 4. Inspect the result before reporting success or starting dependent work. ## Render the current state [#render-the-current-state] Each command exposes a small state object: | Field | What your control should do | | ----------- | ----------------------------------------------------------------- | | `enabled` | Disable the action when it cannot run | | `active` | Show whether a toggle is applied | | `value` | Show the current value for a picker | | `reason` | Explain why an action is disabled | | `supported` | Check whether the controller recognizes and can route the command | Use `getState()` for the initial value and `observe()` for later changes. In React, `useSuperDocCommand(id)` does both and rerenders the component when the state changes. Do not derive state from the rendered document DOM. Selection, mode, history, and document content can all change whether a command is available. Disable a control when `enabled` is `false`. Show `reason` when it helps someone recover. Remove the control only when the workflow does not need it. ## Confirm the command result [#confirm-the-command-result] Use `executeAsync()` when your interface or later work depends on the action: | Result | Meaning | | -------------------- | ----------------------------------------------------- | | `false` | The controller could not route the command | | `true` | The host completed the command without a receipt | | `{ success: true }` | The document operation completed | | `{ success: false }` | The receipt explains why the operation did not finish | Command state is a snapshot, so check the result even when `enabled` was `true`. For receipt failure codes, see [Receipts and errors](/document-api/receipts-and-errors). ## Choose commands deliberately [#choose-commands-deliberately] Use `BuiltInCommandId` when a control accepts only SuperDoc commands. Use `CommandId` when it can also accept commands registered by your application. Both types are exported from `superdoc/ui`. `ui.commands.ids` lists the commands known to the current controller, and `ui.commands.has(id)` checks one ID. Use those methods for discovery or capability checks. Choose the actions your workflow needs instead of generating a toolbar from the entire list. Next, [build a custom toolbar](/editor/custom-ui/formatting-controls) that applies this loop to Bold, font, and size. If you do not need to own the toolbar's markup or interaction design, configure the [built-in toolbar](/editor/built-in-ui/configure-the-toolbar) instead. --- # Build a custom comments panel > Render comment threads, create one from selected text, and connect each row to its document anchor. Keep the [built-in comments UI](/editor/built-in-ui/comments) when its layout and actions fit your product. Build a custom panel when your application needs to own the markup or workflow. This guide covers one complete custom workflow: list root threads, add a comment to selected text, navigate to its anchor, and resolve or reopen it. Use [Document API comments](/document-api/comments) for direct operations and the complete comment lifecycle. ## Try the custom workflow [#try-the-custom-workflow] The example replaces only the comments panel. SuperDoc still renders its toolbar and a three-page DOCX. 1. Choose **Show in document** on each existing thread. The document moves between the first and final pages. 2. On the middle page, select `approval criteria`, then choose **Comment on selection** and add a comment. 3. Resolve or reopen a thread. > **Live example: replace the comments panel** > > SuperDoc renders the toolbar and a three-page DOCX while the application renders the comments panel. Existing threads on the first and final pages make `setActive()` and `scrollTo()` visible; the middle page provides text for `createFromCapture()`. The panel observes `ui.comments` and reports resolve or reopen receipts. Setting `ui.comments` to `false` removes the built-in comments panel without removing comments from the document. The panel is application markup driven by `ui.comments`. The document, selection, comment anchors, and mutations still belong to SuperDoc. ## Build the same panel [#build-the-same-panel] Start from [Build your first custom control](/editor/custom-ui/controller-setup). This example keeps `/sample.docx`, the built-in toolbar, and every other built-in surface so comments remain the only new concern. If you already built a custom toolbar, keep it and apply the same comments changes. For Vanilla, replace the contents of `` in `index.html` with the toolbar, Editor, and panel mounts below: ```html
    ``` Replace the setup guide's Editor code: **Vanilla — `src/main.ts`** ```ts import { SuperDoc } from 'superdoc'; import type { UIConfig } from 'superdoc'; import type { CommentsSlice, SelectionCapture, SelectionSlice, WorkflowReceipt } from 'superdoc/ui'; import 'superdoc/style.css'; function getElement(selector: string): T { const element = document.querySelector(selector); if (!element) throw new Error(`Missing comments element: ${selector}`); return element; } const startComment = getElement('#start-comment'); const toolbar = getElement('#toolbar'); const composer = getElement('#comment-composer'); const commentText = getElement('#comment-text'); const addComment = getElement('#add-comment'); const cancelComment = getElement('#cancel-comment'); const commentCount = getElement('#comment-count'); const commentsStatus = getElement('#comments-status'); const commentList = getElement('#comment-list'); const editorUi = { comments: false, toolbar: { container: toolbar, responsiveTo: 'container' }, } satisfies UIConfig; let capture: SelectionCapture | null = null; let stopBindings: (() => void) | null = null; function report(receipt: Awaited, success: string): boolean { commentsStatus.textContent = receipt.success ? success : receipt.failure.message; return receipt.success; } const superdoc = new SuperDoc({ selector: '#editor', document: '/sample.docx', user: { name: 'Alex Rivera', email: 'alex@example.com' }, ui: editorUi, onReady: ({ superdoc: readySuperDoc }) => { stopBindings?.(); capture = null; composer.hidden = true; commentText.value = ''; addComment.disabled = true; const { ui } = readySuperDoc; let selectionIsEmpty = true; let pendingCapture: SelectionCapture | null = null; const updateComposer = () => { commentText.disabled = pendingCapture !== null; addComment.disabled = pendingCapture !== null || !capture || commentText.value.trim().length === 0; cancelComment.disabled = pendingCapture !== null; }; const closeComposer = () => { capture = null; commentText.value = ''; composer.hidden = true; startComment.disabled = selectionIsEmpty; updateComposer(); startComment.focus(); }; const renderSelection = (selection: SelectionSlice) => { selectionIsEmpty = selection.empty; startComment.disabled = selection.empty || !composer.hidden; }; const renderComments = (commentState: CommentsSlice) => { const threads = commentState.items.filter((comment) => !comment.parentCommentId); commentCount.textContent = commentState.listStatus === 'pending' ? 'Loading comments...' : `${threads.length} threads`; commentList.replaceChildren(); for (const thread of threads) { const row = document.createElement('li'); const body = document.createElement('span'); const show = document.createElement('button'); const toggleStatus = document.createElement('button'); body.textContent = thread.text || 'Comment without text'; show.type = 'button'; show.textContent = 'Show in document'; show.addEventListener('click', async () => { if (!ui.comments.setActive(thread.id)) { commentsStatus.textContent = 'The comment is no longer available.'; return; } const result = await ui.comments.scrollTo(thread.id); commentsStatus.textContent = result.success ? 'Showing the comment in the document.' : (result.reason ?? 'The comment could not be shown.'); }); toggleStatus.type = 'button'; toggleStatus.textContent = thread.status === 'resolved' ? 'Reopen' : 'Resolve'; toggleStatus.addEventListener('click', async () => { const receipt = thread.status === 'resolved' ? await ui.comments.reopen(thread.id) : await ui.comments.resolve(thread.id); report(receipt, thread.status === 'resolved' ? 'Comment reopened.' : 'Comment resolved.'); }); row.append(body, show, toggleStatus); commentList.append(row); } }; const captureSelection = () => { capture = ui.selection.capture(); }; const openComposer = (event: MouseEvent) => { if (event.detail === 0) capture = ui.selection.capture(); else capture ??= ui.selection.capture(); if (!capture) { commentsStatus.textContent = 'Select text before starting a comment.'; return; } composer.hidden = false; startComment.disabled = true; commentText.focus(); updateComposer(); }; const createComment = async (event: SubmitEvent) => { event.preventDefault(); if (!capture || pendingCapture) return; // Lock the composer until the receipt settles so a repeated submit cannot // send the same draft twice, and a draft opened later is not closed by // this receipt. pendingCapture = capture; updateComposer(); const receipt = await ui.comments.createFromCapture(pendingCapture, { text: commentText.value.trim() }); const stillCurrent = capture === pendingCapture; pendingCapture = null; if (report(receipt, 'Comment added.') && stillCurrent) closeComposer(); else updateComposer(); }; renderSelection(ui.selection.getSnapshot()); renderComments(ui.comments.getSnapshot()); const stopSelection = ui.selection.observe(renderSelection); const stopComments = ui.comments.observe(renderComments); startComment.addEventListener('mousedown', captureSelection); startComment.addEventListener('click', openComposer); commentText.addEventListener('input', updateComposer); composer.addEventListener('submit', createComment); cancelComment.addEventListener('click', closeComposer); stopBindings = () => { stopSelection(); stopComments(); startComment.removeEventListener('mousedown', captureSelection); startComment.removeEventListener('click', openComposer); commentText.removeEventListener('input', updateComposer); composer.removeEventListener('submit', createComment); cancelComment.removeEventListener('click', closeComposer); }; }, onContentError: ({ error }) => { commentsStatus.textContent = 'The document could not be opened.'; console.error(error); }, onException: ({ error }) => { commentsStatus.textContent = 'The document could not be opened.'; console.error(error); }, }); window.addEventListener('beforeunload', () => { stopBindings?.(); superdoc.destroy(); }); ``` **React — `src/App.tsx`** ```tsx import { useEffect, useRef, useState } from 'react'; import { SuperDocEditor } from '@superdoc/react'; import type { UIConfig } from 'superdoc'; import type { SelectionCapture, WorkflowReceipt } from 'superdoc/ui'; import { SuperDocUIProvider, useSetSuperDoc, useSuperDocComments, useSuperDocSelection, useSuperDocUI, } from 'superdoc/ui/react'; import '@superdoc/react/style.css'; const editorUi = { comments: false } satisfies UIConfig; const currentUser = { name: 'Alex Rivera', email: 'alex@example.com' }; export default function App() { return (
    ); } function Editor() { const setSuperDoc = useSetSuperDoc(); return ( console.error('SuperDoc could not open the document.', error)} onException={({ error }) => console.error('SuperDoc could not open the document.', error)} onReady={({ superdoc }) => setSuperDoc(superdoc)} ui={editorUi} user={currentUser} /> ); } function CommentsPanel() { const ui = useSuperDocUI(); const commentState = useSuperDocComments(); const selection = useSuperDocSelection(); const startCommentRef = useRef(null); const pressedCapture = useRef(null); const restoreFocus = useRef(false); const [capture, setCapture] = useState(null); const [pending, setPending] = useState(false); const [text, setText] = useState(''); const [status, setStatus] = useState('Select text to start a comment.'); const threads = commentState.items.filter((comment) => !comment.parentCommentId); useEffect(() => { if (capture || !restoreFocus.current) return; restoreFocus.current = false; startCommentRef.current?.focus(); }, [capture]); function report(receipt: Awaited, success: string): boolean { setStatus(receipt.success ? success : receipt.failure.message); return receipt.success; } function captureSelection() { pressedCapture.current = ui?.selection.capture() ?? null; } function openComposer(event: React.MouseEvent) { const nextCapture = event.detail === 0 ? (ui?.selection.capture() ?? null) : (pressedCapture.current ?? ui?.selection.capture() ?? null); pressedCapture.current = null; if (!nextCapture) { setStatus('Select text before starting a comment.'); return; } setCapture(nextCapture); } function closeComposer() { restoreFocus.current = true; pressedCapture.current = null; setCapture(null); setText(''); } async function createComment() { if (!ui || !capture || pending) return; // Lock the composer until the receipt settles so a repeated submit cannot // send the same draft twice, and a draft opened later is not closed by // this receipt. setPending(true); const receipt = await ui.comments.createFromCapture(capture, { text: text.trim() }); setPending(false); if (report(receipt, 'Comment added.')) closeComposer(); } async function showThread(commentId: string) { if (!ui?.comments.setActive(commentId)) { setStatus('The comment is no longer available.'); return; } const result = await ui.comments.scrollTo(commentId); setStatus( result.success ? 'Showing the comment in the document.' : (result.reason ?? 'The comment could not be shown.'), ); } if (!ui) return ; return ( ``` Replace `src/main.ts` with: ```ts import { SuperDoc } from 'superdoc'; import { createReviewFindings, isSupersededRefresh } from './review-highlights'; import type { BoundReviewSelection, ReviewFinding } from './review-highlights'; import 'superdoc/style.css'; function element(id: string): T { const node = document.querySelector(`#${id}`); if (!node) throw new Error(`Missing review element: ${id}`); return node; } const ask = element('ask'); const prompt = element('prompt'); const quote = element('quote'); const question = element('question'); const suggested = element('suggested'); const cancel = element('cancel'); const list = element('findings'); const download = element('download'); const status = element('status'); let selection: BoundReviewSelection | null = null; let rows: readonly ReviewFinding[] = []; let pending = false; let ready = false; let stopSelection: (() => void) | undefined; const review = createReviewFindings({ onFindingsChanged: renderFindings, onFindingsError: (error) => { if (isSupersededRefresh(error)) return; renderFindings([]); showError(error); }, }); function showError(error: unknown) { if (isSupersededRefresh(error)) return; status.textContent = error instanceof Error ? error.message : String(error); } function renderControls() { ask.disabled = !ready || pending || !prompt.hidden || superdoc.ui.selection.getSnapshot().empty; download.disabled = !ready || pending; for (const control of prompt.querySelectorAll( 'input, textarea, button', )) control.disabled = pending; } function renderFindings(findings: readonly ReviewFinding[]) { rows = findings; list.replaceChildren(); for (const finding of findings) { const row = document.createElement('li'); const summary = document.createElement('p'); summary.textContent = `Simulated response: ${finding.payload.summary}`; const show = document.createElement('button'); show.textContent = 'Show in document'; show.disabled = pending || finding.anchorStatus !== 'resolved'; show.onclick = () => void run(async () => { const result = await superdoc.ui.metadata.scrollIntoView({ id: finding.id, block: 'center' }); if (!result.success) throw new Error('The finding could not be shown.'); status.textContent = 'Showing the finding.'; }); const suggest = document.createElement('button'); suggest.textContent = finding.payload.suggestionStatus === 'pending' ? 'Check document' : finding.payload.suggestionStatus === 'created' ? 'Suggestion created' : 'Suggest edit'; suggest.disabled = pending || finding.anchorStatus !== 'resolved' || Boolean(finding.payload.suggestionStatus); suggest.onclick = () => void run(async () => { const result = await review.suggest(superdoc.activeEditor?.doc, finding); if (!result.success) throw new Error(result.message); status.textContent = 'Tracked suggestion added. Review it with the built-in toolbar.'; await refresh(); }); row.append(summary, show, suggest); list.append(row); } } async function refresh() { renderFindings(await review.refresh(superdoc.activeEditor?.doc)); } async function run(action: () => Promise) { if (pending || !ready) return; pending = true; renderControls(); renderFindings(rows); try { await action(); } catch (error) { showError(error); } finally { pending = false; renderControls(); renderFindings(rows); } } const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', extensions: [review.extension], ui: { toolbar: { container: '#toolbar' } }, user: { name: 'Review assistant', email: 'review-assistant@example.com' }, onReady: () => { ready = true; stopSelection?.(); stopSelection = superdoc.ui.selection.observe(renderControls); void run(async () => { await refresh(); status.textContent = 'Select one short paragraph, then ask AI.'; }); }, onContentError: ({ error }) => showError(error), onException: ({ error }) => showError(error), }); function captureSelection() { const capture = superdoc.ui.selection.capture(); selection = capture ? review.bindSelection(capture) : null; } ask.addEventListener('mousedown', captureSelection); ask.addEventListener('click', (event) => { if (event.detail === 0) captureSelection(); if (!selection) { status.textContent = 'Select plain body text in one paragraph.'; return; } quote.textContent = selection.capture.quotedText; suggested.value = `${selection.capture.quotedText} (subject to the agreed limit)`; prompt.hidden = false; renderControls(); question.focus(); }); cancel.onclick = () => { selection = null; prompt.hidden = true; renderControls(); }; prompt.onsubmit = (event) => { event.preventDefault(); const boundSelection = selection; if (!boundSelection) return; void run(async () => { const result = await review.save(superdoc.activeEditor?.doc, boundSelection, { question: question.value, quote: boundSelection.capture.quotedText, summary: 'Consider whether this wording needs a clearer limit.', suggestedText: suggested.value, }); if (!result.success) throw new Error(result.message); selection = null; prompt.hidden = true; await refresh(); status.textContent = 'Finding saved in the document.'; }); }; download.onclick = () => void run(async () => { await superdoc.export({ exportType: ['docx'], triggerDownload: true }); status.textContent = 'DOCX downloaded.'; }); window.addEventListener('beforeunload', () => { stopSelection?.(); superdoc.destroy(); }); ``` Select a short phrase, choose **Ask AI about selection**, and save the simulated response as a finding. Then choose **Suggest edit** and **Download DOCX**. The example uses no model service. Edit the suggested wording before saving; the supplied response only demonstrates the workflow, not legal advice. This standalone example opens one document. It disables review actions and download while an action is pending. Keep the replacement guards below if you add file switching. ## Extend an existing selection workflow [#extend-an-existing-selection-workflow] This advanced recipe builds on [Selection and position](/editor/custom-ui/selection-and-viewport). It reuses the captured selection and sample document. Your application supplies the model response and finding panel; the controller below owns saving findings, resolving their anchors, and creating tracked suggestions. Keep the implementation to three actions: save a finding, show its text, and suggest an edit. Add more controls only when your workflow needs them. Create `src/review-highlights.ts` and `src/review-highlights.css` from the [controller implementation](#controller-implementation) at the end of this guide. The integration fragments below belong in your existing selection workflow; they are not a second standalone Editor. Create one controller for each Editor. Pass its extension through `extensions`, not the removed `editorExtensions` option. Set `user` to the identity that should author tracked suggestions from this Editor: ```ts import { createReviewFindings, isSupersededRefresh } from './review-highlights'; function showRefreshError(error: unknown): void { if (isSupersededRefresh(error)) return; renderFindingPanel([]); showError(error); } const reviewFindings = createReviewFindings({ // Called after an edit moves a finding's anchor and the controller re-resolves it. onFindingsChanged: renderFindingPanel, onFindingsError: showRefreshError, }); const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', extensions: [reviewFindings.extension], user: { name: 'Review assistant', email: 'review-assistant@example.com' }, onReady: ({ superdoc }) => { void reviewFindings.refresh(superdoc.activeEditor?.doc).then(renderFindingPanel, showRefreshError); }, }); ``` Only the newest `refresh()` publishes. `showRefreshError` ignores an older call's expected rejection and reports real failures through your application's `showError` handler. Bind the selection before sending the model request. The controller checks this binding before issuing a write and rejects it if the document has changed: ```ts const reviewSelection = reviewFindings.bindSelection(capture); if (!reviewSelection) { showError('Select plain body text before asking AI.'); return; } ``` Do not call `replaceFile()` while `save()`, `suggest()`, or `remove()` is pending. Await pending review actions before replacing the document, and prevent new review actions until replacement finishes. Binding rejects stale work before dispatch; it does not cancel a write already sent to the Editor. `expectedRevision` guards edits within one document, not document identity across replacements. Every action also carries the `doc` it runs against. Once `refresh()` has named this controller's document, an action carrying a different Editor's `doc` is refused: two Editors showing copies of one DOCX share block IDs, so the wrong document would accept the target and attach the finding to unrelated content. `refresh()` may bind a different document — that is how a replacement is adopted — but doing so retires the rows and captures from the previous binding. Re-render the panel from the value the new `refresh()` returns; a row or selection held across the swap is refused even though it carries the same activation. Before replacement, call `renderFindingPanel(await reviewFindings.refresh(null))` to clear the panel and retire pending reads. After `replaceFile()` resolves, refresh `superdoc.activeEditor?.doc` and pass the result to `renderFindingPanel`, using `showRefreshError` for failures. Do not rely on the initial `onReady` callback to load the replacement's findings. Refresh on both outcomes, not only on success. `replaceFile()` reports its result through `state`: a value other than `review-ready` or `editing-ready` means the replacement was rejected and the previous document was reopened and remounted. The panel has already been cleared by then, so skipping the refresh leaves that restored document with an empty panel and leaves the controller with no document to run its mutation refreshes against. Save the model's analysis and proposed replacement in one typed payload. `save()` persists the record; it does not re-render your panel, so refresh on success and show the message on failure: ```ts const doc = superdoc.activeEditor?.doc; const result = await reviewFindings.save(doc, reviewSelection, { question, quote: reviewSelection.capture.quotedText, summary: answer.summary, suggestedText: answer.suggestedText, }); if (result.success) { void reviewFindings.refresh(doc).then(renderFindingPanel, showRefreshError); } else { showError(result.message); } ``` `save()` refuses a selection that the reader edited while the model request was in flight, because the captured offsets no longer describe the same text. Ask for the selection again when that happens. When the reader chooses **Suggest edit**, resolve the finding's current anchor and create a tracked replacement: ```ts const result = await reviewFindings.suggest(superdoc.activeEditor?.doc, finding); if (!result.success) { showError(result.message); } ``` Use `superdoc.ui.metadata.scrollIntoView({ id: finding.id, block: 'center' })` for **Show in document**. Disable that control, and **Suggest edit**, while `finding.anchorStatus` is not `resolved`: neither can act on a finding whose anchor the document no longer contains. Also disable **Suggest edit** when `finding.payload.suggestionStatus` is set. The controller saves `pending` before requesting the edit, then `created` after it succeeds. This status travels with the exported DOCX, so reopening does not offer the same suggestion again. If a request is interrupted, show **Check document** for `pending`: the edit may have succeeded even though its final status was not saved. Inspect the document before creating a new finding. Use the controller's `remove()` method to delete the finding without deleting its text or any tracked suggestion already created from it. Like `save()`, it returns a result rather than re-rendering, so refresh the panel the same way: ```ts const doc = superdoc.activeEditor?.doc; const result = await reviewFindings.remove(doc, finding); if (result.success) { void reviewFindings.refresh(doc).then(renderFindingPanel, showRefreshError); } else { showError(result.message); } ``` The sample accepts one-paragraph body-text selections, and enforces it: `save()` rejects a selection that spans paragraphs, because `suggest()` can only replace text inside one. See [Store application data in DOCX](/document-api/application-data) for all metadata target rules, collaboration behavior, and the application-data trust boundary. ## Verify the result [#verify-the-result] Save one finding and confirm that a risk mark and panel card appear. Choose **Suggest edit** and confirm that SuperDoc replaces the mark with a tracked revision. The finding card should remain available to explain the change. If the same application action will appear in more than one control, use [Custom commands](/editor/custom-ui/custom-commands) to share its behavior and state. Otherwise, return to the [Custom UI overview](/editor/custom-ui/overview). ## Controller implementation [#controller-implementation] The controller uses `defineSuperDocExtension()` to register its visual layer and document lifecycle handlers. It validates stored data, re-resolves anchors after edits, and rejects stale selections. Keep these checks when adapting it. Save as `src/review-highlights.ts`: ```ts import { defineSuperDocExtension } from 'superdoc'; import type { SuperDocExtension, SuperDocVisualHandle, SuperDocVisualTarget } from 'superdoc'; import type { BrowserDocumentApi, SelectionCapture, SelectionTarget, TextTarget } from 'superdoc/ui'; import './review-highlights.css'; const FINDING_NAMESPACE = 'urn:example:ai-review-findings:1'; export type ReviewFindingPayload = { kind: 'risk'; question: string; quote: string; summary: string; suggestedText?: string; suggestionStatus?: 'pending' | 'created'; }; export type ReviewFinding = { anchorStatus: 'orphan' | 'resolved'; id: string; /** * The activation that listed this row. Carried on the row itself so an immutable copy * (`{ ...finding }`) stays valid, while a row retained across a document swap does not — * a replacement DOCX can reuse metadata IDs, so the ID alone is not proof of provenance. */ sourceToken: string; /** * The document binding that listed this row. `sourceToken` proves the activation, not the * document: `refresh()` can bind a different Editor's `doc` within one activation, and two * copies of a DOCX share metadata IDs. */ documentEpoch: number; payload: ReviewFindingPayload; /** A tracked suggestion was created from this finding. */ suggested: boolean; }; export type BoundReviewSelection = { capture: SelectionCapture; }; export type ReviewFindingsOptions = { /** Called with the current rows whenever an edit forces the findings to re-resolve. */ onFindingsChanged?: (findings: readonly ReviewFinding[]) => void; /** Called when a re-resolve fails and the stale highlights have been cleared. */ onFindingsError?: (error: unknown) => void; }; type FindingActionResult = { success: true; id: string } | { success: false; message: string }; type AttachableCapture = SelectionCapture & { target: TextTarget }; /** * `ranges.resolve()` truncates its verification preview past this many UTF-16 units and sets * `preview.truncated`, which `suggest()` treats as unverifiable. Saving a longer capture would * strand a finding that can never be applied. */ const MAX_VERIFIABLE_CAPTURE_LENGTH = 200; const SUPERSEDED_REFRESH = 'ReviewFindingsRefreshSuperseded'; /** A refresh another call or another document already owns. Safe for a caller to ignore. */ function supersededRefresh(message: string): Error { const error = new Error(message); error.name = SUPERSEDED_REFRESH; return error; } export function isSupersededRefresh(error: unknown): boolean { return error instanceof Error && error.name === SUPERSEDED_REFRESH; } const STALE_SELECTION_MESSAGE = 'The document changed after this text was selected. Select the text again.'; const STALE_FINDING_MESSAGE = 'The document changed after this finding was listed. Refresh the findings.'; function isReviewFindingPayload(value: unknown): value is ReviewFindingPayload { if (typeof value !== 'object' || value === null) return false; const candidate = value as Partial; return ( candidate.kind === 'risk' && typeof candidate.question === 'string' && typeof candidate.quote === 'string' && typeof candidate.summary === 'string' && (candidate.suggestedText === undefined || typeof candidate.suggestedText === 'string') && (candidate.suggestionStatus === undefined || candidate.suggestionStatus === 'pending' || candidate.suggestionStatus === 'created') ); } function toSelectionTarget(target: SelectionTarget | TextTarget): SelectionTarget | null { if (target.kind === 'selection') return target.coordinateSpace === 'tracked' ? null : target; if (target.coordinateSpace === 'tracked' || target.segments.length === 0) return null; const first = target.segments[0]; const last = target.segments[target.segments.length - 1]; if (first.blockId !== last.blockId) return null; return { kind: 'selection', start: { kind: 'text', blockId: first.blockId, offset: first.range.start }, end: { kind: 'text', blockId: last.blockId, offset: last.range.end }, ...(target.story ? { story: target.story } : {}), }; } function toVisualTargets(target: SelectionTarget | TextTarget): SuperDocVisualTarget[] { if (target.kind === 'text') { return target.segments.map((segment) => ({ kind: 'text', blockId: segment.blockId, range: { start: segment.range.start, end: segment.range.end }, })); } if (target.start.kind !== 'text' || target.end.kind !== 'text') return []; if (target.start.blockId !== target.end.blockId) return []; return [ { kind: 'text', blockId: target.start.blockId, range: { start: target.start.offset, end: target.end.offset }, }, ]; } function canAttach(capture: SelectionCapture | null | undefined): capture is AttachableCapture { const target = capture?.status === 'ready' ? capture.target : null; if (!target || target.coordinateSpace === 'tracked') return false; if (target.story !== undefined && target.story.storyType !== 'body') return false; if (!target.segments.every((segment) => segment.range.start < segment.range.end)) return false; // Save only what `suggest()` can act on. `toSelectionTarget()` rejects a target that // spans paragraphs, so accepting one here would persist a finding that can never be // suggested. It also rejects an empty segment list. if (toSelectionTarget(target) === null) return false; const length = target.segments.reduce((total, segment) => total + (segment.range.end - segment.range.start), 0); return length <= MAX_VERIFIABLE_CAPTURE_LENGTH; } export function createReviewFindings(options: ReviewFindingsOptions = {}) { let highlightLayer: SuperDocVisualHandle | null = null; const suggestedFindingIds = new Set(); const visualTargetsByFindingId = new Map(); // A replacement document can reuse block IDs, so each action is bound to the extension activation that produced it // and to the document that activation last listed. // Disposal invalidates the checks before dispatch, but cannot cancel issued writes. // The application must await pending review actions before replacing the document. let activeSource: object | null = null; const sourceBindings = new WeakMap(); // Per-activation token stamped onto every listed row. Survives immutable copies and, unlike // the metadata ID, is not reused by a replacement document. let activeSourceToken = ''; // A bound capture holds frozen block offsets. Any committed edit can move the text under // them, and the document identity does not change, so record the edit count at bind time // and refuse to attach across it. let mutationEpoch = 0; const boundMutationEpoch = new WeakMap(); // Only the newest refresh may publish. Two overlapping calls both pass the source check, // and the older one finishing last would otherwise restore its stale listing. let refreshSequence = 0; let lastRefreshedDoc: BrowserDocumentApi | null = null; // Increments whenever `refresh()` binds a different document. Stamped onto every listed row // and bound capture so work from an earlier binding fails its provenance check. let documentEpoch = 0; const boundDocumentEpoch = new WeakMap(); /** * One mutation refresh at a time, with at most one queued behind it. A typing burst * otherwise starts a listing plus a `get` and `resolve` per finding on every keystroke; * the sequence guard stops stale results publishing but cannot cancel the requests. */ let mutationRefreshRunning = false; let mutationRefreshQueued = false; function runMutationRefresh(): void { const doc = lastRefreshedDoc; if (!doc) return; if (mutationRefreshRunning) { refreshSequence += 1; mutationRefreshQueued = true; return; } mutationRefreshRunning = true; void refresh(doc) .then( (findings) => options.onFindingsChanged?.(findings), (error) => { if (isSupersededRefresh(error)) return; options.onFindingsError?.(error); }, ) .finally(() => { mutationRefreshRunning = false; if (!mutationRefreshQueued) return; mutationRefreshQueued = false; runMutationRefresh(); }); } function paintFindings(layer = highlightLayer) { layer?.replace( [...visualTargetsByFindingId].flatMap(([id, targets]) => (suggestedFindingIds.has(id) ? [] : targets)), ); } const extension: SuperDocExtension = defineSuperDocExtension({ id: 'example.aiReviewFindings', activate(ctx) { const source = {}; activeSource = source; suggestedFindingIds.clear(); visualTargetsByFindingId.clear(); // Unique across controller instances too: two Editors showing copies of the same DOCX // share metadata IDs, so a per-controller counter would collide on their first // activations. activeSourceToken = globalThis.crypto.randomUUID(); lastRefreshedDoc = null; const layer = ctx.visuals.highlight('findings', { className: 'review-finding-highlight', scope: 'text', }); highlightLayer = layer; ctx.disposables.add(layer); // Cached visual targets are numeric. An edit moves the durable metadata anchor but not // the paint, so re-resolve instead of leaving a highlight over different text. ctx.disposables.add( ctx.onMutation({ affects: ['text', 'block'] }, () => { mutationEpoch += 1; if (activeSource !== source) return; runMutationRefresh(); }), ); return { dispose() { if (activeSource === source) activeSource = null; if (highlightLayer === layer) highlightLayer = null; }, }; }, }); function bindSelection(capture: SelectionCapture): BoundReviewSelection | null { const source = activeSource; if (!source || !canAttach(capture)) return null; const selection = { capture } satisfies BoundReviewSelection; sourceBindings.set(selection, source); boundMutationEpoch.set(selection, mutationEpoch); boundDocumentEpoch.set(selection, documentEpoch); return selection; } function sourceIsCurrent(value: BoundReviewSelection | ReviewFinding, doc: BrowserDocumentApi) { if (activeSource === null) return false; // The application supplies `doc` on every call, so an action can arrive carrying a second // Editor's document. Copies of one DOCX reuse block IDs, so that target would resolve and // attach this finding to unrelated content. Once a refresh has named this activation's // document, refuse every other one; before that there is nothing to contradict. if (lastRefreshedDoc !== null && lastRefreshedDoc !== doc) return false; if ('sourceToken' in value) { return value.sourceToken === activeSourceToken && value.documentEpoch === documentEpoch; } return sourceBindings.get(value) === activeSource && boundDocumentEpoch.get(value) === documentEpoch; } function captureIsCurrent(value: BoundReviewSelection, doc: BrowserDocumentApi) { return sourceIsCurrent(value, doc) && boundMutationEpoch.get(value) === mutationEpoch; } async function refresh(doc: BrowserDocumentApi | null | undefined): Promise { const sequence = (refreshSequence += 1); // Rebinding is legitimate — a replacement document, or a swap racing an in-flight refresh — // but rows and captures from the previous binding must not survive it. The activation token // cannot see this: it is unchanged by a document swap within one activation. if ((doc ?? null) !== lastRefreshedDoc) documentEpoch += 1; lastRefreshedDoc = doc ?? null; if (!doc) { visualTargetsByFindingId.clear(); suggestedFindingIds.clear(); highlightLayer?.clear(); return []; } const expectedSource = activeSource; const sourceToken = activeSourceToken; const epoch = documentEpoch; const layer = highlightLayer; try { const listed = await doc.metadata.list({ namespace: FINDING_NAMESPACE }); const rows = await Promise.all( listed.items.map(async (item) => { const [record, resolved] = await Promise.all([ doc.metadata.get({ id: item.id }), doc.metadata.resolve({ id: item.id }), ]); if (!record || !isReviewFindingPayload(record.payload)) return null; return { finding: { id: item.id, anchorStatus: item.anchorStatus, payload: record.payload, suggested: record.payload.suggestionStatus === 'created' || suggestedFindingIds.has(item.id), sourceToken, documentEpoch: epoch, } satisfies ReviewFinding, visualTargets: resolved ? toVisualTargets(resolved.target) : [], }; }), ); if (expectedSource !== activeSource || layer !== highlightLayer) { throw supersededRefresh('The document changed while its findings were loading. Refresh the findings again.'); } if (sequence !== refreshSequence) { throw supersededRefresh('A newer refresh replaced this one. Render the newer result instead.'); } visualTargetsByFindingId.clear(); for (const row of rows) { if (row) visualTargetsByFindingId.set(row.finding.id, row.finding.suggested ? [] : row.visualTargets); } paintFindings(layer); return rows.flatMap((row) => { if (!row) return []; return [row.finding]; }); } catch (error) { if (isSupersededRefresh(error)) throw error; if (expectedSource !== activeSource || layer !== highlightLayer || sequence !== refreshSequence) { throw supersededRefresh('A newer document or refresh owns these findings.'); } visualTargetsByFindingId.clear(); paintFindings(layer); throw error; } } async function save( doc: BrowserDocumentApi | null | undefined, context: BoundReviewSelection | null, payload: Omit, ): Promise { const capture = context?.capture; if (!doc || !canAttach(capture) || !capture.target) { return { success: false, message: 'Select up to 200 characters inside one paragraph of plain body text before saving the finding.', }; } if (!context || !captureIsCurrent(context, doc)) { return { success: false, message: STALE_SELECTION_MESSAGE }; } try { const overlapping = await doc.metadata.list({ within: capture.target }); if (!captureIsCurrent(context, doc)) { return { success: false, message: STALE_SELECTION_MESSAGE }; } if (overlapping.items.length > 0) { return { success: false, message: 'That text already has an attached record.' }; } // The record anchors to `capture.target`, so a quote that disagrees with the captured // text makes every later `suggest()` report a changed document and can never be applied. // Refuse it here rather than persisting a finding that is dead on arrival. if (payload.quote !== capture.quotedText) { return { success: false, message: 'The quote does not match the selected text. Save the finding from the current selection.', }; } const receipt = await doc.metadata.attach( { namespace: FINDING_NAMESPACE, target: capture.target, payload: { kind: 'risk', question: payload.question, quote: payload.quote, summary: payload.summary, ...(payload.suggestedText !== undefined ? { suggestedText: payload.suggestedText } : {}), } satisfies ReviewFindingPayload, }, { expectedRevision: overlapping.evaluatedRevision }, ); if (!receipt.success) return { success: false, message: receipt.failure.message }; return { success: true, id: receipt.id }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : String(error) }; } } async function suggest( doc: BrowserDocumentApi | null | undefined, finding: ReviewFinding, ): Promise { if (!doc) return { success: false, message: 'The document is not ready.' }; // `''` is a valid suggestion: it proposes deleting the anchored text. Only an absent // value means the finding carries no edit, which is how the payload type and `save()` // already treat it. if (finding.payload.suggestedText === undefined) { return { success: false, message: 'This finding does not include a suggested edit.' }; } // A rendered row keeps its suggestedText after the tracked change is created, so a panel // that re-renders from `refresh()` would otherwise offer the action a second time. if (suggestedFindingIds.has(finding.id)) { return { success: false, message: 'This finding already has a tracked suggestion.' }; } if (!sourceIsCurrent(finding, doc)) { return { success: false, message: STALE_FINDING_MESSAGE }; } let releaseBeforeReplace: (() => Promise) | undefined; try { const current = await doc.metadata.list({ namespace: FINDING_NAMESPACE }); if (!sourceIsCurrent(finding, doc)) { return { success: false, message: STALE_FINDING_MESSAGE }; } if (!current.items.some((item) => item.id === finding.id)) { return { success: false, message: 'That finding is no longer available.' }; } // The panel row can predate another writer's update. `expectedRevision` accepts the // newer revision, so read the stored payload back rather than replacing with the // suggestion the panel happens to be holding. const [record, resolved] = await Promise.all([ doc.metadata.get({ id: finding.id }), doc.metadata.resolve({ id: finding.id }), ]); if (!sourceIsCurrent(finding, doc)) { return { success: false, message: STALE_FINDING_MESSAGE }; } if (!record || !isReviewFindingPayload(record.payload)) { return { success: false, message: 'That finding is no longer available.' }; } if (record.payload.suggestionStatus) { return { success: false, message: 'A suggestion was already requested. Check the document before creating another.', }; } const suggestedText = record.payload.suggestedText; if (suggestedText === undefined) { return { success: false, message: 'This finding no longer includes a suggested edit.' }; } let target = resolved ? toSelectionTarget(resolved.target) : null; if (!target) { return { success: false, message: 'The finding is not anchored to one editable paragraph.' }; } const releaseReservation = async () => { releaseBeforeReplace = undefined; if (!sourceIsCurrent(finding, doc)) return; const current = await doc.metadata.list({ namespace: FINDING_NAMESPACE }); const latest = await doc.metadata.get({ id: finding.id }); if (!sourceIsCurrent(finding, doc)) return; if ( latest && isReviewFindingPayload(latest.payload) && latest.payload.suggestionStatus === 'pending' && latest.payload.suggestedText === suggestedText ) { const payload = { ...latest.payload }; delete payload.suggestionStatus; const released = await doc.metadata.update( { id: finding.id, payload }, { expectedRevision: current.evaluatedRevision }, ); if (!released.success) throw new Error(released.failure.message); } }; // Reserve the action durably before editing: an interrupted call must not become a retry after reopening. const reserved = await doc.metadata.update( { id: finding.id, payload: { ...record.payload, suggestionStatus: 'pending' } }, { expectedRevision: current.evaluatedRevision }, ); if (!reserved.success) return { success: false, message: reserved.failure.message }; releaseBeforeReplace = releaseReservation; if (!sourceIsCurrent(finding, doc)) return { success: false, message: STALE_FINDING_MESSAGE }; const afterReservation = await doc.metadata.list({ namespace: FINDING_NAMESPACE }); const pending = await doc.metadata.get({ id: finding.id }); const currentAnchor = await doc.metadata.resolve({ id: finding.id }); if (!sourceIsCurrent(finding, doc)) return { success: false, message: STALE_FINDING_MESSAGE }; // The reservation only pins status. Another writer can still change the quote before // these reads, and the verification below compares against the pre-reservation quote, // so a mismatch here would apply an edit the stored finding no longer describes. if ( !pending || !isReviewFindingPayload(pending.payload) || pending.payload.suggestionStatus !== 'pending' || pending.payload.suggestedText !== suggestedText || pending.payload.quote !== record.payload.quote ) { // A quote-only change leaves our reservation in place, so clear it or the finding stays // durably pending and its action stays disabled. releaseReservation() no-ops when the // pending row is no longer ours. await releaseReservation(); return { success: false, message: 'The finding changed while requesting its suggestion. Check the document.' }; } target = currentAnchor ? toSelectionTarget(currentAnchor.target) : null; if (!target) { await releaseReservation(); return { success: false, message: 'The finding is no longer anchored to editable text.' }; } const range = await doc.ranges.resolve({ start: { kind: 'point', point: target.start }, end: { kind: 'point', point: target.end }, expectedRevision: afterReservation.evaluatedRevision, }); if (!sourceIsCurrent(finding, doc)) return { success: false, message: STALE_FINDING_MESSAGE }; if (range.preview.truncated || range.preview.text !== record.payload.quote) { await releaseReservation(); return { success: false, message: range.preview.truncated ? 'The text is too long to verify. Ask AI about a shorter selection.' : 'The text changed since this finding was saved. Ask AI about the current text.', }; } releaseBeforeReplace = undefined; const receipt = await doc.replace( { target, text: suggestedText }, { changeMode: 'tracked', expectedRevision: afterReservation.evaluatedRevision }, ); if (!receipt.success) { await releaseReservation(); return { success: false, message: receipt.failure?.message ?? 'The tracked suggestion could not be added.' }; } if (!sourceIsCurrent(finding, doc)) return { success: false, message: STALE_FINDING_MESSAGE }; const afterEdit = await doc.metadata.list({ namespace: FINDING_NAMESPACE }); const latest = await doc.metadata.get({ id: finding.id }); if (!sourceIsCurrent(finding, doc)) return { success: false, message: STALE_FINDING_MESSAGE }; if (!latest || !isReviewFindingPayload(latest.payload)) { return { success: false, message: 'The edit was added, but its finding is no longer available.' }; } // Promoting to `created` records which quote the tracked replacement came from, so a quote // rewritten between `doc.replace()` and this read must not be marked as its source. if ( latest.payload.suggestionStatus !== 'pending' || latest.payload.suggestedText !== suggestedText || latest.payload.quote !== record.payload.quote ) { return { success: false, message: 'The edit was added, but the finding changed. Check the document.' }; } const recorded = await doc.metadata.update( { id: finding.id, payload: { ...latest.payload, suggestionStatus: 'created' } }, { expectedRevision: afterEdit.evaluatedRevision }, ); if (!recorded.success) return { success: false, message: 'The edit was added, but its status could not be saved. Check the document.', }; if (!sourceIsCurrent(finding, doc)) return { success: false, message: STALE_FINDING_MESSAGE }; suggestedFindingIds.add(finding.id); paintFindings(); return { success: true, id: finding.id }; } catch (error) { try { await releaseBeforeReplace?.(); } catch { return { success: false, message: 'The suggestion was not sent, but its pending status could not be cleared. Check the document.', }; } return { success: false, message: error instanceof Error ? error.message : String(error) }; } finally { if (sourceIsCurrent(finding, doc)) runMutationRefresh(); } } async function remove( doc: BrowserDocumentApi | null | undefined, finding: ReviewFinding, ): Promise { if (!doc) return { success: false, message: 'The document is not ready.' }; if (!sourceIsCurrent(finding, doc)) { return { success: false, message: STALE_FINDING_MESSAGE }; } try { const current = await doc.metadata.list({ namespace: FINDING_NAMESPACE }); if (!sourceIsCurrent(finding, doc)) { return { success: false, message: STALE_FINDING_MESSAGE }; } if (!current.items.some((item) => item.id === finding.id)) { return { success: false, message: 'That finding is no longer available.' }; } const receipt = await doc.metadata.remove({ id: finding.id }, { expectedRevision: current.evaluatedRevision }); if (!receipt.success) return { success: false, message: receipt.failure.message }; suggestedFindingIds.delete(finding.id); visualTargetsByFindingId.delete(finding.id); paintFindings(); return { success: true, id: receipt.id }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : String(error) }; } } return { bindSelection, extension, refresh, remove, save, suggest }; } ``` Save as `src/review-highlights.css`: ```css .review-finding-highlight { background: rgb(180 35 24 / 12%); box-shadow: inset 0 -2px #b42318; } ``` --- # Build custom find and replace controls > Render Search in your application while SuperDoc finds, highlights, and replaces document text. Use [built-in Search](/editor/built-in-ui/search-and-replace) when its standard controls fit. Build custom controls when Search belongs in your application shell or needs different interaction design. This guide replaces only the Search surface. SuperDoc continues to find and highlight matches, move the active match into view, and apply replacements. ## Try application-owned Search [#try-application-owned-search] The example starts with eight case-insensitive matches for `Client` across three pages. 1. Choose **Next** and watch the active match move through the document. 2. Turn on **Match case**. The count changes from eight to seven. 3. Replace the active match with `Customer`. The document and count update together. > **Live example: drive Search from application-owned controls** > > SuperDoc renders its toolbar and a three-page DOCX while the application renders Find, Match case, Previous, Next, and Replace controls. The example starts with eight case-insensitive `Client` matches. `ui.search` paints and navigates the matches, while the panel observes the same session for its active index, total, and `canReplace` state. The document starts at 80% on wide layouts and fits to width on narrow layouts. The controls call `ui.search` instead of reading the rendered document. The same Search session paints the highlights, tracks the active match, and reports whether replacement is currently available. ## Build the same controls [#build-the-same-controls] Start from [Build your first custom control](/editor/custom-ui/controller-setup). Download the sample to your app's `public` directory as `search-sample.docx`: [Download the Search sample](/fixtures/search-sample.docx): Three pages and one pending deletion · DOCX For Vanilla, replace the contents of `` in `index.html` with the controls and Editor below: ```html

    ``` Replace the setup guide's Editor code: **Vanilla — `src/main.ts`** ```ts import { SuperDoc } from 'superdoc'; import type { UIConfig } from 'superdoc'; import type { SearchSnapshot, WorkflowActionResult } from 'superdoc/ui'; import 'superdoc/style.css'; function getElement(selector: string): T { const element = document.querySelector(selector); if (!element) throw new Error(`Missing search control: ${selector}`); return element; } const searchForm = getElement('#search-controls'); const query = getElement('#search-query'); const matchCase = getElement('#match-case'); const includeDeletions = getElement('#include-deletions'); const previous = getElement('#previous-match'); const next = getElement('#next-match'); const count = getElement('#search-count'); const replacement = getElement('#replacement'); const replace = getElement('#replace-match'); const replaceAll = getElement('#replace-all'); const status = getElement('#search-status'); const editorUi = { search: false } satisfies UIConfig; let stopBindings: (() => void) | null = null; let replacementPending = false; let actionStatus = ''; const superdoc = new SuperDoc({ selector: '#editor', document: '/search-sample.docx', ui: editorUi, onReady: ({ superdoc: readySuperDoc }) => { stopBindings?.(); replacementPending = false; actionStatus = ''; const { search } = readySuperDoc.ui; const render = (snapshot: SearchSnapshot) => { const hasMatches = snapshot.total > 0; query.disabled = replacementPending; matchCase.disabled = replacementPending; includeDeletions.disabled = replacementPending; previous.disabled = !hasMatches || replacementPending; next.disabled = !hasMatches || replacementPending; replacement.disabled = replacementPending; // `canReplace` is document mutability; a replacement still needs a match. replace.disabled = !hasMatches || !snapshot.canReplace || replacementPending; // A truncated match set can replace the active match but not all of them. replaceAll.disabled = !snapshot.canReplaceAll || replacementPending; count.textContent = hasMatches ? snapshot.activeIndex >= 0 ? `${snapshot.activeIndex + 1} of ${snapshot.total}` : `${snapshot.total} matches` : 'No matches'; status.textContent = snapshot.reason ?? actionStatus; }; const runSearch = () => { actionStatus = ''; if (!query.value) { search.clear(); return; } search.find(query.value, { caseSensitive: matchCase.checked, includeTrackedDeletions: includeDeletions.checked, }); }; const report = (result: WorkflowActionResult) => { actionStatus = result.ok ? '' : (result.reason ?? 'The search action is unavailable.'); status.textContent = actionStatus; }; const runReplacement = async (action: () => WorkflowActionResult | Promise) => { if (replacementPending) return; replacementPending = true; render(search.getSnapshot()); try { report(await action()); } finally { replacementPending = false; render(search.getSnapshot()); } }; const replaceCurrent = () => runReplacement(() => search.replace(replacement.value)); const replaceEveryMatch = () => runReplacement(() => search.replaceAll(replacement.value)); const goPrevious = () => report(search.previous()); const goNext = () => report(search.next()); const preventSubmit = (event: SubmitEvent) => event.preventDefault(); const stopSearch = search.observe(render); searchForm.addEventListener('submit', preventSubmit); query.addEventListener('input', runSearch); matchCase.addEventListener('change', runSearch); includeDeletions.addEventListener('change', runSearch); // Text typed before the document opened has no session yet. Run it now. runSearch(); previous.addEventListener('click', goPrevious); next.addEventListener('click', goNext); replace.addEventListener('click', replaceCurrent); replaceAll.addEventListener('click', replaceEveryMatch); stopBindings = () => { stopSearch(); search.close(); searchForm.removeEventListener('submit', preventSubmit); query.removeEventListener('input', runSearch); matchCase.removeEventListener('change', runSearch); includeDeletions.removeEventListener('change', runSearch); previous.removeEventListener('click', goPrevious); next.removeEventListener('click', goNext); replace.removeEventListener('click', replaceCurrent); replaceAll.removeEventListener('click', replaceEveryMatch); }; }, onContentError: ({ error }) => { status.textContent = 'The document could not be opened.'; console.error(error); }, onException: ({ error }) => { status.textContent = 'The document could not be opened.'; console.error(error); }, }); window.addEventListener('beforeunload', () => { stopBindings?.(); superdoc.destroy(); }); ``` **React — `src/App.tsx`** ```tsx import { useEffect, useState } from 'react'; import { SuperDocEditor } from '@superdoc/react'; import type { UIConfig } from 'superdoc'; import type { WorkflowActionResult } from 'superdoc/ui'; import { SuperDocUIProvider, useSetSuperDoc, useSuperDocSearch, useSuperDocUI } from 'superdoc/ui/react'; import '@superdoc/react/style.css'; const editorUi = { search: false } satisfies UIConfig; export default function App() { return (
    ); } function Editor() { const setSuperDoc = useSetSuperDoc(); return ( console.error('SuperDoc could not open the document.', error)} onException={({ error }) => console.error('SuperDoc could not open the document.', error)} onReady={({ superdoc }) => setSuperDoc(superdoc)} ui={editorUi} /> ); } function SearchControls() { const ui = useSuperDocUI(); const search = useSuperDocSearch(); const [query, setQuery] = useState(''); const [replacement, setReplacement] = useState(''); const [matchCase, setMatchCase] = useState(false); const [includeDeletions, setIncludeDeletions] = useState(false); const [replacementPending, setReplacementPending] = useState(false); const [status, setStatus] = useState(''); // Runs on mount, whenever the query or its options change, and again once // the Editor becomes ready, so text typed while loading still searches. useEffect(() => { if (!ui) return; setStatus(''); if (!query) { ui.search.clear(); return; } ui.search.find(query, { caseSensitive: matchCase, includeTrackedDeletions: includeDeletions }); }, [includeDeletions, matchCase, query, ui]); useEffect(() => () => ui?.search.close(), [ui]); function report(result: WorkflowActionResult) { setStatus(result.ok ? '' : (result.reason ?? 'The search action is unavailable.')); } function goPrevious() { if (ui && !replacementPending) report(ui.search.previous()); } function goNext() { if (ui && !replacementPending) report(ui.search.next()); } async function runReplacement(action: () => WorkflowActionResult | Promise) { if (replacementPending) return; setReplacementPending(true); try { report(await action()); } finally { setReplacementPending(false); } } function replaceCurrent() { if (ui) return runReplacement(() => ui.search.replace(replacement)); } function replaceEveryMatch() { if (ui) return runReplacement(() => ui.search.replaceAll(replacement)); } const hasMatches = search.total > 0; const matchCount = search.activeIndex >= 0 ? `${search.activeIndex + 1} of ${search.total}` : `${search.total} matches`; return (
    event.preventDefault()} role='search'> setQuery(event.target.value)} placeholder='Find in document' type='search' value={query} /> {hasMatches ? matchCount : 'No matches'} setReplacement(event.target.value)} placeholder='Replacement' type='text' value={replacement} />

    {search.reason ?? status}

    ); } ``` `ui: { search: false }` hides SuperDoc's Search surface. It does not disable `superdoc.ui.search`. This example keeps the controls visible. If your surface opens on demand, your application also owns its keyboard shortcut and focus behavior. ## Keep controls synchronized [#keep-controls-synchronized] `find()` starts a visual Search session. SuperDoc highlights the matches, and `next()` or `previous()` moves the active match into view. `observe()` in Vanilla and `useSuperDocSearch()` in React keep the result count and replacement controls synchronized as the session settles. `canReplace` reflects the current document mode, not the match count, so it can be `true` before a query runs. Keep **Replace** disabled unless there is a match, `canReplace` is `true`, and no replacement is pending. Gate **Replace all** on `canReplaceAll` instead: it already requires a match, and a session with more matches than SuperDoc enumerates can still replace the active match but refuses to replace every match. Check the action result because the document can change after the controls render. ## Include pending deletions when needed [#include-pending-deletions-when-needed] Pending tracked deletions are excluded by default. Both examples expose the option as an **Include pending deletions** checkbox that passes `includeTrackedDeletions` to `find()`. For a one-off review search, call it directly: ```ts import type { SuperDoc } from 'superdoc'; export function findPendingDeletion(superdoc: SuperDoc) { return superdoc.ui.search.find('Legacy', { includeTrackedDeletions: true, }); } ``` This finds the deleted text. It does not accept, reject, or restore the tracked change. ## Verify the controls [#verify-the-controls] Run the project. Search for `Client`, then move through the eight matches across three pages. Replace one match and confirm that the count updates. Turn on **Include pending deletions** and search for `Legacy`; the pending deletion should be the only match. Use [Document API queries](/document-api/query-content) when code needs document targets instead of a visual Search session. Build an [application-owned context menu](/editor/custom-ui/context-menus) when actions depend on the entity or selection under the pointer. Otherwise, return to the [Custom UI overview](/editor/custom-ui/overview). --- # Build an AI prompt menu for selected text > Capture selected document text, place an application-owned AI prompt beside it, and keep the target when focus moves. Show a small **Ask AI** action beside selected text, then expand it into a prompt that keeps the same document range as context. ## Try the selection prompt [#try-the-selection-prompt] 1. Select the liability cap on the first page. A small **Ask AI** action appears above it. 2. Choose **Ask AI**. The action expands into a question field without losing the captured context. 3. Enter `What does this limit?`, then choose **Ask**. 4. Scroll the document or change its zoom. The prompt follows the selected text while it is visible. 5. Choose **Show selection** to reapply the captured range in the document. > **Live example: ask AI about selected document text** > > Select text in the two-page DOCX. A compact Ask AI action appears first, then expands into a question field. The application captures the text and document target before focus moves. The demo response is local: no text is sent to a model. Scrolling or zooming resolves the same target again, and Show selection reapplies it in the Editor. The demo creates its response locally and sends no text to a model. In your application, send the question and captured text through your own server-side model integration. ## Build the same interaction [#build-the-same-interaction] Start from [Custom UI setup](/editor/custom-ui/controller-setup). Copy this sample to your application's `public` directory as `contract.docx`, or use another DOCX with selectable text. [Download the selection sample](/fixtures/custom-selection-workflow.docx): Two pages of selectable contract text · DOCX ### Add the prompt [#add-the-prompt] Vanilla places the application-owned prompt inside a positioned shell with the Editor. React renders that shell and prompt in the component below and keeps the quickstart `index.html`. For Vanilla, replace the contents of `` in `index.html` with: ```html
    Select text in the document. ``` ### Capture its context and position [#capture-its-context-and-position] Replace the setup guide's Editor code: **Vanilla — `src/main.ts`** ```ts import { SuperDoc } from 'superdoc'; import type { UIConfig } from 'superdoc'; import type { SelectionCapture, SelectionSlice } from 'superdoc/ui'; import 'superdoc/style.css'; type SelectionPromptRequest = Readonly<{ context: string; question: string; }>; type SelectionPromptResponse = Readonly<{ answer: string; }>; const editorShell = document.querySelector('#editor-shell'); const editor = document.querySelector('#editor'); const promptCard = document.querySelector('#prompt-card'); const selectionActions = document.querySelector('#selection-actions'); const openPromptButton = document.querySelector('#open-selection-prompt'); const composer = document.querySelector('#selection-composer'); const closePromptButton = document.querySelector('#close-selection-prompt'); const preview = document.querySelector('#selection-preview'); const form = document.querySelector('#selection-prompt'); const question = document.querySelector('#selection-question'); const askButton = document.querySelector('#ask-selection'); const response = document.querySelector('#prompt-response'); const answer = document.querySelector('#prompt-answer'); const showSelectionButton = document.querySelector('#show-selection'); const status = document.querySelector('#selection-status'); // Set only when the card is hidden while it owns focus, so unhiding restores focus to the // reader who had it and never steals it from the Editor on the card's first appearance. let restorePromptFocus: HTMLElement | null = null; if ( !editorShell || !editor || !promptCard || !selectionActions || !openPromptButton || !composer || !closePromptButton || !preview || !form || !question || !askButton || !response || !answer || !showSelectionButton || !status ) { throw new Error('The selection prompt UI is incomplete.'); } let capture: SelectionCapture | null = null; let capturedTargetKey = ''; let promptRequestId = 0; let isComposerOpen = false; let stopSelection: (() => void) | null = null; let stopViewport: (() => void) | null = null; let removeHandlers: (() => void) | null = null; let interactionStatus = 'Select text in the document.'; const reportInteraction = (message: string) => { interactionStatus = message; status.textContent = message; }; const setComposerOpen = (open: boolean) => { isComposerOpen = open; selectionActions.hidden = open; composer.hidden = !open; promptCard.dataset.mode = open ? 'composer' : 'actions'; promptCard.setAttribute('aria-label', open ? 'Ask AI about selected text' : 'Actions for selected text'); openPromptButton.setAttribute('aria-expanded', String(open)); }; const resetComposer = () => { promptRequestId += 1; question.value = ''; answer.textContent = ''; response.hidden = true; askButton.disabled = true; setComposerOpen(false); }; const readModelResponse = (value: unknown): SelectionPromptResponse => { if (typeof value !== 'object' || value === null || !('answer' in value) || typeof value.answer !== 'string') { throw new Error('The model endpoint returned an invalid response.'); } return { answer: value.answer }; }; const askModel = async (request: SelectionPromptRequest): Promise => { const modelResponse = await fetch('/api/selection-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); if (!modelResponse.ok) throw new Error(`The model request failed with status ${modelResponse.status}.`); return readModelResponse(await modelResponse.json()); }; const editorUi = { comments: false, toolbar: { container: '#toolbar' }, } satisfies UIConfig; // Restoration must not outrank a deliberate focus move. Hiding the card unmounts the focused // control, so the browser parks focus on ; anything else holding it means the reader // moved on, and keeping their place matters more than returning to the prompt. function focusIsUnclaimed() { const active = document.activeElement; return active === null || active === document.body; } function focusedPromptControl(card: HTMLElement): HTMLElement | null { if (!(document.activeElement instanceof HTMLElement)) return null; return card.contains(document.activeElement) ? document.activeElement : null; } const superdoc = new SuperDoc({ selector: '#editor', document: '/contract.docx', ui: editorUi, onReady: ({ superdoc: readySuperDoc }) => { const ui = readySuperDoc.ui; const positionPrompt = () => { const target = capture?.selectionTarget ?? capture?.target; if (!target) { // Capture only on the visible -> hidden transition. A second invalidation while the // card is already hidden would otherwise see focus on the body and clear ownership. if (!promptCard.hidden) restorePromptFocus = focusedPromptControl(promptCard); promptCard.hidden = true; return; } const geometry = ui.viewport.getRect({ target, relativeTo: editorShell }); if (!geometry.found || !geometry.rect) { // Capture only on the visible -> hidden transition. A second invalidation while the // card is already hidden would otherwise see focus on the body and clear ownership. if (!promptCard.hidden) restorePromptFocus = focusedPromptControl(promptCard); promptCard.hidden = true; status.textContent = geometry.reason ?? 'The selection is not currently painted.'; return; } const shellBounds = editorShell.getBoundingClientRect(); const editorBounds = editor.getBoundingClientRect(); const visibleTop = editorBounds.top - shellBounds.top; const visibleBottom = editorBounds.bottom - shellBounds.top; const visibleLeft = editorBounds.left - shellBounds.left; const visibleRight = editorBounds.right - shellBounds.left; const anchorRect = geometry.rects.find( (rect) => rect.bottom >= visibleTop && rect.top <= visibleBottom && rect.right >= visibleLeft && rect.left <= visibleRight, ); if (!anchorRect) { // Capture only on the visible -> hidden transition. A second invalidation while the // card is already hidden would otherwise see focus on the body and clear ownership. if (!promptCard.hidden) restorePromptFocus = focusedPromptControl(promptCard); promptCard.hidden = true; status.textContent = 'Scroll back to the selection to show its prompt.'; return; } // Hiding the card removes the focused control from the rendered tree, so focus falls to // the document body. Restore it when the range scrolls back into view, or a keyboard // user has to rediscover the prompt. promptCard.hidden = false; if (restorePromptFocus) { // The card is only hidden, never removed, so the exact control the reader was on is // still focusable; returning them to the textarea would undo the navigation they did. const control = restorePromptFocus; restorePromptFocus = null; if (focusIsUnclaimed()) { (control.isConnected ? control : composer.hidden ? openPromptButton : question).focus(); } } status.textContent = interactionStatus; const edge = 8; const gap = 12; const maxLeft = Math.max(edge, editorShell.clientWidth - promptCard.offsetWidth - edge); const minTop = editorBounds.top - shellBounds.top + edge; const maxTop = Math.max(minTop, editorBounds.bottom - shellBounds.top - promptCard.offsetHeight - edge); const centeredLeft = anchorRect.left + anchorRect.width / 2 - promptCard.offsetWidth / 2; const above = anchorRect.top - promptCard.offsetHeight - gap; const below = anchorRect.bottom + gap; const belowFits = below + promptCard.offsetHeight <= visibleBottom - edge; const preferredTop = isComposerOpen && belowFits ? below : above >= minTop ? above : below; promptCard.style.left = `${Math.max(edge, Math.min(centeredLeft, maxLeft))}px`; promptCard.style.top = `${Math.max(minTop, Math.min(preferredTop, maxTop))}px`; }; const renderSelection = (selection: SelectionSlice) => { if (selection.status !== 'ready' || selection.empty) return; const nextCapture = ui.selection.capture(); if (!nextCapture) return; const nextTargetKey = JSON.stringify([nextCapture.selectionTarget ?? nextCapture.target, nextCapture.quotedText]); capture = nextCapture; if (nextTargetKey !== capturedTargetKey) { capturedTargetKey = nextTargetKey; resetComposer(); } preview.textContent = `“${nextCapture.quotedText}”`; reportInteraction('Selection captured. Choose Ask AI.'); positionPrompt(); }; const openComposer = () => { if (!capture) return; setComposerOpen(true); reportInteraction('The composer kept the captured text as context.'); positionPrompt(); question.focus(); }; const closeComposer = () => { resetComposer(); reportInteraction('Selection captured. Choose Ask AI.'); positionPrompt(); openPromptButton.focus(); }; const showSelection = () => { if (!capture) return; const result = ui.selection.restore(capture); reportInteraction( result.success ? 'Selection shown.' : `Could not show selection: ${result.reason ?? 'unknown'}`, ); }; const updateQuestion = () => { promptRequestId += 1; answer.textContent = ''; response.hidden = true; askButton.disabled = question.value.trim().length === 0; reportInteraction('The prompt kept its captured document context.'); }; const submitPrompt = async (event: SubmitEvent) => { event.preventDefault(); const currentCapture = capture; const currentQuestion = question.value.trim(); if (!currentCapture || !currentQuestion) return; const requestId = (promptRequestId += 1); askButton.disabled = true; response.hidden = true; reportInteraction('Asking the model about the captured text…'); try { const result = await askModel({ context: currentCapture.quotedText, question: currentQuestion }); if (requestId !== promptRequestId) return; answer.textContent = result.answer; response.hidden = false; reportInteraction('Response received for the captured text.'); positionPrompt(); } catch (error) { if (requestId !== promptRequestId) return; reportInteraction(error instanceof Error ? error.message : 'The model request failed.'); } finally { if (requestId === promptRequestId) askButton.disabled = question.value.trim().length === 0; } }; renderSelection(ui.selection.getSnapshot()); stopSelection = ui.selection.observe(renderSelection); stopViewport = ui.viewport.observe(positionPrompt); openPromptButton.addEventListener('click', openComposer); closePromptButton.addEventListener('click', closeComposer); showSelectionButton.addEventListener('click', showSelection); question.addEventListener('input', updateQuestion); form.addEventListener('submit', submitPrompt); removeHandlers = () => { openPromptButton.removeEventListener('click', openComposer); closePromptButton.removeEventListener('click', closeComposer); showSelectionButton.removeEventListener('click', showSelection); question.removeEventListener('input', updateQuestion); form.removeEventListener('submit', submitPrompt); }; }, onContentError: ({ error }) => { reportInteraction('The document could not be read.'); console.error(error); }, onException: ({ error }) => { reportInteraction('The editor reported a runtime error.'); console.error(error); }, }); window.addEventListener('beforeunload', () => { promptRequestId += 1; stopSelection?.(); stopViewport?.(); removeHandlers?.(); superdoc.destroy(); }); ``` **React — `src/App.tsx`** ```tsx import { useCallback, useEffect, useLayoutEffect, useRef, useState, type FormEvent } from 'react'; import { SuperDocEditor } from '@superdoc/react'; import type { UIConfig } from 'superdoc'; import type { SelectionCapture } from 'superdoc/ui'; import { SuperDocUIProvider, useSetSuperDoc, useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react'; import '@superdoc/react/style.css'; type PromptPosition = { left: number; top: number }; type SelectionPromptRequest = Readonly<{ context: string; question: string }>; type SelectionPromptResponse = Readonly<{ answer: string }>; const editorUi = { comments: false } satisfies UIConfig; // Restoration must not outrank a deliberate focus move. Hiding the card unmounts the focused // control, so the browser parks focus on ; anything else holding it means the reader // moved on, and keeping their place matters more than returning to the prompt. function focusIsUnclaimed() { const active = document.activeElement; return active === null || active === document.body; } // The card remounts on scroll-back with fresh elements, so the reader's position is recorded // as a control name rather than a node. Returning them to the textarea when they were on // Close or Ask would make them navigate back to what they had already reached. function promptControlName(card: HTMLElement | null): string | null { if (!card || !(document.activeElement instanceof HTMLElement)) return null; if (!card.contains(document.activeElement)) return null; return document.activeElement.closest('[data-prompt-control]')?.getAttribute('data-prompt-control') ?? ''; } function focusPromptControl(card: HTMLElement | null, name: string | null): boolean { if (!card || !name) return false; const control = card.querySelector(`[data-prompt-control="${name}"]`); if (!(control instanceof HTMLElement)) return false; control.focus(); return true; } function readModelResponse(value: unknown): SelectionPromptResponse { if (typeof value !== 'object' || value === null || !('answer' in value) || typeof value.answer !== 'string') { throw new Error('The model endpoint returned an invalid response.'); } return { answer: value.answer }; } async function askModel(request: SelectionPromptRequest): Promise { const response = await fetch('/api/selection-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); if (!response.ok) throw new Error(`The model request failed with status ${response.status}.`); return readModelResponse(await response.json()); } export default function App() { return ( ); } function SelectionPromptEditor() { const ui = useSuperDocUI(); const selection = useSuperDocSelection(); const setSuperDoc = useSetSuperDoc(); const shellRef = useRef(null); const promptRef = useRef(null); const actionButtonRef = useRef(null); const questionRef = useRef(null); const captureKeyRef = useRef(''); const promptRequestIdRef = useRef(0); const restoreActionFocusRef = useRef(false); // The composer keeps `isComposerOpen` true across a scroll-away, so visibility alone must // not refocus it: only opening it, or having owned focus when it was hidden, may. const restoreComposerFocusRef = useRef(null); const wasComposerOpenRef = useRef(false); const [capture, setCapture] = useState(null); const [position, setPosition] = useState(null); const [prompt, setPrompt] = useState(''); const [answer, setAnswer] = useState(''); const [isAsking, setIsAsking] = useState(false); const [isComposerOpen, setIsComposerOpen] = useState(false); const [status, setStatus] = useState('Select text in the document.'); const [geometryStatus, setGeometryStatus] = useState(null); const positionPrompt = useCallback(() => { const shell = shellRef.current; const target = capture?.selectionTarget ?? capture?.target; if (!ui || !shell || !target) { setPosition(null); setGeometryStatus(null); return; } const geometry = ui.viewport.getRect({ target, relativeTo: shell }); if (!geometry.found || !geometry.rect) { setPosition(null); setGeometryStatus('The selection is not currently painted.'); return; } const host = ui.viewport.getHost(); if (!host) { setPosition(null); setGeometryStatus('The editor viewport is not currently available.'); return; } const shellBounds = shell.getBoundingClientRect(); const hostBounds = host.getBoundingClientRect(); const visibleTop = hostBounds.top - shellBounds.top; const visibleBottom = hostBounds.bottom - shellBounds.top; const visibleLeft = hostBounds.left - shellBounds.left; const visibleRight = hostBounds.right - shellBounds.left; const anchorRect = geometry.rects.find( (rect) => rect.bottom >= visibleTop && rect.top <= visibleBottom && rect.right >= visibleLeft && rect.left <= visibleRight, ); if (!anchorRect) { setPosition(null); setGeometryStatus('Scroll back to the selection to show its prompt.'); return; } const edge = 8; const gap = 12; const promptWidth = promptRef.current?.offsetWidth ?? (isComposerOpen ? 304 : 104); const promptHeight = promptRef.current?.offsetHeight ?? (isComposerOpen ? 300 : 40); const maxLeft = Math.max(edge, shell.clientWidth - promptWidth - edge); const minTop = visibleTop + edge; const maxTop = Math.max(minTop, visibleBottom - promptHeight - edge); const centeredLeft = anchorRect.left + anchorRect.width / 2 - promptWidth / 2; const above = anchorRect.top - promptHeight - gap; const below = anchorRect.bottom + gap; const belowFits = below + promptHeight <= visibleBottom - edge; const preferredTop = isComposerOpen && belowFits ? below : above >= minTop ? above : below; setGeometryStatus(null); setPosition({ left: Math.max(edge, Math.min(centeredLeft, maxLeft)), top: Math.max(minTop, Math.min(preferredTop, maxTop)), }); }, [capture, isComposerOpen, ui]); useEffect(() => { if (!ui || selection.status !== 'ready' || selection.empty) return; const nextCapture = ui.selection.capture(); if (!nextCapture) return; const nextKey = JSON.stringify([nextCapture.selectionTarget ?? nextCapture.target, nextCapture.quotedText]); if (nextKey !== captureKeyRef.current) { captureKeyRef.current = nextKey; promptRequestIdRef.current += 1; setPrompt(''); setAnswer(''); setIsAsking(false); setIsComposerOpen(false); } setCapture(nextCapture); setStatus('Selection captured. Choose Ask AI.'); }, [selection, ui]); const isPromptVisible = position !== null; useLayoutEffect(positionPrompt, [answer, isComposerOpen, isPromptVisible, positionPrompt]); useEffect(() => ui?.viewport.observe(positionPrompt), [positionPrompt, ui]); // Record whether any prompt control — textarea, Close, Ask, Show selection — owned focus // just before the card unmounts, so a scroll-back restores it to the reader who had it // rather than pulling it from wherever it moved. useLayoutEffect(() => { if (!isPromptVisible) return undefined; return () => { restoreComposerFocusRef.current = promptControlName(promptRef.current); }; }, [isPromptVisible]); useEffect(() => { // The card unmounts whenever the captured range scrolls out of view. isComposerOpen does // not change across that, so visibility has to drive focus restoration too. const composerJustOpened = isComposerOpen && !wasComposerOpenRef.current; wasComposerOpenRef.current = isComposerOpen; if (!isPromptVisible) return; const restoreTarget = restoreComposerFocusRef.current; if (isComposerOpen) { if (!composerJustOpened && restoreTarget === null) return; restoreComposerFocusRef.current = null; if (composerJustOpened) { questionRef.current?.focus(); return; } if (focusIsUnclaimed() && !focusPromptControl(promptRef.current, restoreTarget)) { questionRef.current?.focus(); } return; } // The capture is card-wide, so the compact action button's ownership lands in the same // record; either signal restores this branch. Closing the composer is deliberate and always // lands focus; a scroll-back only reclaims focus that nobody else took. const closedComposer = restoreActionFocusRef.current; if (!closedComposer && restoreTarget === null) return; restoreActionFocusRef.current = false; restoreComposerFocusRef.current = null; if (closedComposer) { actionButtonRef.current?.focus(); return; } if (focusIsUnclaimed() && !focusPromptControl(promptRef.current, restoreTarget)) { actionButtonRef.current?.focus(); } }, [isComposerOpen, isPromptVisible]); useEffect( () => () => { promptRequestIdRef.current += 1; }, [], ); function showSelection() { if (!ui || !capture) return; const result = ui.selection.restore(capture); setStatus(result.success ? 'Selection shown.' : `Could not show selection: ${result.reason ?? 'unknown'}.`); } function closeComposer() { restoreActionFocusRef.current = true; promptRequestIdRef.current += 1; setIsAsking(false); setPrompt(''); setAnswer(''); setIsComposerOpen(false); setStatus('Selection captured. Choose Ask AI.'); } function openComposer() { restoreActionFocusRef.current = false; setIsComposerOpen(true); setStatus('The composer kept the captured text as context.'); } async function submitPrompt(event: FormEvent) { event.preventDefault(); const currentCapture = capture; const question = prompt.trim(); if (!currentCapture || !question || isAsking) return; const requestId = (promptRequestIdRef.current += 1); setIsAsking(true); setAnswer(''); setStatus('Asking the model about the captured text…'); try { const result = await askModel({ context: currentCapture.quotedText, question }); if (requestId !== promptRequestIdRef.current) return; setAnswer(result.answer); setStatus('Response received for the captured text.'); } catch (error) { if (requestId !== promptRequestIdRef.current) return; setStatus(error instanceof Error ? error.message : 'The model request failed.'); } finally { if (requestId === promptRequestIdRef.current) setIsAsking(false); } } return ( <>
    { setGeometryStatus(null); setStatus('The document could not be read.'); console.error(error); }} onException={({ error }) => { setGeometryStatus(null); setStatus('The editor reported a runtime error.'); console.error(error); }} onReady={({ superdoc }) => setSuperDoc(superdoc)} ui={editorUi} /> {capture && position ? (