@docen/vue
@docen/vue
Vue 3 adapter for
@docen/editor— a typed<DocenDocument>component:v-modelfor content, av-slot="{ editor }"scope, and a template-ref expose.
The editor UI is a framework-neutral web component (<docen-document>); this package wraps it for idiomatic Vue — typed props/emits, two-way content binding, and direct access to the underlying Tiptap editor.
Install
pnpm add @docen/vue
@docen/editor comes along as a dependency; vue is a peer dependency (install it in your app).
Usage
<script setup lang="ts">
import { ref } from "vue";
import type { JSONContent } from "@docen/docx";
import { DocenDocument } from "@docen/vue";
import { parseDOCX } from "@docen/docx";
// v-model carries the flat Tiptap JSON model (doc > block+); the template ref
// exposes the Tiptap editor plus a getJSON/setJSON pair.
const content = ref<JSONContent>({ type: "doc", content: [{ type: "paragraph" }] });
const editorRef = ref();
async function open(file: File) {
const json = await parseDOCX(await file.arrayBuffer());
// setJSON routes through the host loader so doc.attrs.styles survive —
// editor.commands.setContent would drop them.
editorRef.value?.setJSON(json);
}
</script>
<template>
<DocenDocument ref="editorRef" v-model="content" filename="Welcome.docx" editable />
</template>
Reaching the editor without a ref — via the default slot scope:
<DocenDocument v-model="content" v-slot="{ editor }">
<button :disabled="!editor" @click="editor?.chain().focus().toggleBold().run()">B</button>
</DocenDocument>
v-model
v-model binds the document content as Tiptap JSON — the public model is a flat doc > block+ (pages are a rendering projection, never stored in the model):
- Setting
modelValuecallshost.setJSON(json), which routes through the host's loader (a freshEditorState) so document-level attrs (styles,core,sectionProperties) survive —editor.commands.setContentwould drop them. The set is skipped whenmodelValueis the same reference the adapter just emitted (round-trip echo break). - Every editor change emits
update:modelValuewithhost.getJSON(), debounced by thedebounceprop (default 300 ms; 0 = synchronous). A DOCX import triggers many change events as pagination reflows — onegetJSONper quiet window instead of one per transaction.
The initial modelValue seeds the editor on docen:ready via host.setJSON (not the content attribute, which would re-serialize a large string and lose doc-level attrs).
Props
Mirror the <docen-document> attributes. Pass undefined to leave an attribute unset (the web component's default applies).
| Prop | Type | Attribute | Notes |
|---|---|---|---|
modelValue |
object | — (setJSON) | Tiptap JSON (flat doc > block+); two-way |
debounce |
number | — | Emit debounce ms (default 300; 0 = sync) |
filename |
string | filename |
|
editable |
boolean | editable |
|
user / avatar |
string | user / avatar |
Identity in the header |
sectionProperties |
object | section-properties |
JSON page setup (size/margin/orientation) |
styles |
object | styles |
JSON named-styles model |
addins |
array | addins |
JSON external add-ins (ribbon/task-pane) |
theme |
string | theme |
Fluent built-in key; reactive |
navigationPane |
boolean | navigation-pane |
Initial nav-pane visibility (once) |
propertiesPane |
boolean | properties-pane |
Initial properties-pane visibility (once) |
zoom |
number | zoom |
Initial zoom percent (once) |
showMarks |
boolean | show-marks |
Initial marks visibility (once) |
lang |
string | lang |
BCP-47 UI locale; per-instance, reactive |
Events
Re-emitted from the web component's docen:* events. Host events forward the
native CustomEvent — read the payload from e.detail:
update:modelValue— editor content changed (drives v-model; Tiptap JSON)@change—e.detail: { dirty: true }; fires on every content-changing transaction (selection-only tx skipped). Lightweight notification only — for the actual content usev-model(update:modelValueis debounced); don't callgetJSON()in this handler on large docs (it's O(n)).@zoom-change({ zoom }),@taskpane-visibility-change({ id, visibilityMode }),@marks-change({ showMarks })@lang-change({ lang }),@theme-change({ theme })
Taking over save / open / print
@save, @save-as, @open, @new, @print are cancelable: call
e.preventDefault() to suppress the host's built-in action (save → native DOCX
save-as dialog, open → file picker, print → browser print) and take over:
<script setup lang="ts">
import type { DocenSaveEvent } from "@docen/vue";
async function onSave(e: DocenSaveEvent) {
e.preventDefault(); // stop the host's built-in save-as dialog
// ...persist to your backend instead
}
</script>
<template>
<DocenDocument @save="onSave" />
</template>
Template ref
The ref exposes { editor, getElement(), getDisplayLanguage(), getJSON(), setJSON(json), addAddin(addin) }, where editor is the Tiptap Editor (undefined until the editor is live). getJSON()/setJSON() mirror the host's content surface; getDisplayLanguage() returns the current UI locale (Office.context.displayLanguage equivalent).
addAddin(addin) registers an Office.js-style add-in at runtime (commands / task-pane / ribbon / mini-toolbar). Use it for add-ins carrying functions — the addins prop only accepts JSON-serializable data (attribute values are strings), so command handlers and pane render functions must register through the ref. Wait for the host to be live (the exposed editor is non-undefined) before calling:
<script setup lang="ts">
import { ref, watch } from "vue";
import { DocenDocument, type DocenAddin } from "@docen/vue";
const docRef = ref();
const myAddin: DocenAddin = {
id: "my-addin",
commands: { "my-action": () => alert("run") },
};
// addAddin needs the host live — `editor` is undefined until docen:ready.
watch(
() => docRef.value?.editor,
(ed) => ed && docRef.value?.addAddin(myAddin),
);
</script>
<template>
<DocenDocument ref="docRef" />
</template>
Internationalization
The adapter re-exports the i18n API from @docen/editor, so a Vue app registers
locales from the same entry. The host ships with English (en, default) and
Chinese (zh-CN); add more by registering a translation table:
import { registerTranslation } from "@docen/vue";
registerTranslation({
languageTag: "fr",
$name: "Français",
translations: { "ribbon.tab.home": "Accueil" /* … */ },
});
Bind the locale with :lang + @lang-change — the host forwards the attribute
to its workspace and every label re-resolves live:
<script setup lang="ts">
import { ref } from "vue";
import { DocenDocument } from "@docen/vue";
const lang = ref("en");
</script>
<template>
<DocenDocument :lang="lang" @lang-change="lang = $event.lang" />
</template>
Re-registering a tag merges, so an add-in can extend a built-in locale with its
own keys. availableLanguages() lists every registered tag.
Theming
<docen-document> ships 8 Fluent built-in themes. Bind with :theme +
@theme-change (mirrors :lang / @lang-change):
<script setup lang="ts">
import { ref } from "vue";
import { DocenDocument } from "@docen/vue";
const theme = ref("light");
</script>
<template>
<DocenDocument :theme="theme" @theme-change="theme = $event.theme" />
</template>
Keys: light, dark, high-contrast, teams-light, teams-dark,
teams-high-contrast, teams-light-v21, teams-dark-v21. In dark / high
contrast the page paper and body ink both follow the theme (Word Dark Mode
behavior); documents still print with their original colors.
Register a custom brand theme with registerTheme(id, createLightTheme(brand))
from @docen/editor — brand is a full 16-step BrandVariants ramp (10 darkest
→ 160 lightest; missing shades make createLightTheme emit undefined tokens
that crash setGlobalTheme) — then set :theme="id". The built-in set is
iterated via builtinThemes.keys().
Why a separate package?
The adapter imports @docen/editor, which registers the <docen-document> custom element as a side effect. Isolating that in @docen/vue (peer-depending on vue) keeps non-Vue consumers of @docen/editor unaffected, and keeps Vue out of the framework-neutral core.
SSR (Nuxt)
That side effect runs customElements.define(...) at module load. Node has no customElements global, so a server bundle that evaluates import "@docen/vue" throws a ReferenceError. <ClientOnly> only guards rendering, not module evaluation — use one of:
- render the page client-only:
definePageMeta({ ssr: false }), or name the component*.client.vue; - or load the adapter dynamically inside the client tree:
import { defineAsyncComponent } from "vue";
const DocenDocument = defineAsyncComponent(() => import("@docen/vue").then((m) => m.DocenDocument));
License
Related Packages
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.7.0 | >=3.3.0 | MIT | |
| 0.6.2 | >=3.3.0 | MIT | |
| 0.6.1 | >=3.3.0 | MIT | |
| 0.6.0 | >=3.3.0 | MIT | |
| 0.5.5 | >=3.3.0 | MIT | |
| 0.5.4 | >=3.3.0 | MIT | |
| 0.5.3 | >=3.3.0 | MIT | |
| 0.5.2 | >=3.3.0 | MIT | |
| 0.5.1 | >=3.3.0 | MIT | |
| 0.5.0 | >=3.3.0 | MIT | |
| 0.4.1 | >=3.3.0 | MIT | |
| 0.4.0 | >=3.3.0 | MIT |