@docen/editor
September 1, 2026 Β· View on GitHub
Assembly layer for docen editors β bundles a Fluent UI host with the @docen/docx Tiptap engine into turnkey web components like
<docen-document>, and owns the LeaferJS canvas stage that renders the paginated pages.
Features
- π§© Turnkey
<docen-document>β One custom element bundles the Fluent UI host (title bar, ribbon, document area, status bar, panes, find/replace) with the @docen/docx engine - π¨ Canvas rendering β Pages render on a LeaferJS canvas (no contenteditable, no DOM text); a viewless Tiptap model drives editing through a textarea bridge
- π Office-style pagination β Fixed-height pages with Word's stacking rules (docGrid pitch, table band split, widow/orphan) from @docen/layout; layout re-runs on every edit
- ποΈ Fluent UI surfaces β Ribbon (buttons, split/toggle buttons, combobox, galleries, color picker), workspace, task/navigation/format panes, context menu
- π i18n β Built-in Chinese (zh-CN) and English (en); add more via
registerTranslation/localizationInfo. Switch live from the status bar (cycles every registered locale) or the Options dialog - π Light/dark theme β Fluent design tokens drive the chrome; switch via the
themeattribute - π DOCX round-trip β Open/save
.docxthrough the underlying @docen/docx engine - π Add-ins β Plug in ribbon tabs, task panes, and commands without touching host internals
Installation
# Install with pnpm
$ pnpm add @docen/editor
# Install with npm
$ npm install @docen/editor
Quick Start
<docen-document> is a self-contained custom element β register the components,
apply a theme, and drop it in.
<docen-document id="doc" user="Demo Macro" filename="Welcome.docx"></docen-document>
<script type="module">
import { registerComponents, applyTheme } from "@docen/editor";
registerComponents(); // register all custom elements
applyTheme("light"); // "light" | "dark"
</script>
Open and save DOCX imperatively:
const doc = document.querySelector<DocenDocument>("#doc")!;
await doc.openDOCX(file); // File | ArrayBuffer | Uint8Array
const output = await doc.saveDOCX(); // β Uint8Array
API
Web component: <docen-document>
A turnkey WYSIWYG document editor. The title bar (brand, auto-save, save/undo/redo, filename menu), ribbon, document area, status bar (page/word count, language indicator, zoom), and panes are all built in.
Attributes
Configuration attributes split by reactivity:
- Reactive β change at runtime and the component re-renders:
editable,filename,user,avatar,section-properties,styles,addins,theme. - Once β read only on connect (initial value); runtime control goes through
methods:
content,navigation-pane,properties-pane,zoom,show-marks.
The chrome (title bar, ribbon, status bar, panes) is always shown β extend it via add-ins rather than toggling attributes.
| Attribute | Default | Description |
|---|---|---|
user | β | Display name shown in the header |
avatar | β | Avatar image URL (omitted β initial-letter avatar) |
filename | "Document" | Document name shown in the header and save dialog default |
content | β | Initial document as Tiptap JSON (once on connect) |
editable | true | false makes the surface read-only (reactive) |
section-properties | β | JSON section page setup (size, margins, orientation); reactive |
styles | β | JSON named styles; reactive |
addins | β | JSON array of external add-ins (ribbon/task-pane data); see Add-ins |
theme | light | "light" | "dark"; drives the Fluent theme |
navigation-pane | β | true opens the navigation (left) pane on connect (once) |
properties-pane | β | true opens the properties (right) pane on connect (once) |
zoom | 100 | Initial zoom percent (once); runtime via setZoom |
show-marks | false | true shows page/section-break markers (once); runtime setShowMarks |
lang | β | BCP-47 UI locale ("zh-CN" / "en" / β¦); per-instance, reactive |
Unwired ribbon commands (skeleton buttons) render visually but are greyed out
(disabled) β the ribbon keeps its full Office shape without dead clicks.
Methods
class DocenDocument extends HTMLElement {
// Open β single entry point auto-detects docx/md from the extension.
open(file: File): Promise<void>;
// Format-specific loaders (use when the format is known up front, e.g. a
// server-fetched docx buffer with no filename).
openDOCX(input: File | ArrayBuffer | Uint8Array): Promise<void>;
openMarkdown(input: File | string): Promise<void>;
saveDOCX(): Promise<Uint8Array>;
saveMarkdown(): string;
// Runtime model β flat Tiptap JSON (doc > block+). Pages are a rendering
// projection (@docen/layout paginates per edit), never stored in the model.
// For Tiptap's own getText / setContent / chain, use getEditor().
getJSON(): JSONContent;
setJSON(json: JSONContent): void;
// The underlying @docen/docx Tiptap Editor β the full Tiptap API surface.
getEditor(): Editor | undefined;
repaginate(): void;
// Task-pane visibility (Office.addin.showAsTaskpane / hide equivalent).
// `id` is "navigation" | "properties"; flips fire docen:taskpane-visibility-change.
showTaskpane(id: TaskPaneId): void;
hideTaskpane(id: TaskPaneId): void;
getTaskpaneState(id: TaskPaneId): boolean;
// Zoom (Office.Document.zoom.set equivalent; clamped 10β500).
setZoom(pct: number): void;
getZoom(): number;
// Editing/formatting marks (page + section-break markers).
setShowMarks(on: boolean): void;
getShowMarks(): boolean;
// Add-in registry β register/unregister ribbon + command contributions.
addAddin(addin: DocenAddin): void;
removeAddin(id: string): void;
// Office.context.displayLanguage equivalent β read-only current UI locale.
readonly displayLanguage: string;
}
Events
All events bubble and compose out of the shadow DOM β listen on the host
element. docen:save / :save-as / :open / :print are cancelable: call
preventDefault() to take over the action (otherwise the built-in behavior runs).
docen:save-as carries { format } ("docx" | "markdown") β which
Save-As variant the user picked. (docen:open is format-agnostic: the host
auto-detects docx/md from the chosen file's extension, so it carries no
detail.)
| Event | When | Detail |
|---|---|---|
docen:ready | Editor mounted and ready | β |
docen:change | Document content changed (autosave driver) | { dirty } |
docen:save | Save button β preventDefault() to take over | β |
docen:save-as | Save As menu β preventDefault() to take over | { format } |
docen:open | Open menu β preventDefault() to take over | β |
docen:new | New menu β host-only (no built-in action) | β |
docen:print | Print menu β preventDefault() to take over | β |
docen:taskpane-visibility-change | A task pane opened/closed (method or pane β) | { id, visibilityMode } |
docen:zoom-change | Zoom changed (button / slider / setZoom) | { zoom } |
docen:marks-change | Formatting marks toggled | { showMarks } |
docen:lang-change | Locale changed (status-bar cycle / Options OK) | { lang } |
Slots
The properties (right) pane body is slot-driven. The default fallback is the
built-in <docen-format-pane> (empty state); slot a custom element to take
over the pane β e.g. show image, table, or paragraph properties depending on
the current selection.
| Slot | Default | Description |
|---|---|---|
properties | <docen-format-pane> | Right pane body. Slot a component to own the properties UI. |
<docen-document>
<image-properties slot="properties"></image-properties>
</docen-document>
Selection-aware switching is the consumer's responsibility β the Office.js
model leaves task-pane content + navigation to the add-in. Track the selection
(e.g. on docen:change) and swap the slotted component (v-if / conditional
render). The built-in <docen-format-pane groups='[{title,fields:[β¦]}]'> is a
declarative radio / number / color renderer you can reuse as the slotted
content when declarative fields are enough.
Configuration
The component works out-of-box. Collaborative actions (save, open, print) hand off to the host via cancelable events; the UI is extended via add-ins.
<!-- Read-only document -->
<docen-document editable="false"></docen-document>
const doc = document.querySelector<DocenDocument>("#doc")!;
// Take over save (skip the built-in picker β route to your storage)
doc.addEventListener("docen:save", (event) => {
event.preventDefault();
saveToStorage(doc.getJSON());
});
// Autosave on change
doc.addEventListener("docen:change", () => scheduleAutosave());
Add-ins
Plug in ribbon tabs and commands without touching host internals β declaratively
(JSON attribute, data-only β functions can't cross the attribute boundary) or
imperatively (full add-in object via addAddin). Task-pane contributions are
reserved for a follow-up (the navigation/format panes are built-in today), so
only ribbon tabs flow through the addins attribute right now.
<!-- Declarative: a "Citations" ribbon tab -->
<docen-document
addins='[{"id":"citations","ribbon":[{"tab":"citations","label":"Citations","groups":[{"id":"tools","label":"Tools","controls":[{"type":"button","id":"cite","label":"Cite","event":"bold"}]}]}]}]'
></docen-document>
// Imperative: full add-in (commands / pane-render allowed here)
doc.addAddin({
id: "citations",
name: "Citations",
ribbon: [
{
tab: "citations",
label: "Citations",
groups: [
{
id: "tools",
label: "Tools",
controls: [/* β¦ */],
},
],
},
],
});
Ribbon control event names route to the engine's native Tiptap commands
(editor.chain().focus().<event>(value).run()), so a built-in name like bold
works directly. Override a command by contributing a Tiptap extension whose
addCommands redefines the same name.
Internationalization
The host ships with English (en, default) and Chinese (zh-CN). Every label
runs through a single t(key) lookup, and the locale resolves per-instance
from <docen-document lang> (forwarded to the internal workspace) β set the
attribute and the ribbon, header, status bar, and Options dialog re-localize
live, with no dependency on <html lang>. The status-bar language pill cycles
every registered locale; the Options dialog renders a <select> of the same
list.
Add a locale by registering its translation table β the Options dropdown and the status-bar cycle pick it up with no further wiring:
import { registerTranslation } from "@docen/editor";
registerTranslation({
languageTag: "fr",
$name: "FranΓ§ais",
translations: { "ribbon.tab.home": "Accueil" /* β¦ */ },
});
Re-registering a tag merges (later wins on key conflicts), so an add-in can
extend a built-in locale with its own keys without clobbering the base. The
Office.js manifest shape is supported too β pass localizationInfo on an
add-in and the host registers it on addAddin:
doc.addAddin({
id: "about",
localizationInfo: {
defaultLanguageTag: "en",
additionalLanguages: [{ languageTag: "zh-CN", translations: { "about.tab": "ε
³δΊ" } }],
},
// β¦ribbon, commandsβ¦
});
availableLanguages() lists every registered tag (for custom pickers); the
read-only displayLanguage getter mirrors Office.context.displayLanguage.
UI Bootstrap
import { registerComponents, applyTheme } from "@docen/editor";
registerComponents(); // registers <docen-document>
applyTheme("light"); // "light" | "dark"
License
- MIT Β© Demo Macro