@shashimadushan/docx-editor-editor
v0.6.1
Published
A clean, extensible, framework-agnostic DOCX editor built on TipTap. Load .docx, edit, save .docx — and add your own extensions. Word/Google-Docs-style paged layout.
Maintainers
Readme
@shashimadushan/docx-editor-editor
A clean, extensible, framework-agnostic DOCX editor. Word/Google-Docs-style paged layout, .docx load/save, 14 built-in extensions, and a public extension API for adding your own.
Install
npm install @shashimadushan/docx-editor-editor react react-domreact and react-dom are peer dependencies (only needed for the React bindings).
Quick start (React)
import * as React from 'react';
import { ReactDocxEditor, type DocxEditor } from '@shashimadushan/docx-editor-editor/react';
import '@shashimadushan/docx-editor-editor/style.css';
export function App() {
const [editor, setEditor] = React.useState<DocxEditor | null>(null);
return (
<div style={{ height: '100vh' }}>
<ReactDocxEditor
content="<h1>Hello, world!</h1><p>Start editing…</p>"
onReady={setEditor}
onUpdate={(json, html) => console.log('Document changed:', html)}
/>
</div>
);
}⚠️ Important: The host element must have a defined height (e.g.
height: 100vhorflex: 1in a flex column). The editor uses a paged layout where only the page surface scrolls — if the host has no height, the editor won't be visible.
Quick start (vanilla JS)
import { DocxEditor } from '@shashimadushan/docx-editor-editor';
import '@shashimadushan/docx-editor-editor/style.css';
const editor = new DocxEditor({
element: document.getElementById('host')!,
content: '<p>Hello</p>',
onUpdate: ({ json, html }) => console.log(html),
});
// Later:
editor.destroy();<ReactDocxEditor /> props
| Prop | Type | Default | Description |
|---|---|---|---|
| content | string | '<p></p>' | Initial HTML content |
| theme | DocxEditorTheme | Word-like defaults | Theme tokens (see below) |
| editable | boolean | true | Whether the editor is editable |
| filename | string | 'Untitled.docx' | Filename shown in the title bar |
| onFilenameChange | (name: string) => void | — | Called when the user edits the filename |
| placeholder | string | 'Start writing, or type "/" for commands…' | Empty-document placeholder |
| disableBuiltins | string[] \| '*' | [] | Builtin extension IDs to disable, or '*' to disable all |
| extensions | DocxEditorExtension[] | [] | Custom extensions to register |
| tiptapExtensions | Extension[] | defaultTipTapExtensions() | Override the TipTap schema entirely |
| onUpdate | (json, html) => void | — | Called on every content change |
| onReady | (editor: DocxEditor) => void | — | Called once when the editor mounts |
| renderToolbar | (editor) => ReactNode | default toolbar | Render-prop for a custom toolbar |
| renderTitleBar | (editor) => ReactNode | default title bar | Render-prop for a custom title bar |
| showTitleBar | boolean | true | Show/hide the default title bar |
| showStatusBar | boolean | true | Show/hide the bottom status bar (word count) |
| titleBarActions | ReactNode | — | Extra content appended to the title bar |
| className | string | — | CSS class on the host wrapper |
| style | CSSProperties | — | Inline style on the host wrapper |
DocxEditor class (framework-agnostic core)
Lifecycle
| Method | Description |
|---|---|
| new DocxEditor(opts) | Create the editor. Pass element to mount immediately. |
| mount(el) | Mount into a DOM element (if not passed in constructor). |
| destroy() | Destroy the editor and free all resources. |
.docx I/O
| Method | Returns | Description |
|---|---|---|
| loadDocx(buffer: ArrayBuffer) | Promise<void> | Load a .docx file into the editor. |
| saveDocx(options?) | Promise<Blob> | Save the document as a .docx Blob. |
| downloadDocx(filename?, options?) | Promise<void> | Trigger a browser download of the .docx. |
Content accessors
| Method | Returns | Description |
|---|---|---|
| getJSON() | any | Document as TipTap JSON |
| setJSON(json) | void | Replace the document |
| getHTML() | string | Document as HTML |
| setHTML(html) | void | Replace the document |
| getText() | string | Plain text (no formatting) |
| setEditable(b) | void | Toggle editability |
| getEditable() | boolean | Is the editor editable? |
Extension API
| Method | Description |
|---|---|
| registerExtension(ext) | Register an extension after creation |
| getToolbarButtons() | Get all toolbar buttons (for custom UIs) |
| getSlashCommands() | Get all slash commands |
| getExtensionContext() | Get the context object passed to extension callbacks |
| registry | The ExtensionRegistry instance |
DOM access
| Method | Returns | Description |
|---|---|---|
| getHost() | HTMLElement | The outermost host element |
| getSurface() | HTMLElement | The gray scroll container |
| getPage() | HTMLElement | The white "paper" element |
| editor | TipTap Editor | The underlying TipTap Editor instance |
Loading overlay
| Method | Description |
|---|---|
| setLoading(loading, message?) | Show/hide a loading overlay on the editor surface |
Theme tokens
Override any of these via the theme prop or by setting CSS custom properties on a parent element:
<ReactDocxEditor
theme={{
background: '#f1f5f9', // desk color (around the page)
text: '#1f2328', // default text color
accent: '#2563eb', // selection, active toolbar button
border: '#d0d7de', // table/border color
fontFamily: 'Inter, sans-serif',
fontSize: '11pt',
pageWidth: '816px', // 8.5in @ 96dpi
pageHeight: '1056px', // 11in @ 96dpi
pageMarginX: '96px', // 1in
pageMarginY: '96px',
pageGap: '24px',
lineHeight: 1.5,
}}
/>Or via CSS:
.my-editor-host {
--de-page-width: 21cm;
--de-page-height: 29.7cm;
--de-page-margin-x: 2cm;
--de-page-margin-y: 2cm;
--de-accent: #f59e0b;
--de-font-family: "Inter", sans-serif;
}Common page sizes
| Format | pageWidth | pageHeight |
|---|---|---|
| US Letter (default) | 816px (8.5in) | 1056px (11in) |
| A4 | 794px (21cm) | 1123px (29.7cm) |
| Legal | 816px (8.5in) | 1344px (14in) |
| A5 | 559px (14.8cm) | 794px (21cm) |
Built-in extensions (14)
These are registered by default. Disable any with disableBuiltins:
| ID | Buttons / commands |
|---|---|
| bold | Bold (Ctrl+B) |
| italic | Italic (Ctrl+I) |
| underline | Underline (Ctrl+U) |
| strike | Strikethrough |
| highlight | Highlight toggle |
| heading | H1, H2, H3 buttons + slash commands |
| list | Bullet list, numbered list |
| alignment | Left, center, right, justify |
| codeBlock | /code slash command |
| blockquote | /quote slash command |
| horizontalRule | /hr slash command |
| table | /table slash command |
| image | /image slash command |
| link | Link button (Ctrl+K) |
// Disable specific builtins:
<ReactDocxEditor disableBuiltins={['highlight', 'blockquote']} />
// Disable ALL builtins (use your own only):
<ReactDocxEditor disableBuiltins="*" extensions={[myCustomExtension]} />Custom extensions
import type { DocxEditorExtension } from '@shashimadushan/docx-editor-editor';
const wordCountExtension: DocxEditorExtension = {
id: 'word-count',
name: 'Word count',
toolbar: [{
id: 'word-count',
label: '📝 Words',
onClick: (ctx) => {
const text = ctx.editor.getText();
const count = text.trim().split(/\s+/).filter(Boolean).length;
alert(`${count} words`);
},
}],
};
<ReactDocxEditor extensions={[wordCountExtension]} />DocxEditorExtension interface
interface DocxEditorExtension {
id: string; // unique id
name?: string; // display name
tiptapExtensions?: Extension | Extension[]; // TipTap nodes/marks to register
toolbar?: ToolbarButton[]; // toolbar buttons
slashCommands?: SlashCommand[];// "/" menu items
commands?: Record<string, (args: any) => Command>; // custom editor commands
onInit?: (ctx: ExtensionContext) => void;
onDestroy?: (ctx: ExtensionContext) => void;
}
interface ToolbarButton {
id: string;
label: string;
icon?: string;
tooltip?: string;
isActive?: (ctx: ExtensionContext) => boolean;
isDisabled?: (ctx: ExtensionContext) => boolean;
onClick: (ctx: ExtensionContext) => void;
}
interface ExtensionContext {
editor: TipTapEditor;
insertHTML(html: string): void;
insertText(text: string): void;
focus(): void;
getJSON(): any;
getHTML(): string;
setJSON(json: any): void;
setHTML(html: string): void;
}Custom toolbar
Replace the entire default toolbar with renderToolbar:
<ReactDocxEditor
renderToolbar={(editor) => (
<div style={{ display: 'flex', gap: 4, padding: 8 }}>
<button onClick={() => editor.editor.chain().focus().toggleBold().run()}>
B
</button>
<button onClick={() => editor.editor.chain().focus().toggleItalic().run()}>
I
</button>
<button onClick={() => editor.downloadDocx('my-doc.docx')}>
Save
</button>
</div>
)}
/>.docx load/save
Load from file input
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !editor) return;
const buffer = await file.arrayBuffer();
await editor.loadDocx(buffer);
};
<input type="file" accept=".docx" onChange={handleFile} />Save to server
const saveToServer = async () => {
const blob = await editor.saveDocx({ title: 'My Document', creator: 'Me' });
const formData = new FormData();
formData.append('file', blob, 'document.docx');
await fetch('/api/upload', { method: 'POST', body: formData });
};Save options
interface SaveDocxOptions {
title?: string; // document title (core properties)
creator?: string; // author name (core properties)
}What's preserved when loading .docx
The mammoth style map maps these Word styles to semantic HTML:
| Word style | HTML element |
|---|---|
| Heading 1–9 | <h1>–<h6> |
| Title | <h1 class="docx-title"> |
| Subtitle | <h2 class="docx-subtitle"> |
| Quote / Intense Quote | <blockquote> |
| Code / Source Code | <pre> |
| List Bullet / Number | <ul> / <ol> |
| Bold / Italic / Underline / Strikethrough | <b> / <i> / <u> / <s> |
| Superscript / Subscript | <sup> / <sub> |
| Tables | <table> with <th> / <td> |
| Images | <img src="data:..."> |
| Hyperlinks | <a href="..."> |
| Caption | <p class="docx-caption"> |
Not preserved (out of scope): headers/footers, footnotes/endnotes, page breaks, watermarks, content controls, floating images, embedded fonts.
CSS
The editor's CSS is bundled separately. Import it once in your app entry:
import '@shashimadushan/docx-editor-editor/style.css';
// or
import '@shashimadushan/docx-editor-editor/styles';All classes are prefixed with docx-editor- to avoid collisions. All design tokens are CSS custom properties prefixed with --de-.
Package structure (for contributors)
src/react/ follows a one component per file convention. A file that
used to export several related components (e.g. ToolbarPrimitives.tsx,
MenuBar.tsx, ui-widgets.tsx, document-structure.tsx,
context-menu/menuPrimitives.tsx, context-menu/TableContextMenu.tsx) is
now a same-named folder containing one file per component plus an
index.ts barrel that re-exports everything the original file exported —
so external imports (from './MenuBar', from './document-structure',
etc.) are unchanged. New multi-component additions should follow the same
pattern: create ComponentGroup/Thing.tsx per component and re-export from
ComponentGroup/index.ts.
Exception: src/react/icons.tsx stays a single file — it's a set of ~30
trivial one-line SVG icon components, and icon sets are conventionally
bundled together (same as lucide-react/heroicons) rather than split one
file per icon.
src/styles/ mirrors that structure for CSS: editor.css is a manifest of
@import statements pulling in focused partials under base/ (reset,
design tokens, outer layout), components/ (title bar, menu bar, toolbar,
suggestions, table picker, watermark/header/footer, agent panel, status
bar), editor-surface/ (paged paper, imported-.docx layout preservation,
placeholder/page-break/pageless/image content blocks), tables/ (all
in-document table styling), and theme/ (dark mode). print.css covers
@media print. Add a new concern as a new partial + @import line rather
than growing an existing file.
The build (tsup.config.ts's onSuccess) copies the whole src/styles/
tree to dist/styles/ so those relative @imports keep resolving, and
publishes dist/style.css as a one-line shim (@import './styles/editor.css';)
at the same path consumers already import
(@shashimadushan/docx-editor-editor/style.css).
The public API surface is exactly what src/react.ts, src/index.ts, and
src/headless.ts export — internal file moves must never change those
barrels' exports.
License
MIT
