@docen/editor

September 1, 2026 Β· View on GitHub

npm version npm downloads npm license

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 theme attribute
  • πŸ”„ DOCX round-trip β€” Open/save .docx through 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.

AttributeDefaultDescription
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)
editabletruefalse 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
themelight"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)
zoom100Initial zoom percent (once); runtime via setZoom
show-marksfalsetrue 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.)

EventWhenDetail
docen:readyEditor mounted and readyβ€”
docen:changeDocument content changed (autosave driver){ dirty }
docen:saveSave button β€” preventDefault() to take overβ€”
docen:save-asSave As menu β€” preventDefault() to take over{ format }
docen:openOpen menu β€” preventDefault() to take overβ€”
docen:newNew menu β€” host-only (no built-in action)β€”
docen:printPrint menu β€” preventDefault() to take overβ€”
docen:taskpane-visibility-changeA task pane opened/closed (method or pane βœ•){ id, visibilityMode }
docen:zoom-changeZoom changed (button / slider / setZoom){ zoom }
docen:marks-changeFormatting marks toggled{ showMarks }
docen:lang-changeLocale 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.

SlotDefaultDescription
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