Plugins
Plugins extend the editor with custom inlines — atomic objects
inside the text (mentions, chips, domain-specific fields) that take part in layout,
are drawn by you on the canvas, and survive saving/reopening the .docx.
Model
Plugins follow Quill’s model. Install them per instance, so two
<universal-write> instances on the same page can have different sets of
plugins, or for every instance with the static
register (like Quill.register): connected instances get the
plugin right away and new ones on connect.
editor.use(myPlugin); // install on this instance
editor.unuse('myapp.x'); // remove by id
editor.pluginIds(); // ['universalwrite.mention', ...]
UniversalWriteElement.register(myPlugin); // every instance, now and later
UniversalWriteElement.unregister('myapp.x');
Registering another plugin with the same id replaces it, and unregister
leaves alone a plugin the host installed with use(). A conflict in one
instance (e.g. an inline type another plugin already took) is reported with
console.warn and never stops the element from connecting.
Interface
interface UniversalWritePlugin {
id: string; // unique, e.g. 'myapp.chips'
inlineRenderers?: readonly UniversalWriteInlineRenderer[];
setup?(context: UniversalWritePluginContext): void | (() => void); // return = cleanup
toolbar?(context: { editor, lang: 'en' | 'pt' | 'es', theme: 'light' | 'dark', ui: UniversalWriteBarKit }): BarContribution; // bars
}
interface UniversalWriteInlineRenderer<T = unknown> {
type: string; // inline identifier, e.g. 'chip'
toText?(data: T): string; // fallback text (export, clipboard, .docx without plugin)
measure?(ctx): { widthTwips: number; heightTwips?: number };
draw?(ctx): void; // draws on the editor canvas
}
measure tells the layout engine how much space the inline occupies (in
twips); draw receives the CanvasRenderingContext2D, the
position in twips, and the current scale. setup runs on installation and
may register listeners on the editor events — return a
function for cleanup on unuse().
Example: custom chip
const chips = {
id: 'myapp.chips',
inlineRenderers: [{
type: 'chip',
toText: (data) => data.name,
measure: ({ fallbackText, measureText }) => ({
widthTwips: measureText(fallbackText) + 200,
}),
draw: ({ ctx, xTwips, yTwips, scale, fallbackText }) => {
ctx.fillStyle = '#e8def8';
ctx.fillRect(xTwips * scale, yTwips * scale, 300 * scale, 100 * scale);
ctx.fillStyle = '#381e72';
ctx.fillText(fallbackText, (xTwips + 50) * scale, yTwips * scale);
},
}],
setup: ({ editor }) => {
const onInput = (e) => { /* react to typing */ };
editor.addEventListener('uw-input', onInput);
return () => editor.removeEventListener('uw-input', onInput);
},
};
editor.use(chips);
editor.insertCustomInline('chip', { name: 'Proposal' });
Bar contributions
toolbar({ editor, lang, theme, ui }) adds commands and their places to the built-in
bars (topbar), on both the compact bar and the ribbon — the equivalent of
Quill’s modules.toolbar. It is called each time the bar is mounted
(language, compact/ribbon or plugin set changed), so labels can follow lang. Labels take a string
or { en, pt, es }; es is optional, and without it the Spanish UI shows the English text.
A plugin whose toolbar throws is left out of the bar (with a warning); the
rest of the bar still mounts.
interface BarContribution {
commands?: Command[]; // registered with the editor's own commands (shortcuts included)
compact?: {
title?: BarItem; leading?: BarItem; titlePlacement?: 'inline' | 'above-menus';
menus?: CompactMenu[]; groups?: BarGroup[]; trailing?: BarItem[];
};
ribbon?: { title?: BarItem; tabs?: RibbonTab[]; groups?: (BarGroup & { tab: string })[]; qat?: BarItem[] };
}
// menus, groups and tabs accept before: '<id>' to be placed before a sibling
// title REPLACES the built-in document title (id 'title'); the last contribution with a title wins
// leading and titlePlacement: the last contribution that sets them wins (default 'above-menus' when there is a title)
editor.use({
id: 'acme.tags',
inlineRenderers: [{ type: 'acme.tag', toText: (d) => `@${d.label}` }],
toolbar: ({ editor, lang }) => ({
compact: {
groups: [{
id: 'acme:tags', label: 'Tags', icon: 'person', before: 'extras',
items: [{ id: 'acme:tags:menu', label: 'Tags', icon: 'person', menu: () => [
{ label: lang === 'pt' ? 'Nome' : 'Name', onClick: () => editor.insertCustomInline('acme.tag', { label: 'Name' }) },
] }],
}],
},
}),
});
To take built-in items out of the bars (menus, groups, tabs, commands and their
shortcuts), list their ids in the hidden-commands attribute or the
hiddenCommands property — see the
API reference.
The editor’s bar as the app bar
A host can use the compact bar as its own app bar, Google Docs style: its title and
status at the left of the menu row (compact.title, which replaces the
built-in title — ribbon.title does the same in the ribbon) and its
actions at the right (compact.trailing). Widgets live in the element’s
shadow root, where the page’s icon fonts (Font Awesome, ligature fonts) do not reach;
the ui kit builds them with the bar’s look — Material Symbols as SVG,
ripple and the light/dark colors. Widgets are synced on every bar sync, including each
uw-theme-change; theme is the theme when the bar was mounted.
ui. | Description |
|---|---|
createBarButton({ icon, label, title, variant, size, disabled, onClick }) | variant: 'filled' (accent) · 'tonal' · 'outlined' (default with a label) · 'text' · 'icon' (default without a label) · 'icon-filled' / 'icon-tonal' (round icon button on an accent / tonal background). size: 'small': a 24px pill (status chips) or a 28px icon button. Icon + label: the space between them is only the gap (Material 3 padding). label/title: string or { en, pt, es } (the bar’s language; es optional, English otherwise). Keeps the focus in the document. |
createBarIcon(name, { size?, title? }) | A Material Symbol (send, save, visibility, check_circle, error, warning, folder_open, arrow_back, description…) in the text color, inside <span class="uo-ic-box">. |
hasBarIcon(name): boolean | Whether the built-in set has the icon (a raw <path…> in the 0 -960 960 960 viewBox also works). |
createBarRow({ title, actions, leading?, titlePlacement? }) | A row with the look of the bar's title row (title at the left in .uo-title-slot, actions at the right, leading before the title), to build the same minimal bar outside the editor. titlePlacement: 'above-menus': the geometry of the bar with the title above the menus (title on the first line, the menus' line empty but keeping its height, leading and actions spanning both) — the default when there is a title; 'inline' keeps one line. |
barCss · barStyleSheet() · adoptBarStyles(root) | The bars' CSS as a string · in a shared constructable CSSStyleSheet (null without support) · added once to a document or shadow root. |
editor.setAttribute('hidden-commands', 'save'); // the host saves (uw-save-request)
editor.use({
id: 'host.appbar',
toolbar: ({ ui }) => ({
compact: {
title: {
id: 'host:title', // own id: hiding 'title' does not hide it
widget: () => {
const el = document.createElement('div'); // gets the class uo-title-slot
const name = Object.assign(document.createElement('span'), { textContent: 'Policy.docx' });
el.append(ui.createBarIcon('description'), name, ui.createBarIcon('check_circle', { size: 18, title: 'Saved' }));
return { el };
},
},
trailing: [
{ id: 'host:preview', widget: () => ({ el: ui.createBarButton({ icon: 'visibility', label: 'Preview', variant: 'text', onClick: preview }) }) },
{ id: 'host:save', widget: () => ({ el: ui.createBarButton({ icon: 'save', label: 'Save', variant: 'tonal', onClick: save }) }) },
{ id: 'host:send', widget: () => ({ el: ui.createBarButton({ icon: 'send', label: { en: 'Send', pt: 'Enviar' }, variant: 'filled', onClick: send }) }) },
],
},
}),
});
Only the built-in editable title has the field look (.uo-title: 17px, border on hover and focus);
the host's title — even an <input> — only gets .uo-title-slot.
Outside the editor (no plugin context), loadBarKit(lang?) loads the same kit on demand:
import { loadBarKit } from '@universal-office/write';
const ui = await loadBarKit('pt');
const shadow = host.attachShadow({ mode: 'open' });
ui.adoptBarStyles(shadow);
shadow.append(ui.createBarRow({
title: 'Política de compras.docx',
actions: [
ui.createBarButton({ icon: 'check_circle', label: 'Publicado', variant: 'tonal', size: 'small' }),
ui.createBarButton({ icon: 'send', title: 'Enviar', variant: 'icon-filled' }),
],
}));
Title above the menus
By default (compact.titlePlacement: 'above-menus', whenever there is a title) the compact bar is laid out like Google Docs: the title row
(compact.title) above the menu row, an optional compact.leading (logo or
document icon) at the left and the compact.trailing actions at the right, both spanning the two rows and
centered. The notice band stays between this block and the full-width toolbar; the menus still turn into
“☰” and the groups still collapse with the width. 'inline' puts everything on one row. Parts:
titlebar, leading, title-row, menu-row, trailing.
editor.use({
id: 'host.appbar',
toolbar: ({ ui }) => ({
compact: {
titlePlacement: 'above-menus',
leading: { id: 'host:logo', widget: () => ({ el: ui.createBarIcon('description', { size: 40 }) }) },
title: { id: 'host:title', widget: () => ({ el: buildTitle(ui) }) },
trailing: [
{ id: 'host:preview', widget: () => ({ el: ui.createBarButton({ icon: 'visibility', label: 'Preview', variant: 'text' }) }) },
{ id: 'host:send', widget: () => ({ el: ui.createBarButton({ icon: 'send', label: 'Send', variant: 'filled' }) }) },
],
},
}),
});
// the same layout outside the editor (preview, read only)
shadow.append(ui.createBarRow({ titlePlacement: 'above-menus', leading: ui.createBarIcon('description', { size: 40 }), title, actions }));
In the viewer
<universal-document-viewer> takes the same plugins
(viewer.use() / unuse(),
UniversalDocumentViewerElement.register() / unregister()): the
inlineRenderers are the same objects, so an inline is drawn the same way in
both components, and setup({ viewer }) can listen to
udv-inline-click and udv-link-click. Both elements also answer
getLinks(), getCustomInlines(type?) and repaint()
(call it when state read by a renderer changes). See the
viewer page.
Persistence in .docx
When saving with save(), each custom inline is serialized as a content
control (w:sdt) with its type, data, and fallback text. When reopened in
Write, the plugin's renderer is reapplied; when opened in another editor (including the
reference app), the fallback text is shown — the document stays readable everywhere.
Ready-made plugin: mentions
The package includes createMentionPlugin: type @ and a
suggestions popover appears; on selection, the mention is inserted as an atomic inline.
import { createMentionPlugin } from '@universal-office/write';
editor.use(createMentionPlugin({
prefix: '@',
background: '#dce8ff',
color: '#174ea6',
maxSuggestions: 8,
emptyLabel: 'No people found',
source: async (query) => {
const resp = await fetch(`/api/people?q=${encodeURIComponent(query)}`);
return resp.json(); // [{ id, label }, ...]
},
}));
Relevant events
| Event | Use in plugins |
|---|---|
uw-before-input | Cancelable — intercept typing triggers (like the mention's @). |
uw-input | React to text already inserted (e.g. update the popover search). |
uw-custom-inline-inserted | Notification that an inline of your type entered the document. |
uw-plugin-added / uw-plugin-removed | Installation lifecycle (per instance, also for register/unregister). |