@domternal/vanilla 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,871 @@
1
+ import { PluginKey, IconSet, Editor, AnyExtension, Content, FocusPosition, JSONContent, ToolbarController, ToolbarLayoutEntry, ToolbarButton, ToolbarDropdown, BubbleMenuOptions, FloatingMenuController, FloatingMenuItemsOverride } from '@domternal/core';
2
+ import { FloatingMenuOptions, FloatingMenuKeymap } from '@domternal/extension-block-menu';
3
+
4
+ /**
5
+ * SSR-safe environment check.
6
+ *
7
+ * `@domternal/vanilla` classes construct DOM nodes and attach listeners on
8
+ * `document` / `window`. During SSR (Astro/Nuxt/Next.js build), the module
9
+ * may be imported but constructors must NOT run server-side.
10
+ *
11
+ * Class constructors call `assertBrowser()` early to throw a clear error
12
+ * if invoked outside a browser environment.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { isBrowser, assertBrowser } from '@domternal/vanilla';
17
+ *
18
+ * if (isBrowser) {
19
+ * const editor = new DomternalEditor(host, { editor });
20
+ * }
21
+ *
22
+ * // Or, in a constructor that requires browser:
23
+ * class MyWrapper {
24
+ * constructor(host: HTMLElement) {
25
+ * assertBrowser('MyWrapper');
26
+ * // ... safe to use document/window from here
27
+ * }
28
+ * }
29
+ * ```
30
+ */
31
+ declare const isBrowser: boolean;
32
+ /**
33
+ * Assert the current environment is a browser. Throws with an actionable
34
+ * error message tailored to common SSR frameworks (Astro, Nuxt, Next.js).
35
+ *
36
+ * @param className - Name of the calling class, included in the error
37
+ * message so users can identify the culprit constructor in a stack trace.
38
+ *
39
+ * @throws Error if called server-side.
40
+ */
41
+ declare function assertBrowser(className: string): void;
42
+
43
+ /**
44
+ * Create a unique `PluginKey` with collision-free suffix.
45
+ *
46
+ * Two instances of the same wrapper class mounted in the same editor (rare
47
+ * but supported) need distinct keys to avoid ProseMirror plugin clashes.
48
+ *
49
+ * Why crypto.randomUUID + Math.random fallback: matches the framework
50
+ * wrapper convention (angular, react, vue all use this pattern as of
51
+ * commit fbef072). SSR may share Math.random seed across mount cycles,
52
+ * giving collisions; crypto.randomUUID is collision-free by spec.
53
+ *
54
+ * @param prefix - Human-readable prefix for debugging. Convention: kebab or
55
+ * camel case identifying the wrapper. e.g. `'vanillaBubbleMenu'`.
56
+ * @returns A `PluginKey` with format `<prefix>-<8-char-suffix>`.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import { createPluginKey } from '@domternal/vanilla';
61
+ *
62
+ * class MyBubbleMenu {
63
+ * #pluginKey = createPluginKey('myBubbleMenu');
64
+ *
65
+ * constructor(editor: Editor) {
66
+ * const plugin = createBubbleMenuPlugin({
67
+ * pluginKey: this.#pluginKey,
68
+ * editor,
69
+ * element: this.host,
70
+ * });
71
+ * editor.registerPlugin(plugin);
72
+ * }
73
+ *
74
+ * destroy() {
75
+ * this.editor.unregisterPlugin(this.#pluginKey);
76
+ * }
77
+ * }
78
+ * ```
79
+ */
80
+ declare function createPluginKey(prefix: string): PluginKey;
81
+
82
+ /**
83
+ * Resolve and inject an icon's SVG string into a host element.
84
+ *
85
+ * Why innerHTML is safe here: icons come from the `defaultIcons` lookup
86
+ * table (Phosphor SVG strings shipped with core) or from a consumer-provided
87
+ * `IconSet` object. Both are trusted by contract because they are constants in
88
+ * source code, not user input. This matches the React (`dangerouslySetInnerHTML`),
89
+ * Angular (`bypassSecurityTrustHtml`), and Vue (`v-html`) patterns.
90
+ *
91
+ * Consumer responsibility (documented in README): never pass user-controlled
92
+ * strings as icon values in your `IconSet`.
93
+ *
94
+ * @param hostEl - Target element. `innerHTML` is replaced (existing children
95
+ * are removed).
96
+ * @param iconKey - Icon name to look up in `icons` first, then `defaultIcons`.
97
+ * When `undefined`, the host is emptied.
98
+ * @param icons - Optional consumer-provided icon overrides. Falls back to
99
+ * `defaultIcons` if not provided or if the key is missing.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * import { renderIconInto } from '@domternal/vanilla';
104
+ *
105
+ * const button = document.createElement('button');
106
+ * const iconSpan = document.createElement('span');
107
+ * button.appendChild(iconSpan);
108
+ *
109
+ * renderIconInto(iconSpan, 'textB'); // default Phosphor icon
110
+ * renderIconInto(iconSpan, 'bold', { bold: mySvg }); // custom override
111
+ * renderIconInto(iconSpan, undefined); // clear icon
112
+ * ```
113
+ */
114
+ declare function renderIconInto(hostEl: HTMLElement, iconKey: string | undefined, icons: IconSet | undefined): void;
115
+ /**
116
+ * Resolve an icon's SVG string without injection. Useful when caller
117
+ * builds the final DOM tree (e.g. via `document.createElement` chain
118
+ * or template literal interpolation).
119
+ *
120
+ * @param iconKey - Icon name to look up. When `undefined`, returns `''`.
121
+ * @param icons - Optional consumer overrides; falls back to `defaultIcons`.
122
+ * @returns Trusted SVG string, or `''` if the icon is unknown.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * import { resolveIcon } from '@domternal/vanilla';
127
+ *
128
+ * const iconHtml = resolveIcon('textB', undefined);
129
+ * const button = document.createElement('button');
130
+ * button.innerHTML = `<span class="icon">${iconHtml}</span><span>Bold</span>`;
131
+ * ```
132
+ */
133
+ declare function resolveIcon(iconKey: string | undefined, icons: IconSet | undefined): string;
134
+
135
+ /**
136
+ * Helpers for typed event subscription over `EventTarget`.
137
+ *
138
+ * Each `@domternal/vanilla` class extends the platform `EventTarget` and
139
+ * dispatches `CustomEvent` instances. Consumers can use the platform
140
+ * `addEventListener('eventName', handler)` directly, OR call `subscribe()`
141
+ * which returns an unsubscribe function and narrows the detail type.
142
+ *
143
+ * Usage:
144
+ * ```ts
145
+ * import { subscribe } from '@domternal/vanilla';
146
+ *
147
+ * const off = subscribe<{ isOpen: boolean }>(picker, 'openchange', (detail) => {
148
+ * console.log(detail.isOpen);
149
+ * });
150
+ * off(); // remove listener
151
+ * ```
152
+ *
153
+ * Each class JSDoc lists the events it emits + the shape of `detail`.
154
+ */
155
+ declare function subscribe<TDetail = unknown>(target: EventTarget, type: string, listener: (detail: TDetail) => void): () => void;
156
+
157
+ /**
158
+ * Shared option types reused across `@domternal/vanilla` components.
159
+ */
160
+ /**
161
+ * Custom content slot. Pass a pre-built DOM tree the wrapper appends into
162
+ * its host instead of rendering the default UI.
163
+ *
164
+ * The DOM you pass stays YOURS. The wrapper does NOT mutate it after
165
+ * construction and does NOT clean up event handlers on it during destroy.
166
+ * Manage its lifecycle yourself.
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * const myUi = document.createElement('div');
171
+ * myUi.innerHTML = `<button data-mark="bold">B</button>`;
172
+ * myUi.querySelector('button')!.addEventListener('click', () => {
173
+ * editor.chain().focus().toggleBold().run();
174
+ * });
175
+ *
176
+ * const bubble = new DomternalBubbleMenu(host, {
177
+ * editor,
178
+ * customContent: myUi,
179
+ * });
180
+ *
181
+ * // Later, when YOU destroy your UI, do listener cleanup yourself:
182
+ * bubble.destroy(); // detaches myUi from host
183
+ * myUi.remove(); // your responsibility
184
+ * ```
185
+ */
186
+ interface CustomContentOption {
187
+ customContent?: HTMLElement;
188
+ }
189
+
190
+ declare const DEFAULT_EXTENSIONS: AnyExtension[];
191
+ interface DomternalEditorOptions {
192
+ /** Custom extensions to add. Merged on top of `DEFAULT_EXTENSIONS`. */
193
+ extensions?: AnyExtension[];
194
+ /** Initial editor content (HTML string or JSON). */
195
+ content?: Content;
196
+ /** Whether the editor is editable. @default true */
197
+ editable?: boolean;
198
+ /** Where to autofocus on mount. @default false */
199
+ autofocus?: FocusPosition;
200
+ /**
201
+ * Output format hint for downstream consumers (e.g. host frameworks
202
+ * comparing controlled-mode content). Does not change editor behavior.
203
+ * @default 'html'
204
+ */
205
+ outputFormat?: 'html' | 'json';
206
+ /** Called once when the underlying Editor instance is created. */
207
+ onCreate?: (editor: Editor) => void;
208
+ /** Called when the document content changes. */
209
+ onUpdate?: (ctx: {
210
+ editor: Editor;
211
+ }) => void;
212
+ /** Called when the selection moves without a content change. */
213
+ onSelectionChange?: (ctx: {
214
+ editor: Editor;
215
+ }) => void;
216
+ /** Called when the editor gains focus. */
217
+ onFocus?: (ctx: {
218
+ editor: Editor;
219
+ event: FocusEvent;
220
+ }) => void;
221
+ /** Called when the editor loses focus. */
222
+ onBlur?: (ctx: {
223
+ editor: Editor;
224
+ event: FocusEvent;
225
+ }) => void;
226
+ /** Called before the underlying Editor is destroyed. */
227
+ onDestroy?: () => void;
228
+ }
229
+ /**
230
+ * `DomternalEditor` - vanilla DOM wrapper around `@domternal/core`'s `Editor`.
231
+ *
232
+ * Mounts an editor into the host element, wires lifecycle callbacks, exposes
233
+ * reactive-friendly getters via plain properties, and dispatches `CustomEvent`
234
+ * instances for state changes (so framework adapters can bridge).
235
+ *
236
+ * Construction is browser-only - throws in SSR contexts (use Astro
237
+ * `<client:only="vanilla">` or similar gate).
238
+ *
239
+ * Cleanup is automatic via `destroy()` (calls `editor.destroy()` + emits
240
+ * `destroy` event).
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * import { DomternalEditor } from '@domternal/vanilla';
245
+ * import { StarterKit } from '@domternal/core';
246
+ *
247
+ * const host = document.getElementById('editor')!;
248
+ * const wrapper = new DomternalEditor(host, {
249
+ * extensions: [StarterKit],
250
+ * content: '<p>Hello world</p>',
251
+ * onUpdate: ({ editor }) => console.log(editor.getHTML()),
252
+ * });
253
+ *
254
+ * // Reactive access via getters:
255
+ * console.log(wrapper.htmlContent);
256
+ * console.log(wrapper.isEmpty);
257
+ *
258
+ * // Event subscription (CustomEvent):
259
+ * wrapper.addEventListener('update', () => {
260
+ * console.log('content changed');
261
+ * });
262
+ *
263
+ * // Cleanup:
264
+ * wrapper.destroy();
265
+ * ```
266
+ *
267
+ * **Events dispatched** (CustomEvent, `detail` shape in brackets):
268
+ * - `create` - `{ editor: Editor }` - emitted once after Editor instantiation
269
+ * - `update` - `{ editor: Editor }` - emitted on every content change
270
+ * - `selectionchange` - `{ editor: Editor }` - emitted on selection-only updates
271
+ * - `focus` - `{ editor: Editor; event: FocusEvent }`
272
+ * - `blur` - `{ editor: Editor; event: FocusEvent }`
273
+ * - `destroy` - `null` - emitted just before destroy completes
274
+ */
275
+ declare class DomternalEditor extends EventTarget {
276
+ #private;
277
+ /** The underlying ProseMirror-backed `Editor` instance. */
278
+ readonly editor: Editor;
279
+ /** The host element provided to the constructor. */
280
+ readonly host: HTMLElement;
281
+ constructor(host: HTMLElement, options?: DomternalEditorOptions);
282
+ get htmlContent(): string;
283
+ get jsonContent(): JSONContent;
284
+ get isEmpty(): boolean;
285
+ get isFocused(): boolean;
286
+ get isEditable(): boolean;
287
+ /**
288
+ * Replace editor content. Does NOT emit `update` event by default
289
+ * (mirrors `Editor.setContent` behavior); pass `emitUpdate=true` to fire.
290
+ *
291
+ * No-op if the wrapper has been destroyed.
292
+ */
293
+ setContent(content: Content, emitUpdate?: boolean): void;
294
+ /**
295
+ * Toggle the editor's editable state (`true` allows input, `false` makes
296
+ * it read-only). No-op if the wrapper has been destroyed.
297
+ */
298
+ setEditable(editable: boolean): void;
299
+ /**
300
+ * Programmatically focus the editor.
301
+ *
302
+ * @param position - Focus position (`'start' | 'end' | 'all' | number | boolean`).
303
+ * Defaults to current selection. No-op if the wrapper has been destroyed.
304
+ */
305
+ focus(position?: FocusPosition): void;
306
+ /**
307
+ * Tear down the underlying editor + remove all subscriptions.
308
+ *
309
+ * Idempotent - calling twice is a no-op. Dispatches a `destroy` CustomEvent
310
+ * BEFORE the editor is destroyed, so listeners can read `this.editor` state
311
+ * one last time.
312
+ */
313
+ destroy(): void;
314
+ }
315
+
316
+ interface DomternalToolbarOptions {
317
+ /** Editor instance the toolbar binds to. */
318
+ editor: Editor;
319
+ /** Optional icon set override. Falls back to `defaultIcons` per key. */
320
+ icons?: IconSet;
321
+ /**
322
+ * Optional custom layout (item names + separators + custom dropdowns).
323
+ * When omitted, items are grouped by their declared `group` property and
324
+ * sorted by priority within each group.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * new DomternalToolbar(host, {
329
+ * editor,
330
+ * layout: ['bold', 'italic', 'underline', '|', 'heading', '|', 'undo', 'redo'],
331
+ * });
332
+ * ```
333
+ */
334
+ layout?: ToolbarLayoutEntry[];
335
+ }
336
+ /**
337
+ * `DomternalToolbar` - vanilla DOM toolbar bound to an editor's items.
338
+ *
339
+ * Mounts a `ToolbarController` from `@domternal/core` and renders its state
340
+ * (groups, active map, disabled map, dropdown state) into the host element.
341
+ *
342
+ * Re-renders are batched via `requestAnimationFrame` so transaction-rate
343
+ * updates don't thrash the DOM. Button DOM nodes are reused across renders
344
+ * (only their classes/attrs update) to preserve focus when navigating with
345
+ * the keyboard.
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * import { Editor, StarterKit } from '@domternal/core';
350
+ * import { DomternalToolbar } from '@domternal/vanilla';
351
+ * import '@domternal/theme';
352
+ *
353
+ * const editor = new Editor({
354
+ * element: document.getElementById('editor')!,
355
+ * extensions: [StarterKit],
356
+ * });
357
+ *
358
+ * const toolbar = new DomternalToolbar(document.getElementById('toolbar')!, {
359
+ * editor,
360
+ * // Optional custom layout:
361
+ * // layout: ['bold', 'italic', '|', 'heading'],
362
+ * });
363
+ *
364
+ * // Update later:
365
+ * toolbar.setLayout(['bold', 'italic', 'underline']);
366
+ *
367
+ * // Cleanup:
368
+ * toolbar.destroy();
369
+ * ```
370
+ *
371
+ * **Events dispatched** (CustomEvent, `detail` shape in brackets):
372
+ * - `dropdownopen` - `{ name: string }` - emitted when a dropdown opens
373
+ * - `dropdownclose` - `{ name: string }` - emitted when a dropdown closes
374
+ */
375
+ declare class DomternalToolbar extends EventTarget {
376
+ #private;
377
+ readonly host: HTMLElement;
378
+ /** The editor instance this toolbar is bound to. */
379
+ get editor(): Editor;
380
+ /** Underlying controller. Exposed for power-user introspection. */
381
+ get controller(): ToolbarController;
382
+ constructor(host: HTMLElement, options: DomternalToolbarOptions);
383
+ get openDropdown(): string | null;
384
+ setLayout(layout: ToolbarLayoutEntry[] | undefined): void;
385
+ setIcons(icons: IconSet | undefined): void;
386
+ closeDropdown(): void;
387
+ destroy(): void;
388
+ }
389
+
390
+ /**
391
+ * Format a toolbar button's tooltip including its keyboard shortcut.
392
+ *
393
+ * On macOS uses Unicode glyphs (⌘ ⇧ ⌥); elsewhere falls back
394
+ * to "Ctrl+Shift+Alt" text. Mirrors Vue/React wrapper behavior.
395
+ */
396
+ declare function getTooltip(item: ToolbarButton): string;
397
+
398
+ declare const DROPDOWN_CARET: string;
399
+ interface IconCache {
400
+ resolveSvg: (name: string) => string;
401
+ getIcon: (name: string) => string;
402
+ getTriggerLabel: (label: string, isIcon?: boolean) => string;
403
+ getTriggerIcon: (iconName: string) => string;
404
+ getItemContent: (icon: string, label: string, mode?: 'icon-text' | 'text' | 'icon') => string;
405
+ getDropdownTriggerHtml: (dropdown: ToolbarDropdown, activeItem: ToolbarButton | undefined) => string;
406
+ setIcons: (icons: IconSet | undefined) => void;
407
+ }
408
+ /**
409
+ * Resolve + cache toolbar icons. Mirrors Vue's `useToolbarIcons` logic
410
+ * verbatim - same cache keys, same fallback order (consumer `IconSet` ->
411
+ * `defaultIcons`), same dropdown trigger HTML composition.
412
+ *
413
+ * Cache is invalidated when consumer swaps `IconSet`.
414
+ */
415
+ declare function createIconCache(initialIcons: IconSet | undefined): IconCache;
416
+
417
+ /**
418
+ * Read a CSS property at the current cursor position.
419
+ * Prefers inline style, falls back to computed style. Used by dropdowns
420
+ * with `computedStyleProperty` (e.g. font-size) to show the current value
421
+ * in their trigger label.
422
+ */
423
+ declare function getComputedStyleAtCursor(editor: Editor, prop: string): string | null;
424
+ /**
425
+ * Read ONLY inline style at the current cursor position (skips browser-default
426
+ * computed inheritance). Used for `font-family` where computed value includes
427
+ * the document's default fallback that would override any user-set font in the
428
+ * toolbar's "current font" indicator.
429
+ */
430
+ declare function getInlineStyleAtCursor(editor: Editor, prop: string): string | null;
431
+
432
+ interface ResolvedPosShape {
433
+ parent: {
434
+ type: {
435
+ name: string;
436
+ spec: {
437
+ marks?: string;
438
+ };
439
+ };
440
+ };
441
+ depth: number;
442
+ node: (depth: number) => {
443
+ type: {
444
+ name: string;
445
+ };
446
+ };
447
+ }
448
+ interface SelectionShape {
449
+ empty: boolean;
450
+ $from: ResolvedPosShape;
451
+ $to: ResolvedPosShape;
452
+ node?: {
453
+ type: {
454
+ name: string;
455
+ };
456
+ };
457
+ }
458
+ interface BubbleMenuSeparator {
459
+ type: 'separator';
460
+ name: string;
461
+ }
462
+ type BubbleMenuItem = ToolbarButton | ToolbarDropdown | BubbleMenuSeparator;
463
+ interface ItemMaps {
464
+ itemMap: Map<string, ToolbarButton>;
465
+ dropdownMap: Map<string, ToolbarDropdown>;
466
+ /** Bubble defaults indexed by context name (e.g. 'text', 'codeBlock'). */
467
+ bubbleDefaults: Map<string, BubbleMenuItem[]>;
468
+ }
469
+ /**
470
+ * Walks `editor.toolbarItems` and indexes them by name. Dropdowns are kept
471
+ * separately so the bubble menu can resolve them by name (e.g. text-align).
472
+ */
473
+ declare function buildItemMaps(editor: Editor): ItemMaps;
474
+ /**
475
+ * Resolve a name array to BubbleMenuItems via the item/dropdown maps.
476
+ * Dropdowns take priority over buttons sharing the same name. Pipe `|`
477
+ * tokens become separator entries.
478
+ */
479
+ declare function resolveNames(names: string[], itemMap: Map<string, ToolbarButton>, dropdownMap: Map<string, ToolbarDropdown>): BubbleMenuItem[];
480
+ /**
481
+ * All `format`-group buttons sorted by priority (used by `context: true`
482
+ * shorthand to show "all format marks for this context").
483
+ */
484
+ declare function getFormatItems(itemMap: Map<string, ToolbarButton>): ToolbarButton[];
485
+ /**
486
+ * Determine the active bubble-menu context based on the selection. Returns
487
+ * `null` when no context matches (menu should not show).
488
+ *
489
+ * Resolution order:
490
+ * 1. CellSelection (`$anchorCell`) - no menu inside table cell selections
491
+ * 2. NodeSelection (image, HR, etc.) - return the node's type name
492
+ * 3. Empty selection - no context (caret-only)
493
+ * 4. Inside a table cell - return 'table' (when from/to share a cell)
494
+ * 5. `$from.parent.type.name` if listed in contexts
495
+ * 6. 'text' if the parent allows marks
496
+ */
497
+ declare function detectContext(selection: SelectionShape, ctxs: Record<string, string[] | true | null>): string | null;
498
+ /**
499
+ * Filter items by what the schema actually allows in the given context.
500
+ * E.g. inside a `codeBlock` node, marks like Bold/Italic aren't permitted.
501
+ *
502
+ * Pass-through for 'text' and 'table' contexts (schema check would be
503
+ * lossy there because marks are allowed but some items still apply).
504
+ */
505
+ declare function filterBySchema(editor: Editor, contextName: string, schemaItems: ToolbarButton[]): ToolbarButton[];
506
+ /**
507
+ * Detect whether `$pos` is inside a table cell (cell or header).
508
+ */
509
+ declare function isInsideTableCell($pos: ResolvedPosShape): boolean;
510
+
511
+ /**
512
+ * Live state for the bubble-menu trailing buttons.
513
+ *
514
+ * Mirrors Angular's `syncTrailingButtonsState` output. Computed per editor
515
+ * transaction and coalesced into a single object so each transaction
516
+ * triggers at most one re-render.
517
+ */
518
+ interface BubbleMenuTrailingState {
519
+ /** True when current selection is a NodeSelection (image, HR, ...). Trailing buttons hide in that case. */
520
+ isNodeSelection: boolean;
521
+ /** True when the `notionColorPicker` extension is loaded on this editor (cached once per editor). */
522
+ showColorPickerButton: boolean;
523
+ /** True when the `blockContextMenu` extension is loaded on this editor (cached once per editor). */
524
+ showBlockMenuButton: boolean;
525
+ /** True when the selection spans more than one top-level block (multi-block "..." disable). */
526
+ blockMenuButtonDisabled: boolean;
527
+ /** CSS color value for the "A" trigger glyph (e.g. `var(--dm-block-text-yellow)`), or null. */
528
+ currentTextColorVar: string | null;
529
+ /** CSS color value for the "A" trigger underline, or null. */
530
+ currentBgColorVar: string | null;
531
+ /** True when any token-based text or background color is applied at the cursor. */
532
+ hasAnyColor: boolean;
533
+ }
534
+ declare const INITIAL_TRAILING_STATE: BubbleMenuTrailingState;
535
+ interface SyncOptions {
536
+ hasNotionColorPicker: boolean;
537
+ hasBlockContextMenu: boolean;
538
+ }
539
+ /**
540
+ * Compute trailing-button state for the current editor selection.
541
+ *
542
+ * - `isNodeSelection`: hides trailing buttons during NodeSelection (image, HR)
543
+ * - `blockMenuButtonDisabled`: multi-block disable via `$from.before(1) !== $to.before(1)`
544
+ * - `currentTextColorVar` / `currentBgColorVar` / `hasAnyColor`: read the
545
+ * `textStyle` mark at cursor and project token names to CSS-var refs that
546
+ * the theme resolves at paint time.
547
+ */
548
+ declare function computeTrailingState(editor: Editor, opts: SyncOptions): BubbleMenuTrailingState;
549
+
550
+ interface DomternalBubbleMenuOptions extends CustomContentOption {
551
+ /** Editor instance the bubble menu binds to. */
552
+ editor: Editor;
553
+ /** Custom visibility predicate. Replaces the default (auto-detect context). */
554
+ shouldShow?: BubbleMenuOptions['shouldShow'];
555
+ /** Placement relative to the selection. @default 'top' */
556
+ placement?: 'top' | 'bottom';
557
+ /** Pixel offset from the selection edge. @default 8 */
558
+ offset?: number;
559
+ /** Debounce delay for position updates (ms). @default 0 */
560
+ updateDelay?: number;
561
+ /** Explicit item list (overrides contexts). E.g. `['bold', 'italic', '|', 'link']`. */
562
+ items?: string[];
563
+ /**
564
+ * Context-aware items. Map from context name (`'text'`, `'codeBlock'`, etc.)
565
+ * to item name list, `true` (show all format items), or `null` (no menu for
566
+ * this context). Defaults to `defaultBubbleContexts(editor)`.
567
+ */
568
+ contexts?: Record<string, string[] | true | null>;
569
+ /** Custom icon overrides. Falls back to default Phosphor icons for unmapped keys. */
570
+ icons?: IconSet;
571
+ }
572
+
573
+ /**
574
+ * `DomternalBubbleMenu` - vanilla DOM bubble menu bound to an editor.
575
+ *
576
+ * Mounts `createBubbleMenuPlugin` from `@domternal/core` for visibility and
577
+ * positioning, then renders ProseMirror selection-aware buttons + dropdowns
578
+ * inside the host element. Trailing buttons ("A" for Notion color picker,
579
+ * "..." for block context menu) are rendered automatically when the
580
+ * corresponding extensions are loaded.
581
+ *
582
+ * Re-renders are batched via `requestAnimationFrame`. DOM is rebuilt on every
583
+ * transaction because the item list changes with context (selection moves
584
+ * between text and code-block, for example).
585
+ *
586
+ * **Stable identity convention.** Trigger buttons (including the "A" and "..."
587
+ * trailing triggers and dropdown triggers) are DESTROYED and RECREATED on
588
+ * every transaction. Consumers that store a reference to a trigger element
589
+ * (e.g. as a popover anchor) should:
590
+ * - Either listen for `selectionUpdate` / `transaction` and re-resolve the
591
+ * anchor via `host.querySelector('.dm-ncp-trigger')` on demand
592
+ * - Or match the trigger by class + container (`closest('.dm-bubble-menu')`)
593
+ * rather than DOM identity for purposes like toggle-on-second-click.
594
+ *
595
+ * @example
596
+ * ```ts
597
+ * import { DomternalBubbleMenu } from '@domternal/vanilla';
598
+ *
599
+ * const bubble = new DomternalBubbleMenu(host, { editor });
600
+ *
601
+ * // Custom item list:
602
+ * new DomternalBubbleMenu(host, {
603
+ * editor,
604
+ * items: ['bold', 'italic', '|', 'link'],
605
+ * });
606
+ *
607
+ * // Listen for state changes:
608
+ * bubble.addEventListener('dropdownopen', (e) => {
609
+ * console.log('opened', (e as CustomEvent<{ name: string }>).detail.name);
610
+ * });
611
+ *
612
+ * bubble.destroy();
613
+ * ```
614
+ *
615
+ * **Events dispatched** (CustomEvent, `detail` shape in brackets):
616
+ * - `dropdownopen` - `{ name: string }`
617
+ * - `dropdownclose` - `{ name: string }`
618
+ */
619
+ declare class DomternalBubbleMenu extends EventTarget {
620
+ #private;
621
+ readonly host: HTMLElement;
622
+ get editor(): Editor;
623
+ /** Current trailing-button state snapshot. */
624
+ get trailing(): Readonly<BubbleMenuTrailingState>;
625
+ /** Currently open dropdown name (e.g. text-align), or `null`. */
626
+ get openDropdown(): string | null;
627
+ constructor(host: HTMLElement, options: DomternalBubbleMenuOptions);
628
+ /**
629
+ * Emit `notionColorOpen` with the given anchor element. Listened to by
630
+ * `DomternalNotionColorPicker` (Phase 4) which positions its panel against
631
+ * the anchor. Typically called by the wrapper's internal "A" trigger but
632
+ * exposed publicly for power users with custom UIs.
633
+ */
634
+ openColorPicker(anchor: HTMLElement): void;
635
+ /**
636
+ * Open the block context menu against the cursor's containing block.
637
+ * Dispatches `dm:block-context-menu-open` on `.dm-editor`. The
638
+ * `BlockContextMenu` extension listens and positions itself against the
639
+ * provided anchor.
640
+ *
641
+ * Walks one level up when the cursor's textblock is not a direct doc
642
+ * child (e.g. cursor in `listItem > paragraph` targets the `listItem`),
643
+ * so "Delete" / "Turn into" operate on the visual block.
644
+ */
645
+ openBlockContextMenu(anchor: HTMLElement): void;
646
+ /**
647
+ * Replace the explicit item list. When `undefined`, falls back to
648
+ * context-aware resolution (per `contexts` or `defaultBubbleContexts`).
649
+ * Re-renders on next transaction; call manually if needed:
650
+ * `editor.dispatch(editor.state.tr)` to force an immediate re-resolve.
651
+ */
652
+ setItems(items: string[] | undefined): void;
653
+ /**
654
+ * Replace context map. When `undefined`, defaults to
655
+ * `defaultBubbleContexts(editor)`. Note: `shouldShow` is baked into the
656
+ * plugin at construction; this only changes WHICH items render once the
657
+ * menu is visible.
658
+ */
659
+ setContexts(contexts: Record<string, string[] | true | null> | undefined): void;
660
+ /** Replace the icon set. `undefined` restores default Phosphor icons. */
661
+ setIcons(icons: IconSet | undefined): void;
662
+ /** Close any open dropdown (text-align). No-op if nothing is open. */
663
+ closeDropdown(): void;
664
+ destroy(): void;
665
+ }
666
+
667
+ interface DomternalFloatingMenuOptions extends CustomContentOption {
668
+ /** Editor instance the floating menu binds to. */
669
+ editor: Editor;
670
+ /** Custom visibility predicate. Defaults to "cursor at start of empty paragraph". */
671
+ shouldShow?: FloatingMenuOptions['shouldShow'];
672
+ /** Pixel offset from the anchor. @default 0 */
673
+ offset?: number;
674
+ /** Items override (array replaces defaults; function filters/reorders). */
675
+ items?: FloatingMenuItemsOverride;
676
+ /** Keyboard shortcuts for entering the menu. */
677
+ keymap?: FloatingMenuKeymap;
678
+ /** Icon set override. Falls back to `defaultIcons` per key. */
679
+ icons?: IconSet;
680
+ /**
681
+ * When `true`, the menu opens ONLY via explicit trigger (`showFloatingMenu`
682
+ * call, typically from BlockHandle's `+` button). Pressing Enter to make
683
+ * a new empty paragraph does NOT auto-show the menu. Notion-style behavior.
684
+ * @default false
685
+ */
686
+ requireExplicitTrigger?: boolean;
687
+ }
688
+ /**
689
+ * `DomternalFloatingMenu` - vanilla DOM block-insert menu bound to an editor.
690
+ *
691
+ * Renders the editor's `floatingMenuItems` (collected via extensions'
692
+ * `addFloatingMenuItems()` hook) in groups, with roving-tabindex keyboard
693
+ * navigation and `requireExplicitTrigger` support for Notion-style flow.
694
+ *
695
+ * Mounts `createFloatingMenuPlugin` from `@domternal/extension-block-menu`
696
+ * for visibility + positioning. The plugin moves the host element inside
697
+ * `.dm-editor` automatically so it scrolls with the editor.
698
+ *
699
+ * @example
700
+ * ```ts
701
+ * import { DomternalFloatingMenu } from '@domternal/vanilla';
702
+ *
703
+ * // Classic: auto-shows on every empty paragraph
704
+ * new DomternalFloatingMenu(host, { editor });
705
+ *
706
+ * // Notion-style: only opens via "+" button or `showFloatingMenu(view)`
707
+ * new DomternalFloatingMenu(host, { editor, requireExplicitTrigger: true });
708
+ * ```
709
+ */
710
+ declare class DomternalFloatingMenu extends EventTarget {
711
+ #private;
712
+ readonly host: HTMLElement;
713
+ get editor(): Editor;
714
+ /**
715
+ * Underlying controller. Exposed for power-user introspection.
716
+ *
717
+ * `null` when `customContent` is provided (consumer owns rendering, and
718
+ * therefore the item-state machine). Use the `editor.floatingMenuItems`
719
+ * direct API in that case.
720
+ */
721
+ get controller(): FloatingMenuController | null;
722
+ constructor(host: HTMLElement, options: DomternalFloatingMenuOptions);
723
+ /**
724
+ * Swap the icon set. Re-renders to refresh button SVGs. No-op when
725
+ * `customContent` is in use (consumer renders their own icons).
726
+ */
727
+ setIcons(icons: IconSet | undefined): void;
728
+ destroy(): void;
729
+ }
730
+
731
+ interface DomternalNotionColorPickerOptions {
732
+ /** The editor instance the picker binds to. */
733
+ editor: Editor;
734
+ }
735
+ /**
736
+ * `DomternalNotionColorPicker` - vanilla DOM Notion-style color picker.
737
+ *
738
+ * Listens for the `notionColorOpen` editor event (emitted by the bubble menu's
739
+ * "A" trigger) and renders an inline panel positioned against the trigger.
740
+ * Two sections (Text color / Background color), each with a 5-column grid of
741
+ * named-token swatches plus a default reset swatch.
742
+ *
743
+ * The panel is appended into the `.dm-editor` host so CSS variables
744
+ * (`--dm-block-text-{token}`, `--dm-block-bg-{token}`) cascade naturally
745
+ * from the editor's theme scope. **Requires the editor view to be inside a
746
+ * `.dm-editor` host** (matches `@domternal/theme` convention). When that
747
+ * ancestor is missing the picker silently no-ops on open.
748
+ *
749
+ * Unlike other `@domternal/vanilla` components, this class does NOT take a
750
+ * `host` element argument - it auto-resolves the `.dm-editor` parent. The
751
+ * resolved host is exposed via the `host` getter for power-user inspection.
752
+ *
753
+ * @example
754
+ * ```ts
755
+ * import { DomternalNotionColorPicker } from '@domternal/vanilla';
756
+ *
757
+ * const picker = new DomternalNotionColorPicker({ editor });
758
+ *
759
+ * // picker.open(anchor) opens programmatically; usually called by the
760
+ * // bubble menu's "A" trigger via the `notionColorOpen` editor event.
761
+ *
762
+ * picker.destroy();
763
+ * ```
764
+ *
765
+ * **Events dispatched** (CustomEvent, `detail` shape in brackets):
766
+ * - `openchange` - `{ isOpen: boolean }`
767
+ * - `apply` - `{ kind: 'text' | 'bg', token: string | null }`
768
+ */
769
+ declare class DomternalNotionColorPicker extends EventTarget {
770
+ #private;
771
+ /** The picker's panel element (created on first open, persistent for reuse). */
772
+ get panel(): HTMLDivElement | null;
773
+ /**
774
+ * The `.dm-editor` host into which the panel mounts. Auto-resolved from
775
+ * `editor.view.dom.closest('.dm-editor')`. Returns `null` when the editor
776
+ * view is not inside a `.dm-editor` ancestor (in which case the picker
777
+ * silently no-ops on `open()`).
778
+ */
779
+ get host(): HTMLElement | null;
780
+ get editor(): Editor;
781
+ get isOpen(): boolean;
782
+ get currentTextToken(): string | null;
783
+ get currentBgToken(): string | null;
784
+ get palette(): readonly string[];
785
+ constructor(options: DomternalNotionColorPickerOptions);
786
+ /**
787
+ * Open the picker against the given anchor element. Typically called
788
+ * implicitly via the `notionColorOpen` editor event, but exposed publicly
789
+ * for power users who emit the open from custom UI.
790
+ *
791
+ * Toggle semantics: calling `open(anchor)` with the SAME anchor while the
792
+ * picker is already open closes the picker (with refocus). Calling with a
793
+ * different anchor re-anchors against the new element.
794
+ */
795
+ open(anchor: HTMLElement): void;
796
+ /**
797
+ * Close the picker. When `refocus` is `true`, returns focus to the editor
798
+ * view (NOT the anchor button - the anchor is on the bubble menu which
799
+ * vanishes on focus return).
800
+ */
801
+ close(opts?: {
802
+ refocus?: boolean;
803
+ }): void;
804
+ /** Apply a text color token to the current selection. Picker stays open. */
805
+ applyText(token: string | null): void;
806
+ /** Apply a background color token to the current selection. Picker stays open. */
807
+ applyBg(token: string | null): void;
808
+ /** Display label for a palette token (title-case fallback). */
809
+ tokenLabel(token: string): string;
810
+ destroy(): void;
811
+ }
812
+
813
+ interface EmojiPickerItem {
814
+ emoji: string;
815
+ name: string;
816
+ group: string;
817
+ }
818
+ interface DomternalEmojiPickerOptions {
819
+ /** The editor instance the picker binds to. */
820
+ editor: Editor;
821
+ /** Full emoji set with `{ emoji, name, group }` entries. Required. */
822
+ emojis: EmojiPickerItem[];
823
+ }
824
+ /**
825
+ * `DomternalEmojiPicker` - vanilla DOM emoji picker.
826
+ *
827
+ * Listens for the `insertEmoji` editor event (emitted by the toolbar's emoji
828
+ * trigger or via custom code) and renders a panel with search input, category
829
+ * tabs, and an 8-column grid of swatches. Frequently-used emojis come from
830
+ * the `Emoji` extension's storage if available.
831
+ *
832
+ * @example
833
+ * ```ts
834
+ * import { DomternalEmojiPicker } from '@domternal/vanilla';
835
+ * import { emojis } from '@domternal/extension-emoji';
836
+ *
837
+ * const picker = new DomternalEmojiPicker(host, { editor, emojis });
838
+ *
839
+ * // picker.open(anchor) opens programmatically; usually triggered
840
+ * // implicitly by the `insertEmoji` editor event.
841
+ *
842
+ * picker.destroy();
843
+ * ```
844
+ *
845
+ * **Events dispatched** (CustomEvent, `detail` shape in brackets):
846
+ * - `openchange` - `{ isOpen: boolean }`
847
+ * - `select` - `{ name: string; emoji: string }`
848
+ */
849
+ declare class DomternalEmojiPicker extends EventTarget {
850
+ #private;
851
+ readonly host: HTMLElement;
852
+ get editor(): Editor;
853
+ get isOpen(): boolean;
854
+ get searchQuery(): string;
855
+ get activeCategory(): string;
856
+ constructor(host: HTMLElement, options: DomternalEmojiPickerOptions);
857
+ /**
858
+ * Open the picker. If `anchor` is provided, panel positions against it;
859
+ * otherwise the panel renders un-positioned inside the host (consumer's CSS
860
+ * responsibility).
861
+ */
862
+ open(anchor: HTMLElement | null): void;
863
+ /**
864
+ * Close the picker. Returns focus to the editor view (so the caret stays
865
+ * visible). Idempotent.
866
+ */
867
+ close(): void;
868
+ destroy(): void;
869
+ }
870
+
871
+ export { type BubbleMenuItem, type BubbleMenuSeparator, type BubbleMenuTrailingState, type CustomContentOption, DEFAULT_EXTENSIONS, DROPDOWN_CARET, DomternalBubbleMenu, type DomternalBubbleMenuOptions, DomternalEditor, type DomternalEditorOptions, DomternalEmojiPicker, type DomternalEmojiPickerOptions, DomternalFloatingMenu, type DomternalFloatingMenuOptions, DomternalNotionColorPicker, type DomternalNotionColorPickerOptions, DomternalToolbar, type DomternalToolbarOptions, type EmojiPickerItem, INITIAL_TRAILING_STATE, type IconCache, type ItemMaps, type ResolvedPosShape, type SelectionShape, assertBrowser, buildItemMaps, computeTrailingState, createIconCache, createPluginKey, detectContext, filterBySchema, getComputedStyleAtCursor, getFormatItems, getInlineStyleAtCursor, getTooltip, isBrowser, isInsideTableCell, renderIconInto, resolveIcon, resolveNames, subscribe };