@betteroffice/docx-react
v0.4.3
Published
Word-faithful React DOCX editor and viewer — full OOXML fidelity, real-time human + AI agent collaboration.
Maintainers
Readme
@betteroffice/docx-react
React chrome for the BetterOffice DOCX editor — wraps
@betteroffice/docx in a
drop-in <DocxEditor> component with the toolbar, ruler, selection, keyboard,
comments, and tracked-changes UI wired up. Rendering and layout run in the
core's Rust/WebAssembly engine; pages are painted onto canvas.
bun add @betteroffice/docx-react @betteroffice/docx react react-domreact and react-dom (18 or 19) are peer dependencies.
View a document
import { DocxEditor } from '@betteroffice/docx-react';
import '@betteroffice/docx-react/styles.css';
<DocxEditor documentBuffer={buffer} mode="viewing" />;documentBuffer accepts an ArrayBuffer, Uint8Array, Blob, or File.
Edit a document
import { useState } from 'react';
import { DocxEditor } from '@betteroffice/docx-react';
import '@betteroffice/docx-react/styles.css';
export function App() {
const [file, setFile] = useState<ArrayBuffer>();
return (
<>
<input
type="file"
accept=".docx"
onChange={async (e) => {
const f = e.target.files?.[0];
if (f) setFile(await f.arrayBuffer());
}}
/>
<DocxEditor
documentBuffer={file}
onSave={(bytes) => console.log(`saved ${bytes.byteLength} bytes`)}
/>
</>
);
}Without onSave, File > Save downloads the edited bytes.
Key props: documentBuffer (or a parsed document), onSave, onChange,
author, mode (editing / suggesting / viewing), showToolbar, toolbar,
showRuler, showZoomControl, showHiddenText, i18n, measurementFontProvider. The ref
exposes the full editor API (selection, formatting, find/replace, comments,
revisions) and the editor's command store, commands.
Vanished (hidden) text stays out of the layout by default; pass
showHiddenText to reveal it with normal wrapping.
previewFirstPage (experimental, off by default) paints a display-only preview
of a document's first pages before the whole document opens, then hands them
over to it; the editor stays read-only until then. It is ignored with
collaboration.
onFirstPagePainted is called once per document when its first pages are painted
on screen, from the preview or the full document, so a host can drop its loading
state before the whole document is open.
What works today
- Editing with Word-faithful pagination; layout runs in Rust, never in the DOM
- Suggesting mode: tracked changes with accept/reject review UI
- Comment threads with replies and resolution, controllable from the host
- Find and replace, headers and footers, footnotes, images, tables
- Zoom control and ruler
- Composable chrome: arrange built-in controls with their state, shortcuts and restrictions, add host actions, or drive a toolbar outside the editor
- Host-owned plugins with panels, overlays, sidebar items, lifecycle events, and explicitly granted commands and edit batches
- Localized UI via the
i18nprop (@betteroffice/docx-i18n) - Real-time collaboration with people or agents; the document is a CRDT
- Live collaborator cursors and selections, shown in each peer's color
For font metrics closer to Word, configure @betteroffice/fonts once at
module scope, or pass a provider through measurementFontProvider. The CDN
entry loads Latin and Japanese coverage fonts without bundling font binaries:
import { configureDefaultFonts } from '@betteroffice/docx-react';
configureDefaultFonts({ load: () => import('@betteroffice/fonts/cdn') });For offline assets, import * as fonts from @betteroffice/fonts and call
configureDefaultFonts({ fonts }); see font configuration.
Several editors can share one page: each hears only its own font loads through
onFontsLoaded and onError, paints a document's embedded faces even when
another open document embeds different faces under the same family name, and
releases those faces when it unmounts or loads its next document.
Collaboration
The document is a CRDT — pass a collaboration prop and wire a transport to
co-edit with other people or an agent. onReplica hands you the session as a
CollaborationReplica; drive it with a CollaborationProvider over any
transport (a WebSocket relay, etc.). Every window must boot from the same shared
state, so pass identical initialUpdate bytes to each peer.
import { CollaborationProvider } from '@betteroffice/docx/collaboration';
<DocxEditor
documentBuffer={file}
collaboration={{
clientId,
initialUpdate: sharedSeed, // identical bytes for every peer
onReplica: (replica) => {
if (!replica) return;
const provider = new CollaborationProvider(replica, transport, {
user: { name: "Ada" }, // shown on this peer's remote caret
});
provider.connect();
},
}}
/>;Host saves and pending input
onSaveRequest owns File > Save and Cmd/Ctrl+S before serialization. Its awaited
callback can perform locks, revision checks, and persistence. Returning true
continues the built-in export/download; returning false or nothing suppresses it.
Overlapping UI save requests share one workflow. Errors reach onError.
DocxEditorRef.flushPendingInput() and PagedEditorRef.flushPendingInput() wait
until input accepted before the call and its selection are authoritative in Yrs.
They wait for active IME composition to end, and reject on input failure, unmount,
or document replacement. An input failure rejects only the flushes, saves and
commands that waited for that input; later ones proceed.
<DocxEditor
ref={editorRef}
onSaveRequest={async () => {
await validateRevision();
await editorRef.current!.flushPendingInput();
const bytes = await editorRef.current!.save();
if (bytes) await persistDocument(bytes);
}}
/>save() flushes input and exports directly, so calling it inside onSaveRequest
does not invoke that callback again. onSave(buffer) remains the notification
after export. Before direct session reads or mutations, await flushPendingInput();
after a mutation, use syncYrsInputState(true) to refresh the editor.
For a document opened from DOCX bytes, Save writes the session's Word paragraph
IDs, including those persistParagraphIds() gave comments, note separators and
retained XML, and records them as saved, as saveYrsDocx does. Use saveYrsDocx
for the persisted anchors of the saved paragraphs.
Compose the toolbar
Every built-in control runs through one command store, ref.commands, which
hosts can use for their own chrome. Pass toolbar to replace the default chrome with an arrangement of public parts; the children of
EditorToolbar.Toolbar are the complete row, in your order:
import {
DocxEditor,
EditorToolbar,
ToolbarButton,
ToolbarCommandButton,
ToolbarCommandSelect,
ToolbarGroup,
} from '@betteroffice/docx-react';
function CompactToolbar({ onShare }: { onShare(): void }) {
return (
<EditorToolbar>
<EditorToolbar.Toolbar>
<ToolbarGroup label="Formatting">
<ToolbarCommandSelect id="paragraphStyle" />
<ToolbarCommandButton id="bold" />
</ToolbarGroup>
<ToolbarCommandButton id="undo" />
<EditorToolbar.Review />
<ToolbarButton title="Share" onClick={onShare}>Share</ToolbarButton>
</EditorToolbar.Toolbar>
</EditorToolbar>
);
}
<DocxEditor documentBuffer={file} toolbar={<CompactToolbar onShare={share} />} />;toolbar omitted renders the default chrome, null renders none, and
showToolbar={false} hides either. The default chrome is hidden for readOnly;
chrome you supply still renders, with its writing controls disabled.
To place the toolbar outside the editor, capture its commands in state;
commands={null} reports every command unavailable:
import { useState } from 'react';
import { DocxCommandProvider, DocxEditor, type DocxCommandStore } from '@betteroffice/docx-react';
function Editor({ share }: { share(): void }) {
const [commands, setCommands] = useState<DocxCommandStore | null>(null);
return <>
<DocxCommandProvider commands={commands}>
<CompactToolbar onShare={share} />
</DocxCommandProvider>
<DocxEditor ref={(editor) => setCommands(editor?.commands ?? null)} toolbar={null} />
</>;
}Outside the editor, EditorToolbar supplies its own styling root and the
editor's locale, and its keyboard shortcuts reach that editor only.
- Parts.
ToolbarCommandButtonbinds a command (parameterized ones needargs, such as{ value: 'center' }foralignment),ToolbarCommandSelectrenders a selector's built-in picker,ToolbarCommandrenders any command's default control, andToolbarButton,ToolbarGroupandToolbarSeparatorhold host actions.useDocxCommand(id)anduseDocxCommandState(id)bind custom controls. - State.
getState(id, args?)returns{ enabled, active, value, options }; a disabled command always carriesdisabledReason: { code, message }, and controls expose that message as their accessible description. Marks report'mixed'for mixed selections. - Results.
execute(id, args)resolves to{ ok: true, status }(executed,noop,openedfor a dialog or picker,requestedwhen handed to the host) or{ ok: false, failure }. Commands run after input accepted before the call and check availability again first, so a stale enabled state never authorizes a change. A dialog or picker a command opens applies to the document and selection it opened with, or fails withdocument-replacedortarget-changed; print waits until accepted input is rendered. - Modes.
readOnlyand viewing mode refuse writes (read-only,viewing-mode); navigation, find, zoom, print and save remain available. Suggesting mode tracks paragraph styles and row edits, keeps direct character formatting untracked, and refuses structural commands such as table and break insertion withsuggesting-unsupported. - Focus and overflow. Pointer clicks keep the document focused; keyboard
activation keeps focus in the toolbar, and dialogs take focus. At narrow
widths trailing groups move into a More menu with arrow, Home/End, typeahead
and submenu navigation, keeping every choice of the built-in controls; custom
colors and sizes are asked for in a dialog. Wrap host content in
ToolbarOverflowto give it a menu entry; content without one, and any group holding it, stays in the row, which scrolls horizontally when that content does not fit.
Host edit batches
DocxEditorRef.readParagraphs(), findText(), validateEdits() and applyEdits()
flush pending input first, then run the version-checked batch API of
@betteroffice/docx on the
open document. Build expectVersion and targets from a flushed read:
const read = await editorRef.current!.readParagraphs({ view: 'accepted' });
if (!read.ok) return;
const result = await editorRef.current!.applyEdits({
expectVersion: read.version,
steps: [
{
op: 'insertParagraphs',
target: { story: 'body', paraId: read.paragraphs[0].paraId },
at: 'end',
paragraphs: [{ text: 'Inserted by the host', styleId: 'Heading2' }],
},
],
});A batch commits as one undo step and refreshes every story it changed, or returns
a typed refusal. A refusal never rolls back typing that the flush committed, so
typing that lands between the read and the batch refuses with stale-version.
Read-only editors refuse with read-only (locked-target stays for locked
content); in suggesting mode every step must carry suggest. The promise rejects when the document is replaced while input
flushes. proposeChange, addComment and applyFormatting resolve their
{ paraId, search } targets through the same Rust resolver in the accepted view.
getPositionAtPoint(clientX, clientY) returns the text under a client point, such
as a drop target, with the version its layout shows and a collapsed
accepted-view target to insert at with expectVersion: version. It never moves
selection or focus, and returns null outside text, while typed or composed input is
pending, and until the painted pages show the current version; retry after
flushPendingInput() or on the next frame.
listContentControls() and findContentControls(query) flush the same way and
list the document's content controls with the version they were read at; fill
plain- and rich-text controls with setContentControlText steps through
applyEdits, which refreshes the control's story and the story holding it.
Host proposals
DocxEditorRef.proposeChanges(), setProposalStates(), withdrawProposals() and
getProposals() flush pending input, then run the session's host proposals: a
round of tracked changes grouped by the host's proposal ids, shown with the
editor's tracked-change highlighting, and decisions that show accepted proposals
as plain text and hide rejected ones without changing the document or undo
history. withdrawProposals() settles finished proposals as their decisions show
them, outside undo history, so the next round searches the text the reader saw. Set
allowHostProposals to use them in a read-only or viewing editor; typing,
applyEdits, commands and plugin writes stay blocked there. Proposals never save
or open the comments sidebar.
const round = await editorRef.current!.proposeChanges({
expectVersion: (await editorRef.current!.getProposals()).version,
proposals: [
{
id: 'p-17',
paragraph: { kind: 'persisted', story: { partUri: '/word/document.xml', kind: 'body' }, paraId: '1A2B3C4D' },
suggest: { author: 'Reviewer', date: new Date().toISOString() },
op: 'replaceText',
search: '30 days',
replaceWith: '45 days',
},
],
});
if (round.ok) {
await editorRef.current!.setProposalStates({
expectVersion: round.snapshot.version,
expectPreviewVersion: round.snapshot.previewVersion,
changes: [{ id: 'p-17', state: 'accepted' }],
});
}The input, result and refusal-code types (DocxProposalRequest, DocxProposalResult,
DocxProposalStateRequest, DocxProposalWithdrawRequest, DocxProposalSnapshot, DocxProposalFailure, and related
types) are re-exported from @betteroffice/docx-react -- no need to import
@betteroffice/docx/yrs directly just to type host proposal code.
Host search
DocxEditorRef.search(query, options?) drives the editor's find from a host's own
search box. It highlights every match in the document body, tables included, makes
the first match on or after the page in view current and scrolls it to the middle
of the view, without moving the selection or focus. It works in a read-only editor
and on pages that are not painted yet. Matching is case-insensitive unless
options.caseSensitive is set, and an empty query clears.
const state = await editorRef.current!.search('warranty');
label.textContent = state.total ? `${state.current + 1} of ${state.total}` : 'No matches';
editorRef.current!.searchNext(); // Enter
editorRef.current!.searchPrevious(); // Shift+Enter
editorRef.current!.clearSearch(); // closing the box
const unsubscribe = editorRef.current!.onSearchChange((state) => render(state));searchNext(), searchPrevious() and searchGoTo(index) wrap around, scroll the
new current match into view and return the new state, or null without a search.
getSearchState() reads it, and onSearchChange reports every change, including
a re-run after the document changes, which keeps the current match, and null on
clear. Highlights use the .docx-find-highlight and .docx-find-highlight-current
classes, which a host stylesheet can restyle.
Host plugins
Host-owned tools (review aids, templates, checks) install through the plugins
prop. A plugin contributes a panel, an overlay, sidebar cards, and commands, and
works through restricted clients rather than the editor or its session:
import { DocxEditor, defineDocxPlugin } from '@betteroffice/docx-react';
type State = { version: string | null; count: number };
const review = defineDocxPlugin<State>({
id: 'acme.review',
createState: () => ({ version: null, count: 0 }),
async onEvent(context, event) {
if (event.type !== 'load' && event.type !== 'document-change') return;
const read = await context.read.readParagraphs({ view: 'accepted' });
if (read.ok) {
context.setState({ version: read.version, count: read.paragraphs.length }, read.version);
}
},
panel: {
title: 'Review',
placement: 'right',
render: ({ context }) => <p>{context.state.count} paragraphs</p>,
},
});
<DocxEditor
documentBuffer={file}
plugins={[review]}
pluginGrants={{ 'acme.review': { document: 'write', editBatches: true } }}
onPluginError={(error) => report(error)}
/>;- Lifecycle. Once a document is ready, each plugin gets fresh state,
initialize, then oneloadevent (loaded,replaced, orattachedfor a plugin added to an open document), and its contributions appear. A document change the hook did not make itself aborts it and deliversloadagain; after ten such runs the plugin is stopped and reported. It then receivesdocument-change(the committed version only, for typing, remote edits, undo, commands and batches, its own included, never for refusals or no-ops),selection-change,mode-change(with the effectivereadOnly),layout-change,proposal-change(host proposals or their preview decisions changed, withpreviewVersion) andgrants-change. Events describe current state: several changes may arrive as one, and a newer one aborts the hook still handling the previous (context.signal), except a change that hook's own edit batch made. Replacing the document, removing the plugin, changing itsrevision, unmounting, or a failure ends the activation: its signals abort, its clients refuse, and everyonCleanupdisposer runs once with the reason. A disposer registered afterwards runs immediately. Plugins are matched byidandrevision, so new array or callback identities and reordering keep their state.context.run(action)gives event handlers a fresh context and isolates their failures. - Stale results.
setStatereturns false once the context is superseded or ended, or when the document is no longer atatVersion(by default the version the context was created at). Pass the version a read returned so an older read never overwrites newer state. - Grants. Without a grant a plugin reads, validates and navigates only, and
context.editsis null. Built-in commands need their id incommands, and mutating ones alsodocument: 'write'; edit batches needdocument: 'write'andeditBatches, andhistory: 'none'alsountrackedHistory. Grants, editor mode and document policy are checked again right before each change, so a revoked grant, viewing mode,readOnlyor a replaced document refuses even through a client obtained earlier. Mutating built-in commands refuse plugins withunsupported-policy; plugins change documents through edit batches, whose Rust policy refuses locked content. Suggesting mode follows each operation's own rules; a batch step needssuggestthere. - Contributed commands register as
plugin:<pluginId>/<id>onref.commands. They always run with their own plugin's clients, even when the host's toolbar, shortcuts orref.commandsinvoke them, and outside the input queue, so a handler can await its own batches.executereturns{ ok: true, status }or a failure with the plugin's own code, or a refused edit batch as-is; callers receive it unchanged.mutatesDocumentdisables a command in viewing and read-only modes but grants nothing. Plugin shortcuts must use Mod or Alt, or a function key; built-in and clipboard shortcuts win, and a clashing plugin shortcut is not bound and is reported. Keys pressed in plugin chrome are the user's: built-in shortcuts act on the editor there, and a plugin's text fields keep their own editing keys.toolbarlists local ids in order: the default toolbar shows them, and replacement chrome places<DocxPluginToolbar />where it wants them.ToolbarCommandButton,ToolbarCommand,useDocxCommandanduseDocxCommandStatealso take a contributed id; a button renders nothing while no plugin contributes it. - Panels dock left, right or bottom of the document, beside the toolbar
rather than inside the scroll area, with tabs when several share a side.
preferredSizeis clamped to 40% of the editor. In a narrow editor side docks show their tabs, and a tab opens its panel as a drawer over the document that Escape closes. Collapsing a panel keeps the plugin running. - Geometry.
context.geometryis null until a rendered layout shows the current version, and whenever it falls behind.geometry.domanswers in pages-container units divided by zoom;geometry.toOverlayRect(rect)converts one of those rectangles into pixels of the unscaled overlay layer, once, or returns null after that layout stops being rendered.geometry.getPositionAtPoint(clientX, clientY)returns the text under a client point with the layout'slayoutIdandversionand an edit batchtarget, or null likewise, while input is pending, and until the pages show that layout.geometry.getAnchorGeometry(target)resolves a proposal, revision, paragraph, search match or text range to overlay-layer pixels: every visible fragment with its zero-basedpageIndex, ananchorcollapsed at the end of the last one (at the boundary of a target the preview hides, else at its paragraph) and the anchor'spageRect. It refuses with a typed failure rather than answer from a stale or unpainted layout, andlayout-changerepeats once the pages have painted a layout that arrived before its pixels;layout.previewVersionis the proposal preview the pixels show. The layer ignores the pointer; interactive overlay elements setpointer-events: auto.snapshot.selection.displayRangebelongs to one layout and is never an edit target. - Sidebar cards anchor to
{ version, story, paraId }and show only while the document is at that version and the body paragraph resolves uniquely.renderreceives theitemit draws, so one component can draw every card; a card keeps its React state while the plugin returns itsid, and starts afresh for a new id or document. Their ids are namespaced, so they never collide with comments. - Navigation.
navigation.scrollToParagraph(target, { expectVersion })flushes input, waits briefly for a layout of that version, and keeps focus and selection unlessfocus: true. It succeeds only when it scrolled, and otherwise says why (stale-version,missing-target,ambiguous-target,layout-unavailable,unsupported). - Failures. Every contribution renders behind its own error boundary with a
command context restricted to its plugin, so public command hooks inside it
act for the plugin. A throwing hook, renderer, sidebar or command-state
function, command, or
context.runaction stops only that plugin, runs its cleanups and reports{ pluginId, generation, phase, error }toonPluginError; it stays stopped until a new document,revisionor reinstallation. Plugins run in the page's realm: grants govern the supported API, not a sandbox. - Legacy props.
pluginOverlaysandpluginSidebarItemsstill render as unmanaged content, below managed overlays and merged with managed cards;pluginRenderedDomContextis ignored while plugins are installed, andonRenderedDomContextReadyreceives the editor's own context. The snapshot-basedEditorPluginCoretypes in@betteroffice/docx/plugin-apiare deprecated in favour of this API.
Page-referenced export
DocxEditorRef.exportStructuredWithPages(options) flushes pending input and exports
structured content with the page map of the editor's authoritative layout of that
version, the same map @betteroffice/docx documents. The layout must have been
computed from the editor's current fonts, measurement defaults, render environment
and pagination options; when it was not, the editor lays the document out again or
waits up to two seconds for its fonts. Page references describe that layout, which
a later edit may supersede before it is painted:
const result = await editorRef.current!.exportStructuredWithPages({
revisionView: 'markup',
stories: ['body', 'headers', 'footers'],
includeGeometry: true,
});
if (result.ok) {
const { structured, layout } = result.content;
// layout.pages, layout.occurrences, layout.fragments; result.version
}Geometry is in unzoomed CSS pixels from each page's top-left corner, so zoom and
scrolling do not change it. With expectLayoutVersion the editor never lays out
again and a newer document is refused as stale-document. The promise rejects when
the document is replaced meanwhile.
Memory
Each wasm32 memory holds at most 4 GiB. DocxEditorRef.getMemoryStats() reports
the editor's wasm memories on the main thread and in its resident worker: each
module's memory size, which only grows and so is also its high-water mark, and for
the editing core the bytes allocated now, the most allocated at once, and the size
of an allocation that failed. onMemoryPressure is called when the fullest memory
crosses a memoryBudget level, 75% and 90% of 4 GiB by default, and when it drops
back; it stays silent below the warning level. The editing core counts by its
allocated bytes and the other modules by their memory size.
<DocxEditor
documentBuffer={bytes}
onMemoryPressure={({ level, stats }) => report(level, stats)}
/>memoryBudget.workerLimitBytes caps what the resident worker's editing core may
allocate at once; an allocation past it fails as if the memory were full. A worker
that runs out of memory is replaced by a fresh one once. If that one runs out too,
the editor reports ResidentWorkerOutOfMemoryError from @betteroffice/docx/yrs
through onError, with the worker's memories at that point, and stops rendering
rather than moving the work to the main thread. Any other worker failure moves the
work to the main thread.
Framework notes
Import @betteroffice/docx-react/styles.css once at the entry/page/layout level.
In Next.js, load the editor with next/dynamic and ssr: false.
JavaScript guide · Changelog · Apache-2.0.
