Vanilla
@domternal/vanilla provides framework-free DOM components for the Domternal editor. Use it in Astro, Svelte, Solid, plain HTML, Web Components - anywhere without a framework runtime. The API is class-based: new DomternalEditor(host, opts) returns an instance with public getters and setters, EventTarget events, and a .destroy() method that cleans up via AbortController.
The package ships six classes:
| Class | Purpose |
|---|---|
DomternalEditor | The editor itself (wraps Editor from core) |
DomternalToolbar | Top toolbar with extension-driven buttons |
DomternalBubbleMenu | Floating menu on text selection |
DomternalFloatingMenu | Block-insert menu on empty paragraphs |
DomternalEmojiPicker | Emoji picker panel |
DomternalNotionColorPicker | Named-token color picker (Notion-style) |
Plus shared utilities (isBrowser, assertBrowser, createPluginKey, renderIconInto, resolveIcon, subscribe) and helper exports for power users.
Installation
Section titled “Installation”pnpm add @domternal/core @domternal/theme @domternal/vanillanpm install @domternal/core @domternal/theme @domternal/vanillayarn add @domternal/core @domternal/theme @domternal/vanillaQuickstart
Section titled “Quickstart”-
Create the mount points
Section titled “Create the mount points”<div id="toolbar"></div><div id="editor" class="dm-editor"></div><div id="bubble" class="dm-bubble-menu"></div> -
Instantiate the classes
Section titled “Instantiate the classes”import { StarterKit, BubbleMenu } from '@domternal/core';import {DomternalEditor,DomternalToolbar,DomternalBubbleMenu,} from '@domternal/vanilla';import '@domternal/theme';const toolbarEl = document.getElementById('toolbar')!;const editorEl = document.getElementById('editor')!;const bubbleEl = document.getElementById('bubble')!;const dm = new DomternalEditor(editorEl, {extensions: [StarterKit, BubbleMenu.configure({ element: bubbleEl })],content: '<p>Hello world</p>',});new DomternalToolbar(toolbarEl, { editor: dm.editor });new DomternalBubbleMenu(bubbleEl, { editor: dm.editor }); -
Tear down when done
Section titled “Tear down when done”Each class exposes a
.destroy()method that cleans up listeners, plugins, and DOM. Call it before navigation in SPAs, when unmounting from a framework, or when finished with the editor:dm.destroy();Single-page apps that swap content between routes must call
destroy()on every instance or the editor leaks plugins and event listeners.
Class-based API overview
Section titled “Class-based API overview”Every class follows the same pattern:
const instance = new DomternalX(host, options);// host: HTMLElement - the mount point// options: required + optional fields, depends on the class
instance.someGetter; // read stateinstance.setSomeOption(value); // update reactive optionsinstance.addEventListener('eventname', handler); // listen to changesinstance.destroy(); // cleanup, idempotentEach class extends EventTarget so you can listen for state changes via addEventListener. All listeners attached internally use an AbortController scoped to the instance - calling destroy() aborts the signal and removes them in one operation.
DomternalEditor
Section titled “DomternalEditor”const dm = new DomternalEditor(host, options);The editor itself. Wraps a new Editor({ element: host, ...options }) from core, exposes content getters, callback options, and EventTarget events. The underlying Editor instance is exposed via dm.editor.
Options
Section titled “Options”interface DomternalEditorOptions { extensions?: AnyExtension[]; // merged on top of DEFAULT_EXTENSIONS content?: Content; // initial HTML string or JSON editable?: boolean; // default true autofocus?: FocusPosition; // default false outputFormat?: 'html' | 'json'; // default 'html' onCreate?: (editor: Editor) => void; onUpdate?: (ctx: { editor: Editor }) => void; onSelectionChange?: (ctx: { editor: Editor }) => void; onFocus?: (ctx: { editor: Editor; event: FocusEvent }) => void; onBlur?: (ctx: { editor: Editor; event: FocusEvent }) => void; onDestroy?: () => void;}Public surface
Section titled “Public surface”| Member | Type | Description |
|---|---|---|
editor | Editor (readonly) | Underlying ProseMirror Editor |
host | HTMLElement (readonly) | Host element passed to constructor |
htmlContent | string (getter) | Current editor HTML |
jsonContent | JSONContent (getter) | Current editor JSON |
isEmpty | boolean (getter) | True if document is empty |
isFocused | boolean (getter) | True if editor has focus |
isEditable | boolean (getter) | Current editable state |
setContent(content, emitUpdate?) | method | Replace content |
setEditable(editable) | method | Toggle read-only |
focus(position?) | method | Programmatic focus |
destroy() | method | Tear down, idempotent |
Events
Section titled “Events”dm.addEventListener('create', (e) => /* { editor } */);dm.addEventListener('update', (e) => /* { editor } */);dm.addEventListener('selectionchange', (e) => /* { editor } */);dm.addEventListener('focus', (e) => /* { editor, event } */);dm.addEventListener('blur', (e) => /* { editor, event } */);dm.addEventListener('destroy', (e) => /* null */);All events are CustomEvents. Access the payload via e.detail.
DEFAULT_EXTENSIONS
Section titled “DEFAULT_EXTENSIONS”If you pass extensions: undefined (or omit it), DomternalEditor uses a sensible default list. Pass your own array to override. Extensions you pass are merged on top of the defaults.
import { DEFAULT_EXTENSIONS } from '@domternal/vanilla';DomternalToolbar
Section titled “DomternalToolbar”const toolbar = new DomternalToolbar(host, options);Renders a toolbar with extension-driven buttons. Internally uses ToolbarController from core; you can read it via toolbar.controller for advanced cases.
Options
Section titled “Options”interface DomternalToolbarOptions { editor: Editor; // required icons?: IconSet; // icon overrides layout?: ToolbarLayoutEntry[]; // custom layout (subset/order) customContent?: HTMLElement; // append your own DOM after default items}Public surface
Section titled “Public surface”| Member | Type | Description |
|---|---|---|
host | HTMLElement (readonly) | Host element |
editor | Editor (readonly) | Bound editor |
controller | ToolbarController (readonly) | Underlying controller |
openDropdown | string | null (getter) | Currently open dropdown name |
setLayout(layout?) | method | Replace layout |
setIcons(icons?) | method | Replace icon set |
closeDropdown() | method | Close any open dropdown |
destroy() | method | Tear down, idempotent |
Events
Section titled “Events”toolbar.addEventListener('dropdownopen', (e) => /* { name } */);toolbar.addEventListener('dropdownclose', (e) => /* { name } */);DomternalBubbleMenu
Section titled “DomternalBubbleMenu”const bubble = new DomternalBubbleMenu(host, options);Floating contextual menu on text selection. Renders formatting buttons + Notion-mode trailing buttons (A color trigger, … block menu trigger) when those extensions are loaded.
Options
Section titled “Options”interface DomternalBubbleMenuOptions { editor: Editor; // required shouldShow?: BubbleMenuOptions['shouldShow']; // override visibility placement?: 'top' | 'bottom'; // default 'top' offset?: number; // default 8 updateDelay?: number; // default 0 items?: string[]; // explicit item list, e.g. ['bold', 'italic', '|', 'link'] contexts?: Record<string, string[] | true | null>; // context-aware items (e.g. { image: true }) icons?: IconSet; // NEW in v0.7.0 customContent?: HTMLElement; // append custom DOM after default items + trailing}Public surface
Section titled “Public surface”| Member | Type | Description |
|---|---|---|
host | HTMLElement (readonly) | Host element |
editor | Editor (readonly) | Bound editor |
trailing | Readonly<BubbleMenuTrailingState> (getter) | Current trailing-button state |
openDropdown | string | null (getter) | Currently open dropdown (e.g. text-align) |
openColorPicker(anchor) | method | Emit notionColorOpen event |
openBlockContextMenu(anchor) | method | Dispatch dm:block-context-menu-open |
setItems(items?) | method | Replace explicit item list |
setContexts(contexts?) | method | Replace context map |
setIcons(icons?) | method | Replace icon set |
closeDropdown() | method | Close text-align dropdown |
destroy() | method | Tear down, idempotent |
BubbleMenuTrailingState
Section titled “BubbleMenuTrailingState”interface BubbleMenuTrailingState { isNodeSelection: boolean; showColorPickerButton: boolean; showBlockMenuButton: boolean; blockMenuButtonDisabled: boolean; currentTextColorVar: string | null; currentBgColorVar: string | null; hasAnyColor: boolean;}Events
Section titled “Events”bubble.addEventListener('dropdownopen', (e) => /* { name } */);bubble.addEventListener('dropdownclose', (e) => /* { name } */);DomternalFloatingMenu
Section titled “DomternalFloatingMenu”const floating = new DomternalFloatingMenu(host, options);Block-insert menu shown on empty paragraphs. Renders items contributed via addFloatingMenuItems() from extensions.
Options
Section titled “Options”interface DomternalFloatingMenuOptions { editor: Editor; // required shouldShow?: FloatingMenuOptions['shouldShow']; // override visibility offset?: number; // default 0 items?: FloatingMenuItemsOverride; // override default items keymap?: FloatingMenuKeymap; // default { enterMenu: ['Alt-F10', 'Mod-/'] } icons?: IconSet; // icon overrides requireExplicitTrigger?: boolean; // default false. Notion mode = true customContent?: HTMLElement; // when set, consumer owns rendering}Public surface
Section titled “Public surface”| Member | Type | Description |
|---|---|---|
host | HTMLElement (readonly) | Host element |
editor | Editor (readonly) | Bound editor |
controller | FloatingMenuController | null (readonly) | Underlying controller (null when customContent is used) |
setIcons(icons?) | method | Replace icon set |
destroy() | method | Tear down, idempotent |
Keyboard navigation
Section titled “Keyboard navigation”Roving tabindex pattern - only the focused item is in Tab order. Inside the menu: Arrow Up/Down (wraps), Home/End, Enter/Space (execute), Escape (leave). Enter the menu via the keymap.enterMenu shortcuts (default Alt-F10, Mod-/).
See Floating Menu for the full items API.
DomternalEmojiPicker
Section titled “DomternalEmojiPicker”const picker = new DomternalEmojiPicker(host, options);Options
Section titled “Options”interface DomternalEmojiPickerOptions { editor: Editor; // required emojis: EmojiPickerItem[]; // required - full emoji set customContent?: HTMLElement; // append custom DOM}
interface EmojiPickerItem { emoji: string; name: string; group: string;}Public surface
Section titled “Public surface”| Member | Type | Description |
|---|---|---|
host | HTMLElement (readonly) | Host element |
editor | Editor (readonly) | Bound editor |
isOpen | boolean (getter) | Current open state |
searchQuery | string (getter) | Current search filter |
activeCategory | string (getter) | Currently selected category tab |
open(anchor) | method | Open against anchor, or unpositioned if null |
close() | method | Close + focus editor view |
destroy() | method | Tear down, idempotent |
Events
Section titled “Events”picker.addEventListener('openchange', (e) => /* { isOpen } */);picker.addEventListener('select', (e) => /* { name, emoji } */);The picker also listens for the insertEmoji editor event (from extension-emoji) with toggle-open semantics: if open, closes; if closed, opens.
DomternalNotionColorPicker
Section titled “DomternalNotionColorPicker”const picker = new DomternalNotionColorPicker(options);Options
Section titled “Options”interface DomternalNotionColorPickerOptions { editor: Editor; // required - the editor instance}Public surface
Section titled “Public surface”| Member | Type | Description |
|---|---|---|
panel | HTMLDivElement | null (readonly) | Panel element (created lazily on first open) |
host | HTMLElement | null (readonly) | Auto-resolved .dm-editor host (null if not in DOM) |
editor | Editor (readonly) | Bound editor |
isOpen | boolean (getter) | Current open state |
currentTextToken | string | null (getter) | Active text color token from selection |
currentBgToken | string | null (getter) | Active background color token |
palette | readonly string[] (getter) | Named-token palette |
open(anchor) | method | Open against anchor (toggle on same anchor) |
close(opts?) | method | Close picker. { refocus: true } focuses editor view. |
applyText(token) | method | Apply text color token (null clears) |
applyBg(token) | method | Apply background color token (null clears) |
tokenLabel(token) | method | Display label for a palette token (title-case fallback) |
destroy() | method | Tear down, idempotent |
Events
Section titled “Events”picker.addEventListener('openchange', (e) => /* { isOpen } */);picker.addEventListener('apply', (e) => /* { kind: 'text'|'bg', token: string|null } */);The picker listens for the notionColorOpen editor event from DomternalBubbleMenu’s A trigger.
SSR / Astro integration
Section titled “SSR / Astro integration”Every constructor calls assertBrowser() first, which throws if typeof window === 'undefined'. Module-scope code is SSR-safe - only constructor bodies and methods touch the DOM, so import { DomternalEditor } from '@domternal/vanilla' succeeds during server-side rendering.
For Astro:
<div id="toolbar"></div><div id="editor" class="dm-editor"></div><div id="bubble" class="dm-bubble-menu"></div>
<script> import { StarterKit, BubbleMenu } from '@domternal/core'; import { DomternalEditor, DomternalToolbar, DomternalBubbleMenu } from '@domternal/vanilla'; import '@domternal/theme';
const editorEl = document.getElementById('editor')!; const toolbarEl = document.getElementById('toolbar')!; const bubbleEl = document.getElementById('bubble')!;
const dm = new DomternalEditor(editorEl, { extensions: [StarterKit, BubbleMenu.configure({ element: bubbleEl })], content: '<p>Hello from Astro!</p>', });
new DomternalToolbar(toolbarEl, { editor: dm.editor }); new DomternalBubbleMenu(bubbleEl, { editor: dm.editor });</script>The <script> block in Astro is client-side by default. For component-style integration with client:* directives, wrap the vanilla code in your own component and use client:only="any" or client:load.
Custom content via customContent
Section titled “Custom content via customContent”Most classes accept a customContent: HTMLElement option for users who want to render their own DOM instead of (or appended to) the default UI.
const myCustom = document.createElement('div');myCustom.innerHTML = '<button class="my-btn">Custom action</button>';myCustom.querySelector('.my-btn')!.addEventListener('click', () => { // your logic});
const bubble = new DomternalBubbleMenu(host, { editor: dm.editor, customContent: myCustom,});The DOM you pass remains YOUR responsibility - clean up your event listeners when the wrapper is destroyed. The wrapper appends customContent to its host but does not mutate it. If you need dynamic updates, listen to the wrapper’s EventTarget events and mutate your DOM yourself.
Tree-shaking
Section titled “Tree-shaking”The package exports a barrel (@domternal/vanilla) AND per-component subpaths. Use subpaths for the smallest possible bundle:
// Barrel (loads all 6 classes, tree-shaking removes unused)import { DomternalEditor, DomternalToolbar } from '@domternal/vanilla';
// Subpath imports (smallest)import { DomternalEditor } from '@domternal/vanilla/editor';import { DomternalToolbar } from '@domternal/vanilla/toolbar';import { DomternalBubbleMenu } from '@domternal/vanilla/bubble-menu';import { DomternalFloatingMenu } from '@domternal/vanilla/floating-menu';import { DomternalEmojiPicker } from '@domternal/vanilla/emoji-picker';import { DomternalNotionColorPicker } from '@domternal/vanilla/notion-color-picker';The package is marked sideEffects: false so unused subpaths are eliminated by any modern bundler (Vite, esbuild, Rollup).
Lifecycle and cleanup
Section titled “Lifecycle and cleanup”When to call .destroy()
Section titled “When to call .destroy()”- Before navigating away in a single-page app
- When unmounting from a framework that re-renders parts of the page
- In tests, between scenarios
- Whenever you’re done with the editor instance
.destroy() is idempotent - calling it twice is a no-op.
How cleanup works internally
Section titled “How cleanup works internally”Every instance creates one AbortController and passes { signal: this.#abortCtl.signal } to every addEventListener call. Calling .destroy() calls this.#abortCtl.abort(), which removes ALL listeners in one operation. No manual removeEventListener calls are needed and no leaks are possible from forgotten listeners.
Plugins registered with the editor are explicitly unregistered. Floating UI cleanup callbacks are invoked. DOM trees mounted into the host are removed.
Multi-mount considerations
Section titled “Multi-mount considerations”Two instances mounted in the same editor (rare but possible) get distinct PluginKey suffixes via createPluginKey() (which uses crypto.randomUUID() with a Math.random fallback). Each destroy() is independent.
Notion mode
Section titled “Notion mode”The Vanilla wrapper has full parity with Angular / React / Vue for Notion-mode features. See the Notion Mode guide for the cross-framework setup including:
- Required extensions list (
@domternal/extension-block-menu,@domternal/extension-toc,NotionColorPicker,BlockColor,UniqueID,ListIndent) .dm-notion-modeCSS classrequireExplicitTrigger: trueonDomternalFloatingMenu- Cross-framework configuration cookbook
Shared utilities (advanced)
Section titled “Shared utilities (advanced)”For power users and framework adapters:
import { isBrowser, // boolean: typeof window !== 'undefined' assertBrowser, // throws if not browser createPluginKey, // creates a unique PluginKey (crypto.randomUUID + fallback) renderIconInto, // safe innerHTML helper for icon SVG strings resolveIcon, // IconSet lookup with defaultIcons fallback subscribe, // EventTarget subscribe helper} from '@domternal/vanilla';
import type { CustomContentOption } from '@domternal/vanilla';These are exposed so framework adapters can compose them into framework-specific components without depending on internal paths.
Next Steps
Section titled “Next Steps”- Notion Mode guide - full Notion-style editor setup with extension-block-menu + extension-toc + theme class
- StackBlitz Example - working vanilla editor with all extensions
- Editor API - commands, events, content getters - same as headless usage
- Theming - CSS custom properties