Universal OfficeBeta

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): booleanWhether 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

EventUse in plugins
uw-before-inputCancelable — intercept typing triggers (like the mention's @).
uw-inputReact to text already inserted (e.g. update the popover search).
uw-custom-inline-insertedNotification that an inline of your type entered the document.
uw-plugin-added / uw-plugin-removedInstallation lifecycle (per instance, also for register/unregister).