# @shashimadushan/docx-editor-editor

A clean, extensible, framework-agnostic **DOCX editor** built on [TipTap](https://tiptap.dev). Word / Google-Docs-style paged layout, high-fidelity `.docx` load/save with OOXML round-trip, Editing / Suggesting / Viewing modes, text-anchored comments, an edit-history sidebar, an opt-in ruler, `Ctrl/Cmd+F` Find & Replace, a touch-friendly mobile layout, a headless build for the server, and a public extension API for adding your own tools.

The AI agent (`@shashimadushan/docx-editor-agent`), the Sri Lankan legal templates (`@shashimadushan/docx-editor-legal-templates`) and real-time collaboration (`@shashimadushan/docx-editor-collaboration`) are **separate, opt-in packages** — this one has no dependency on them.

## Install

```bash
npm install @shashimadushan/docx-editor-editor react react-dom
```

`react` / `react-dom` are peer dependencies (only needed for the `/react` bindings). `puppeteer` is an **optional** peer, used only if you call the headless `renderHtmlToPdf()`.

## Quick start (React)

```tsx
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('changed:', html)}
      />
    </div>
  );
}
```

> **⚠️ The host must have a real height** (`height: 100vh`, or `flex: 1` inside a flex column). The editor uses a paged layout where only the page surface scrolls — with no height it renders zero-tall and looks blank.

## Quick start (vanilla JS)

```ts
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();
```

## Entry points

| Import | Contents |
|---|---|
| `@shashimadushan/docx-editor-editor` | `DocxEditor` class + all types, framework-agnostic. |
| `.../react` | `ReactDocxEditor` + every composable component (`HeaderActions`, `Toolbar`, `MenuBar`, `StatusBar`, `ModeSwitcher`, `Ruler`, `FindReplace`, `CommentsLayer`, `SuggestionLayer`, `HistorySidebar`, `TemplatePicker`, …), the history/comments storage adapters, and the `useDocumentHistory` hook. |
| `.../headless` | `parseDocx`, `extractText`, `processTemplate`, `renderHtmlToPdf` — no DOM/React, for Node. |
| `.../diff` | The block-level diff used by the history sidebar and the agent's `rewrite_range`. |
| `.../style.css` (alias `.../styles`) | The stylesheet — import once in your app entry. |

---

## `<ReactDocxEditor />` props

| Prop | Type | Default | Description |
|---|---|---|---|
| `content` | `string` | `'<p></p>'` | Initial HTML. |
| `theme` | `DocxEditorTheme` | Word-like | Theme tokens (see below). |
| `pageSetup` | `PageSetup` | last-saved / A4 | `{ size, orientation, margins }`. Persisted to `localStorage`; the **View → Page setup** dialog writes it too. |
| `editable` | `boolean` | `true` | Editable flag. |
| `mode` | `'editing' \| 'suggesting' \| 'viewing'` | — (user picks) | **Locks** the mode pill to a host-controlled value and hides its dropdown. Omit for a freely-switchable pill. |
| `filename` | `string` | `'Untitled.docx'` | Title-bar filename (also the download name). |
| `onFilenameChange` | `(name: string) => void` | — | Fires when the user edits the filename. |
| `placeholder` | `string` | `'Start writing…'` | Empty-doc placeholder. |
| `disableBuiltins` | `string[] \| '*'` | `[]` | Formatting-extension ids to disable, or `'*'` for all. |
| `extensions` | `DocxEditorExtension[]` | `[]` | Your custom extensions. |
| `tiptapExtensions` | `Extension[]` | defaults | Replace the TipTap schema entirely. |
| `onUpdate` | `(json, html) => void` | — | Every content change. |
| `onReady` | `(editor: DocxEditor) => void` | — | Once, on mount. |
| `renderToolbar` / `renderMenuBar` / `renderTitleBar` / `renderStatusBar` | `(editor) => ReactNode` | defaults | Replace a whole section. |
| `showTitleBar` / `showMenuBar` / `showStatusBar` | `boolean` | `true` | Toggle a section. |
| `showRuler` | `boolean` | `false` | Show the horizontal indent / tab-stop ruler above the page. |
| `titleBarActions` | `ReactNode \| HeaderActionItem[]` | — | Host chrome for the header's right side. A bare node renders at the `after-mode` slot; a `HeaderActionItem[]` (`{ id, node, placement }`) drops each node into a named slot — see **Header customization**. |
| `hideDefaultHeaderActions` | `{ open?; save?; exportPdf? }` | — | Hide individual built-in header buttons (no CSS needed). |
| `onExportPdf` | `(editor: DocxEditor) => void` | print dialog | Override "Export PDF" — e.g. POST `editor.getExportSnapshot()` to a server that runs `renderHtmlToPdf()`. |
| `history` | `false \| HistoryManagerOptions` | `LocalStorage` adapter | Edit-history sidebar config (`{ storage, key, maxEntries }`). `false` hides the button and disables checkpoint recording. See **Edit history**. |
| `comments` | `false \| { storage?, mentionableUsers?, currentAuthor?, … }` | in-memory | Text-anchored comment threads. `false` disables the feature. See **Comments**. |
| `agent` | `AgentMentionAdapter` | — | Enables the in-document `@` agent bubble + "Edit with AI" selection menu. Pass `{ sendMessage }` from `@shashimadushan/docx-editor-agent`'s `useDocxAgent()`. |
| `onAddSelectionToAgent` | `(info: SelectionContextInfo) => void` | — | Adds "Add to agent" to the selection right-click menu. |
| `templates` / `onInsertTemplate` | `TemplatePickerItem[]` / `(id) => void` | — | A "Templates" toolbar dropdown (e.g. from `@shashimadushan/docx-editor-legal-templates`). |
| `clauses` / `onInsertClause` | `TemplatePickerItem[]` / `(id) => void` | — | A "Clauses" dropdown next to Templates. |
| `toolbarExtraItems` / `toolbarExtraGroups` | `ReactNode` | — | Append / prepend custom toolbar content (desktop). |
| `enableContextMenu` | `boolean` | `true` | Right-click menus (page / selection / image) + link dialog. |
| `layout` | `'desktop' \| 'mobile'` | `'desktop'` | `'mobile'` swaps in the touch chrome (tabbed toolbar, folded header, selection format bar). |
| `className` / `style` | — | — | On the host wrapper. |

### Header customization

`titleBarActions` accepts positioned items so your buttons can sit **between** the built-ins, not just after them:

```tsx
<ReactDocxEditor
  hideDefaultHeaderActions={{ save: true }}   // app saves to a backend, not a local download
  titleBarActions={[
    { id: 'save',   placement: 'after-export-pdf', node: <SaveToBackend /> },
    { id: 'status', placement: 'after-history',    node: <SaveStatusBadge /> },
    { id: 'lang',   placement: 'after-history',    node: <LanguageSwitcher /> },
  ]}
/>
```

Slots, left → right:
`start` · `before-open` · **[Open]** · `after-open` · **[Save]** · `after-save` · **[Export PDF]** · `after-export-pdf` · **[History]** · `after-history` · `│` · `before-mode` · **[mode pill]** · `after-mode` (default) · `end`.

Exports: `HeaderActionItem`, `HeaderActionPlacement`, `HeaderDefaultActionToggles`. To change the built-in buttons themselves, compose `HeaderActions` by hand or use `renderTitleBar`.

### Editing / Suggesting / Viewing modes

The mode pill lives in the header, next to Export PDF.

- **`editing`** — normal.
- **`suggesting`** — turns on track changes: edits become `<ins>` / `<del>` marks resolved via Accept/Reject. Consecutive keystrokes that abut the same author's run share one change id (5-min window), so a typed word is **one** suggestion with **one** Accept/Reject card — not one per letter (0.6.1+). The `SuggestionLayer` card opens automatically while the caret is inside a tracked span, and stays reachable across the page margin (350 ms grace + a `mousemove` safe corridor).
- **`viewing`** — read-only.

Pass `mode` to lock it (the pill becomes a plain indicator). `editor.getMode()` / `setMode()`, `acceptSuggestion(id)` / `rejectSuggestion(id)` / `acceptAllSuggestions()` / `rejectAllSuggestions()`, `setSuggestionAuthor(name)`.

### Edit history

A "History" button (after Export PDF) opens a docked sidebar (shrinks the editor, never overlays) listing every manual save and every AI-agent turn that changed the document, newest first. Hovering an entry highlights the change word-level; clicking reverts non-destructively (appends a "restored" entry).

```tsx
import { HttpHistoryAdapter } from '@shashimadushan/docx-editor-editor/react';

// default: LocalStorageHistoryAdapter, zero setup.
<ReactDocxEditor onReady={setEditor} />

// persist to your own backend:
const adapter = React.useMemo(() => new HttpHistoryAdapter({ baseUrl: '/api/history', documentId }), [documentId]);
<ReactDocxEditor history={{ storage: adapter, key: documentId }} />
// history={false}  → hide the button, disable recording
```

Adapters (all from `.../react`): `MemoryHistoryAdapter`, `LocalStorageHistoryAdapter(key)`, `IndexedDBHistoryAdapter(key, { dbName? })`, `HttpHistoryAdapter({ baseUrl, documentId?, headers? })`. Manual saves call `editor.recordCheckpoint({ source, label })`; the agent records one checkpoint per `run()` that mutated the doc, automatically, when `history` is enabled.

### Comments

Select text → "Comment" → a draggable panel with an `@`-mention composer, toggled from a bubble pinned to the page margin. Independent of Suggesting-mode track changes — the in-document `comment` mark carries only an id and round-trips with the document; thread content lives in a `CommentsStorageAdapter`.

```tsx
<ReactDocxEditor
  comments={{
    storage: myAdapter,                    // default: MemoryCommentsAdapter (lost on reload)
    mentionableUsers: [{ id: 'u1', name: 'Priya Fernando' }],
    currentAuthor: 'You',
  }}
/>
```

Built-in adapters: `MemoryCommentsAdapter`, `LocalStorageCommentsAdapter`. No built-in HTTP/IndexedDB adapter — write your own against the `CommentsStorageAdapter` interface. Editor methods: `addCommentAnchor(id, from, to)`, `removeCommentAnchor(id)`, `listCommentAnchors()`, `hasComments()`.

### Find & Replace, Ruler, Page Setup

- **Find & Replace** — opens on `Ctrl/Cmd+F`, wired into `ReactDocxEditor` by default; match highlighting, replace / replace-all, case + whole-word toggles. Also exported as `<FindReplace editor open onClose />`.
- **Ruler** — opt-in via `showRuler`; horizontal indent / tab-stop ruler that tracks the page width (recenters when a sidebar docks).
- **Page Setup** — **View → Page setup** dialog (size A4/Letter/Legal/A5, orientation, margins), persisted to `localStorage`. `editor.getPageSetup()` / `setPageSetup()`.

### Mobile layout

`layout="mobile"` sets `data-de-layout="mobile"` on the host and swaps the desktop title/menu/toolbar/status rows for touch equivalents: `MobileHeader` (folded filename + a "⋯" sheet), tabbed `MobileToolbar` (Home / Insert / Table / Image, 44px targets), `MobileStatusBar` (word count + zoom), and a floating `SelectionFormatBar` above any selection. All mobile CSS is scoped under that attribute, so omitting the prop reproduces desktop exactly. This is what `@shashimadushan/docx-editor-react-native` runs inside a WebView.

---

## `DocxEditor` class

### Lifecycle
`new DocxEditor(opts)` · `mount(el)` · `destroy()`

### `.docx` I/O
| Method | Description |
|---|---|
| `loadDocx(buffer)` | Load a `.docx`. Tries the high-fidelity OOXML parser, falls back to mammoth. |
| `saveDocx(options?)` | Full serialize → `Blob`. For a fresh document, wraps the output in a generated package whose `styles.xml`/`numbering.xml` make Word match the screen. |
| `saveDocxWithSelectivePatch()` | **Preferred for anything opened from a `.docx`** — patches only the XML for changed paragraphs, preserving the original file's styles / numbering / headers / anything the editor doesn't model. Falls back to `saveDocx()` when there's no original buffer. |
| `downloadDocx(filename?, options?)` | Browser download. |
| `printToPdf()` | Browser print dialog. |
| `getExportSnapshot()` | `{ html, stylesheetCss, … }` for server-side PDF rendering (`renderHtmlToPdf()` in `/headless`). |

### Content
`getJSON()` / `setJSON(json)` · `getHTML()` / `setHTML(html)` · `getText()` · `replaceContent(json)` (agent edit path — fires `onUpdate`) · `setEditable(b)` / `getEditable()`

### Modes & suggestions
`getMode()` / `setMode(m)` · `setSuggestionAuthor(name)` · `hasPendingSuggestions()` · `listPendingSuggestionIds()` · `acceptSuggestion(id)` / `rejectSuggestion(id)` · `acceptAllSuggestions()` / `rejectAllSuggestions()`

### History & comments
`recordCheckpoint(meta)` · `onCheckpoint(listener) → unsubscribe` · `previewHistoryDiff(diff)` / `clearHistoryDiffPreview()` · `addCommentAnchor(id, from, to)` · `removeCommentAnchor(id)` · `listCommentAnchors()` · `hasComments()`

### Headers & footers
`getHeaderHTML()` / `setHeaderHTML(html)` · `getFooterHTML()` / `setFooterHTML(html)` · `getHeaderElement()` / `getFooterElement()` · `setShowPageNumber(b)` · `getHeaderFooter()`

### Page & view
`getPageSetup()` / `setPageSetup(s)` · `getViewMode()` / `setViewMode('paged' | 'pageless')` · `getWatermark()` / `setWatermark(cfg)` / `getWatermarkPresets()` · `getCursorContext(chars?)`

### Extensions & DOM
`registerExtension(ext)` · `getToolbarButtons()` · `getSlashCommands()` · `getExtensionContext()` · `registry` · `getHost()` / `getSurface()` / `getPage()` · `editor` (the TipTap `Editor`) · `setLoading(b, message?)`

---

## OOXML round-trip fidelity

The high-fidelity parser/serializer preserves, on load **and** save:

| Feature | How it round-trips |
|---|---|
| Headings, lists, tables, images, hyperlinks, bold/italic/underline/strike/super/subscript, alignment, colors, highlights | semantic HTML nodes/marks |
| **Content controls** (`w:sdt`) | bordered `ContentControlMark` (inline scope) — merge-field placeholders survive |
| **Simple fields** (`w:fldSimple`) | `FieldMark` with cached display text (PAGE/DATE/etc. keep their last value, not re-evaluated) |
| **Bookmarks** (`w:bookmarkStart/End`) | invisible `BookmarkMark` anchor — internal hyperlinks resolve |
| **Tab stops** (`w:tabs`) | `ParagraphLayout` extension — real tab-stop positions, not a bare `\t` |
| **Headers & footers** (`header1.xml` / `footer1.xml`) | read on import, written on save |
| **Tracked changes** (`w:ins` / `w:del`) | `insertion` / `deletion` marks — a doc edited in Word's track-changes mode opens with revisions intact |
| Embedded fonts | loaded before content renders |

`saveDocxWithSelectivePatch()` keeps everything the editor *doesn't* model (section properties, custom XML, macros, …) byte-for-byte by only rewriting changed paragraphs.

---

## Theme tokens

```tsx
<ReactDocxEditor theme={{
  background: '#f1f5f9',   // desk color around the page
  text: '#1f2328',
  accent: '#2563eb',       // selection, active toolbar button
  border: '#d0d7de',
  fontFamily: 'Inter, sans-serif',
  fontSize: '11pt',
  pageWidth: '794px', pageHeight: '1123px',   // A4 @ 96dpi
  pageMarginX: '96px', pageMarginY: '96px',
  pageGap: '24px', lineHeight: 1.5,
}} />
```

Or set CSS custom properties (`--de-*`) on any parent. All classes are prefixed `docx-editor-`; all tokens `--de-`.

| Format | pageWidth | pageHeight |
|---|---|---|
| A4 | `794px` (21cm) | `1123px` (29.7cm) |
| US Letter | `816px` (8.5in) | `1056px` (11in) |
| Legal | `816px` | `1344px` (14in) |
| A5 | `559px` | `794px` |

---

## Built-in formatting extensions (14)

`bold` · `italic` · `underline` · `strike` · `highlight` · `heading` (H1–H3) · `list` (bullet / ordered) · `alignment` (left/center/right/justify) · `codeBlock` · `blockquote` · `horizontalRule` · `table` · `image` · `link`.

```tsx
<ReactDocxEditor disableBuiltins={['highlight', 'blockquote']} />
<ReactDocxEditor disableBuiltins="*" extensions={[myExtension]} />
```

Document features like paged layout, page breaks, track changes, comments, content controls, fields, bookmarks, tab stops, headers/footers, watermarks and image/table interactions are always-on schema extensions, not in this list.

## Custom extensions

```ts
import type { DocxEditorExtension } from '@shashimadushan/docx-editor-editor';

const wordCount: DocxEditorExtension = {
  id: 'word-count',
  name: 'Word count',
  toolbar: [{
    id: 'word-count',
    label: '📝 Words',
    onClick: (ctx) => alert(ctx.editor.getText().trim().split(/\s+/).filter(Boolean).length + ' words'),
  }],
};

<ReactDocxEditor extensions={[wordCount]} />
```

```ts
interface DocxEditorExtension {
  id: string;
  name?: string;
  tiptapExtensions?: Extension | Extension[];
  toolbar?: ToolbarButton[];
  slashCommands?: SlashCommand[];
  commands?: Record<string, (args: any) => Command>;
  onInit?: (ctx: ExtensionContext) => void;
  onDestroy?: (ctx: ExtensionContext) => void;
}
```

---

## Headless (Node)

```ts
import { parseDocx, extractText, processTemplate, renderHtmlToPdf } from '@shashimadushan/docx-editor-editor/headless';

const text = await extractText(buffer);              // plain-text index
const { json, html } = await parseDocx(buffer);      // same parser the editor uses
const pdf = await renderHtmlToPdf(snapshot.html, { stylesheetCss: snapshot.stylesheetCss }); // needs puppeteer
```

---

## CSS

```ts
import '@shashimadushan/docx-editor-editor/style.css';
```

`dist/style.css` is a one-line `@import` manifest over partials in `dist/styles/` (`base/`, `components/`, `editor-surface/`, `tables/`, `theme/`, `mobile/`, `print.css`). If your PDF pipeline injects the stylesheet by string with no base URL, flatten the `@import`s first (or use `getExportSnapshot().stylesheetCss`, which is already flattened).

---

## License

MIT
