@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.
package/dist/index.js ADDED
@@ -0,0 +1,2822 @@
1
+ import { Document, Paragraph, Text, BaseKeymap, History, PluginKey, defaultIcons, Editor, ToolbarController, positionFloatingOnce, defaultBubbleContexts, createBubbleMenuPlugin, FloatingMenuController, positionFloating } from '@domternal/core';
2
+ import { createFloatingMenuPlugin } from '@domternal/extension-block-menu';
3
+
4
+ // src/shared/isBrowser.ts
5
+ var isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
6
+ function assertBrowser(className) {
7
+ if (!isBrowser) {
8
+ throw new Error(
9
+ `[${className}] requires a browser environment. If using Astro, wrap with <client:only="vanilla"> or instantiate inside a <script> tag. If using Nuxt/Next.js, gate construction with a typeof window check.`
10
+ );
11
+ }
12
+ }
13
+ function createPluginKey(prefix) {
14
+ const cryptoRef = globalThis.crypto;
15
+ const suffix = cryptoRef?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
16
+ return new PluginKey(prefix + "-" + suffix);
17
+ }
18
+ function renderIconInto(hostEl, iconKey, icons) {
19
+ if (!iconKey) {
20
+ hostEl.innerHTML = "";
21
+ return;
22
+ }
23
+ const customIcon = icons?.[iconKey];
24
+ const defaultIcon = defaultIcons[iconKey];
25
+ const svg = customIcon ?? defaultIcon ?? "";
26
+ hostEl.innerHTML = svg;
27
+ }
28
+ function resolveIcon(iconKey, icons) {
29
+ if (!iconKey) return "";
30
+ return icons?.[iconKey] ?? defaultIcons[iconKey] ?? "";
31
+ }
32
+
33
+ // src/shared/eventTarget.ts
34
+ function subscribe(target, type, listener) {
35
+ const wrapper = (event) => {
36
+ listener(event.detail);
37
+ };
38
+ target.addEventListener(type, wrapper);
39
+ return () => {
40
+ target.removeEventListener(type, wrapper);
41
+ };
42
+ }
43
+ var DEFAULT_EXTENSIONS = [
44
+ Document,
45
+ Paragraph,
46
+ Text,
47
+ BaseKeymap,
48
+ History
49
+ ];
50
+ var DomternalEditor = class extends EventTarget {
51
+ /** The underlying ProseMirror-backed `Editor` instance. */
52
+ editor;
53
+ /** The host element provided to the constructor. */
54
+ host;
55
+ #destroyed = false;
56
+ #onCreate;
57
+ #onUpdate;
58
+ #onSelectionChange;
59
+ #onFocus;
60
+ #onBlur;
61
+ #onDestroy;
62
+ #transactionHandler = null;
63
+ #focusHandler = null;
64
+ #blurHandler = null;
65
+ constructor(host, options = {}) {
66
+ super();
67
+ assertBrowser("DomternalEditor");
68
+ if (!(host instanceof HTMLElement)) {
69
+ throw new TypeError(
70
+ '[DomternalEditor] host must be an HTMLElement. Pass a DOM node (e.g. document.querySelector("#editor")).'
71
+ );
72
+ }
73
+ this.host = host;
74
+ this.#onCreate = options.onCreate;
75
+ this.#onUpdate = options.onUpdate;
76
+ this.#onSelectionChange = options.onSelectionChange;
77
+ this.#onFocus = options.onFocus;
78
+ this.#onBlur = options.onBlur;
79
+ this.#onDestroy = options.onDestroy;
80
+ this.editor = new Editor({
81
+ element: host,
82
+ extensions: [...DEFAULT_EXTENSIONS, ...options.extensions ?? []],
83
+ content: options.content ?? "",
84
+ editable: options.editable ?? true,
85
+ autofocus: options.autofocus ?? false
86
+ });
87
+ this.#wireEditorEvents();
88
+ this.#onCreate?.(this.editor);
89
+ this.dispatchEvent(
90
+ new CustomEvent("create", { detail: { editor: this.editor } })
91
+ );
92
+ }
93
+ // === Reactive-friendly getters ===
94
+ get htmlContent() {
95
+ return this.editor.getHTML();
96
+ }
97
+ get jsonContent() {
98
+ return this.editor.getJSON();
99
+ }
100
+ get isEmpty() {
101
+ return this.editor.isEmpty;
102
+ }
103
+ get isFocused() {
104
+ return this.editor.isFocused;
105
+ }
106
+ get isEditable() {
107
+ return this.editor.isEditable;
108
+ }
109
+ // === Mutators ===
110
+ /**
111
+ * Replace editor content. Does NOT emit `update` event by default
112
+ * (mirrors `Editor.setContent` behavior); pass `emitUpdate=true` to fire.
113
+ *
114
+ * No-op if the wrapper has been destroyed.
115
+ */
116
+ setContent(content, emitUpdate = false) {
117
+ if (this.#destroyed) return;
118
+ this.editor.setContent(content, emitUpdate);
119
+ }
120
+ /**
121
+ * Toggle the editor's editable state (`true` allows input, `false` makes
122
+ * it read-only). No-op if the wrapper has been destroyed.
123
+ */
124
+ setEditable(editable) {
125
+ if (this.#destroyed) return;
126
+ this.editor.setEditable(editable);
127
+ }
128
+ /**
129
+ * Programmatically focus the editor.
130
+ *
131
+ * @param position - Focus position (`'start' | 'end' | 'all' | number | boolean`).
132
+ * Defaults to current selection. No-op if the wrapper has been destroyed.
133
+ */
134
+ focus(position) {
135
+ if (this.#destroyed) return;
136
+ this.editor.commands.focus(position);
137
+ }
138
+ /**
139
+ * Tear down the underlying editor + remove all subscriptions.
140
+ *
141
+ * Idempotent - calling twice is a no-op. Dispatches a `destroy` CustomEvent
142
+ * BEFORE the editor is destroyed, so listeners can read `this.editor` state
143
+ * one last time.
144
+ */
145
+ destroy() {
146
+ if (this.#destroyed) return;
147
+ this.#destroyed = true;
148
+ this.#onDestroy?.();
149
+ this.dispatchEvent(new CustomEvent("destroy", { detail: null }));
150
+ this.#unwireEditorEvents();
151
+ if (!this.editor.isDestroyed) {
152
+ this.editor.destroy();
153
+ }
154
+ }
155
+ // === Internal ===
156
+ #wireEditorEvents() {
157
+ this.#transactionHandler = ({ transaction }) => {
158
+ if (transaction.docChanged) {
159
+ this.#onUpdate?.({ editor: this.editor });
160
+ this.dispatchEvent(
161
+ new CustomEvent("update", { detail: { editor: this.editor } })
162
+ );
163
+ }
164
+ if (!transaction.docChanged && transaction.selectionSet) {
165
+ this.#onSelectionChange?.({ editor: this.editor });
166
+ this.dispatchEvent(
167
+ new CustomEvent("selectionchange", { detail: { editor: this.editor } })
168
+ );
169
+ }
170
+ };
171
+ this.#focusHandler = ({ event }) => {
172
+ this.#onFocus?.({ editor: this.editor, event });
173
+ this.dispatchEvent(
174
+ new CustomEvent("focus", { detail: { editor: this.editor, event } })
175
+ );
176
+ };
177
+ this.#blurHandler = ({ event }) => {
178
+ this.#onBlur?.({ editor: this.editor, event });
179
+ this.dispatchEvent(
180
+ new CustomEvent("blur", { detail: { editor: this.editor, event } })
181
+ );
182
+ };
183
+ this.editor.on("transaction", this.#transactionHandler);
184
+ this.editor.on("focus", this.#focusHandler);
185
+ this.editor.on("blur", this.#blurHandler);
186
+ }
187
+ #unwireEditorEvents() {
188
+ if (this.#transactionHandler) {
189
+ this.editor.off("transaction", this.#transactionHandler);
190
+ this.#transactionHandler = null;
191
+ }
192
+ if (this.#focusHandler) {
193
+ this.editor.off("focus", this.#focusHandler);
194
+ this.#focusHandler = null;
195
+ }
196
+ if (this.#blurHandler) {
197
+ this.editor.off("blur", this.#blurHandler);
198
+ this.#blurHandler = null;
199
+ }
200
+ }
201
+ };
202
+
203
+ // src/toolbar/tooltip.ts
204
+ var isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
205
+ var MODIFIER_MAP = isMac ? { Mod: "\u2318", Shift: "\u21E7", Alt: "\u2325" } : { Mod: "Ctrl", Shift: "Shift", Alt: "Alt" };
206
+ function getTooltip(item) {
207
+ if (!item.shortcut) return item.label;
208
+ const parts = item.shortcut.split("-").map((part) => MODIFIER_MAP[part] ?? part);
209
+ const shortcut = isMac ? parts.join("") : parts.join("+");
210
+ return `${item.label} (${shortcut})`;
211
+ }
212
+ var DROPDOWN_CARET = '<svg class="dm-dropdown-caret" width="10" height="10" viewBox="0 0 10 10"><path d="M2 4l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
213
+ function createIconCache(initialIcons) {
214
+ const cache = /* @__PURE__ */ new Map();
215
+ let icons = initialIcons;
216
+ let prevIcons = initialIcons;
217
+ function checkInvalidation() {
218
+ if (icons !== prevIcons) {
219
+ cache.clear();
220
+ prevIcons = icons;
221
+ }
222
+ }
223
+ function resolveSvg(name) {
224
+ if (icons) {
225
+ return icons[name] ?? "";
226
+ }
227
+ return defaultIcons[name] ?? "";
228
+ }
229
+ function getIcon(name) {
230
+ checkInvalidation();
231
+ const key = `i:${name}`;
232
+ let cached = cache.get(key);
233
+ if (cached === void 0) {
234
+ cached = resolveSvg(name);
235
+ cache.set(key, cached);
236
+ }
237
+ return cached;
238
+ }
239
+ function getTriggerLabel(label, isIcon) {
240
+ checkInvalidation();
241
+ const key = `tl:${label}:${isIcon ? "1" : "0"}`;
242
+ let cached = cache.get(key);
243
+ if (cached === void 0) {
244
+ const content = isIcon ? resolveSvg(label) : label;
245
+ cached = `<span class="dm-toolbar-trigger-label">${content}</span>${DROPDOWN_CARET}`;
246
+ cache.set(key, cached);
247
+ }
248
+ return cached;
249
+ }
250
+ function getTriggerIcon(iconName) {
251
+ checkInvalidation();
252
+ const key = `t:${iconName}`;
253
+ let cached = cache.get(key);
254
+ if (cached === void 0) {
255
+ cached = resolveSvg(iconName) + DROPDOWN_CARET;
256
+ cache.set(key, cached);
257
+ }
258
+ return cached;
259
+ }
260
+ function getItemContent(iconName, label, displayMode) {
261
+ const mode = displayMode ?? "icon-text";
262
+ checkInvalidation();
263
+ const key = `dc:${iconName}:${label}:${mode}`;
264
+ let cached = cache.get(key);
265
+ if (cached === void 0) {
266
+ if (mode === "text") {
267
+ cached = label;
268
+ } else if (mode === "icon") {
269
+ cached = resolveSvg(iconName);
270
+ } else {
271
+ cached = resolveSvg(iconName) + " " + label;
272
+ }
273
+ cache.set(key, cached);
274
+ }
275
+ return cached;
276
+ }
277
+ function getDropdownTriggerHtml(dropdown, activeItem) {
278
+ checkInvalidation();
279
+ if (dropdown.layout === "grid") {
280
+ const color = activeItem?.color ?? dropdown.defaultIndicatorColor ?? null;
281
+ const key = `tr:${dropdown.icon}:${color ?? ""}`;
282
+ let cached = cache.get(key);
283
+ if (cached === void 0) {
284
+ cached = resolveSvg(dropdown.icon) + DROPDOWN_CARET;
285
+ if (color) {
286
+ cached += `<span class="dm-toolbar-color-indicator" style="background-color: ${color}"></span>`;
287
+ }
288
+ cache.set(key, cached);
289
+ }
290
+ return cached;
291
+ }
292
+ if (dropdown.dynamicLabel) {
293
+ if (activeItem) return getTriggerLabel(activeItem.label);
294
+ if (dropdown.dynamicLabelFallback) return getTriggerLabel(dropdown.dynamicLabelFallback);
295
+ return getTriggerLabel(dropdown.icon, true);
296
+ }
297
+ const icon = dropdown.dynamicIcon && activeItem ? activeItem.icon : dropdown.icon;
298
+ return getTriggerIcon(icon);
299
+ }
300
+ function setIcons(newIcons) {
301
+ icons = newIcons;
302
+ }
303
+ return {
304
+ resolveSvg,
305
+ getIcon,
306
+ getTriggerLabel,
307
+ getTriggerIcon,
308
+ getItemContent,
309
+ getDropdownTriggerHtml,
310
+ setIcons
311
+ };
312
+ }
313
+
314
+ // src/toolbar/computedStyle.ts
315
+ function resolveElementAtCursor(editor) {
316
+ const { from } = editor.state.selection;
317
+ const { node } = editor.view.domAtPos(from);
318
+ return node instanceof HTMLElement ? node : node.parentElement;
319
+ }
320
+ function getComputedStyleAtCursor(editor, prop) {
321
+ try {
322
+ const node = resolveElementAtCursor(editor);
323
+ if (!node) return null;
324
+ const inline = node.style.getPropertyValue(prop);
325
+ if (inline) return inline;
326
+ return window.getComputedStyle(node).getPropertyValue(prop) || null;
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+ function getInlineStyleAtCursor(editor, prop) {
332
+ try {
333
+ const node = resolveElementAtCursor(editor);
334
+ if (!node) return null;
335
+ return node.style.getPropertyValue(prop) || null;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ // src/toolbar/DomternalToolbar.ts
342
+ var DomternalToolbar = class extends EventTarget {
343
+ host;
344
+ /** The editor instance this toolbar is bound to. */
345
+ get editor() {
346
+ return this.#editor;
347
+ }
348
+ /** Underlying controller. Exposed for power-user introspection. */
349
+ get controller() {
350
+ return this.#controller;
351
+ }
352
+ #controller;
353
+ #editor;
354
+ #layout;
355
+ #iconCache;
356
+ #abortCtl = new AbortController();
357
+ #destroyed = false;
358
+ /** Tracks the groups array reference to detect "structure changed" vs "state changed". */
359
+ #lastGroupsRef = null;
360
+ /** Maps top-level button/dropdown name -> rendered trigger element. */
361
+ #buttonEls = /* @__PURE__ */ new Map();
362
+ /** Rendered dropdown panel elements (created lazily when opened). */
363
+ #dropdownPanelEl = null;
364
+ #cleanupFloating = null;
365
+ #renderPending = false;
366
+ #renderRaf = 0;
367
+ #editorEl = null;
368
+ constructor(host, options) {
369
+ super();
370
+ assertBrowser("DomternalToolbar");
371
+ if (!(host instanceof HTMLElement)) {
372
+ throw new TypeError(
373
+ '[DomternalToolbar] host must be an HTMLElement. Pass a DOM node (e.g. document.querySelector("#toolbar")).'
374
+ );
375
+ }
376
+ if (!options.editor) {
377
+ throw new TypeError("[DomternalToolbar] options.editor is required.");
378
+ }
379
+ this.host = host;
380
+ this.#editor = options.editor;
381
+ this.#layout = options.layout;
382
+ this.#iconCache = createIconCache(options.icons);
383
+ this.host.classList.add("dm-toolbar");
384
+ this.host.setAttribute("role", "toolbar");
385
+ this.host.setAttribute("aria-label", "Editor formatting");
386
+ this.host.setAttribute("data-dm-editor-ui", "");
387
+ this.#controller = new ToolbarController(
388
+ this.#editor,
389
+ () => {
390
+ this.#scheduleRender();
391
+ },
392
+ this.#layout
393
+ );
394
+ this.#controller.subscribe();
395
+ this.#attachListeners();
396
+ this.#render();
397
+ }
398
+ // === Getters ===
399
+ get openDropdown() {
400
+ return this.#controller.openDropdown;
401
+ }
402
+ // === Setters ===
403
+ setLayout(layout) {
404
+ if (this.#destroyed) return;
405
+ this.#layout = layout;
406
+ this.#controller.destroy();
407
+ this.#controller = new ToolbarController(
408
+ this.#editor,
409
+ () => {
410
+ this.#scheduleRender();
411
+ },
412
+ this.#layout
413
+ );
414
+ this.#controller.subscribe();
415
+ this.#lastGroupsRef = null;
416
+ this.#scheduleRender();
417
+ }
418
+ setIcons(icons) {
419
+ if (this.#destroyed) return;
420
+ this.#iconCache.setIcons(icons);
421
+ this.#lastGroupsRef = null;
422
+ this.#scheduleRender();
423
+ }
424
+ closeDropdown() {
425
+ if (this.#destroyed) return;
426
+ this.#controller.closeDropdown();
427
+ }
428
+ destroy() {
429
+ if (this.#destroyed) return;
430
+ this.#destroyed = true;
431
+ cancelAnimationFrame(this.#renderRaf);
432
+ this.#abortCtl.abort();
433
+ this.#cleanupFloating?.();
434
+ this.#cleanupFloating = null;
435
+ this.#controller.destroy();
436
+ this.#dropdownPanelEl?.remove();
437
+ this.#dropdownPanelEl = null;
438
+ this.host.replaceChildren();
439
+ this.#buttonEls.clear();
440
+ }
441
+ // === Lifecycle helpers ===
442
+ #attachListeners() {
443
+ const { signal } = this.#abortCtl;
444
+ document.addEventListener(
445
+ "mousedown",
446
+ (e) => {
447
+ if (!this.#controller.openDropdown) return;
448
+ const target = e.target;
449
+ if (this.host.contains(target)) return;
450
+ if (this.#dropdownPanelEl?.contains(target)) return;
451
+ this.closeDropdown();
452
+ },
453
+ { signal }
454
+ );
455
+ this.host.addEventListener(
456
+ "keydown",
457
+ (e) => {
458
+ this.#onKeyDown(e);
459
+ },
460
+ { signal }
461
+ );
462
+ this.#editorEl = this.#editor.view.dom.closest(".dm-editor");
463
+ this.#editorEl?.addEventListener(
464
+ "dm:dismiss-overlays",
465
+ () => {
466
+ if (this.#controller.openDropdown) {
467
+ this.closeDropdown();
468
+ }
469
+ },
470
+ { signal }
471
+ );
472
+ }
473
+ // === Render ===
474
+ #scheduleRender() {
475
+ if (this.#renderPending) return;
476
+ this.#renderPending = true;
477
+ this.#renderRaf = requestAnimationFrame(() => {
478
+ this.#renderPending = false;
479
+ if (this.#destroyed) return;
480
+ this.#render();
481
+ });
482
+ }
483
+ #render() {
484
+ const groups = this.#controller.groups;
485
+ const groupsChanged = groups !== this.#lastGroupsRef;
486
+ if (groupsChanged) {
487
+ this.#renderGroupsStructure(groups);
488
+ this.#lastGroupsRef = groups;
489
+ }
490
+ this.#updateButtonStates();
491
+ this.#renderDropdown();
492
+ }
493
+ #renderGroupsStructure(groups) {
494
+ this.#cleanupFloating?.();
495
+ this.#cleanupFloating = null;
496
+ this.#dropdownPanelEl = null;
497
+ this.host.replaceChildren();
498
+ this.#buttonEls.clear();
499
+ groups.forEach((group, gi) => {
500
+ if (gi > 0) {
501
+ const sep = document.createElement("div");
502
+ sep.className = "dm-toolbar-separator";
503
+ sep.setAttribute("role", "separator");
504
+ this.host.appendChild(sep);
505
+ }
506
+ const groupEl = document.createElement("div");
507
+ groupEl.className = "dm-toolbar-group";
508
+ groupEl.setAttribute("role", "group");
509
+ groupEl.setAttribute("aria-label", group.name || "Tools");
510
+ for (const item of group.items) {
511
+ if (item.type === "button") {
512
+ const btnEl = this.#createButton(item);
513
+ groupEl.appendChild(btnEl);
514
+ this.#buttonEls.set(item.name, btnEl);
515
+ } else if (item.type === "dropdown") {
516
+ const wrapper = this.#createDropdownTrigger(item);
517
+ groupEl.appendChild(wrapper);
518
+ }
519
+ }
520
+ this.host.appendChild(groupEl);
521
+ });
522
+ }
523
+ #createButton(item) {
524
+ const btn = document.createElement("button");
525
+ btn.type = "button";
526
+ btn.className = "dm-toolbar-button";
527
+ btn.innerHTML = this.#iconCache.getIcon(item.icon);
528
+ btn.setAttribute("aria-label", item.label);
529
+ btn.title = getTooltip(item);
530
+ if (item.style) btn.setAttribute("style", item.style);
531
+ btn.addEventListener("mousedown", (e) => {
532
+ e.preventDefault();
533
+ });
534
+ btn.addEventListener("click", (e) => {
535
+ this.#onButtonClick(item, e);
536
+ });
537
+ btn.addEventListener("focus", () => {
538
+ this.#onButtonFocus(item.name);
539
+ });
540
+ return btn;
541
+ }
542
+ #createDropdownTrigger(dd) {
543
+ const wrapper = document.createElement("div");
544
+ wrapper.className = "dm-toolbar-dropdown-wrapper";
545
+ const btn = document.createElement("button");
546
+ btn.type = "button";
547
+ btn.className = "dm-toolbar-button dm-toolbar-dropdown-trigger";
548
+ btn.setAttribute("aria-haspopup", "true");
549
+ btn.setAttribute("aria-label", dd.label);
550
+ btn.title = dd.label;
551
+ btn.setAttribute("data-dropdown", dd.name);
552
+ btn.innerHTML = this.#resolveDropdownTriggerHtml(dd);
553
+ btn.addEventListener("mousedown", (e) => {
554
+ e.preventDefault();
555
+ });
556
+ btn.addEventListener("click", () => {
557
+ this.#onDropdownToggle(dd);
558
+ });
559
+ btn.addEventListener("focus", () => {
560
+ this.#onButtonFocus(dd.name);
561
+ });
562
+ wrapper.appendChild(btn);
563
+ this.#buttonEls.set(dd.name, btn);
564
+ return wrapper;
565
+ }
566
+ #resolveDropdownTriggerHtml(dd) {
567
+ const activeItem = dd.items.find((sub) => this.#controller.activeMap.get(sub.name));
568
+ if (dd.dynamicLabel && !activeItem && dd.computedStyleProperty) {
569
+ let computed;
570
+ if (dd.computedStyleProperty === "font-family") {
571
+ computed = getInlineStyleAtCursor(this.#editor, dd.computedStyleProperty);
572
+ if (computed) {
573
+ const first = computed.split(",")[0]?.replace(/['"]+/g, "").trim();
574
+ computed = first ?? null;
575
+ }
576
+ } else {
577
+ computed = getComputedStyleAtCursor(this.#editor, dd.computedStyleProperty);
578
+ }
579
+ if (computed) {
580
+ return `<span class="dm-toolbar-trigger-label">${computed}</span>${DROPDOWN_CARET}`;
581
+ }
582
+ }
583
+ return this.#iconCache.getDropdownTriggerHtml(dd, activeItem);
584
+ }
585
+ #updateButtonStates() {
586
+ const groups = this.#controller.groups;
587
+ const focusedIndex = this.#controller.focusedIndex;
588
+ for (const group of groups) {
589
+ for (const item of group.items) {
590
+ if (item.type === "button") {
591
+ this.#updateButton(item, focusedIndex);
592
+ } else if (item.type === "dropdown") {
593
+ this.#updateDropdownTrigger(item, focusedIndex);
594
+ }
595
+ }
596
+ }
597
+ }
598
+ #updateButton(item, focusedIndex) {
599
+ const btn = this.#buttonEls.get(item.name);
600
+ if (!btn) return;
601
+ const isActive = this.#controller.activeMap.get(item.name) ?? false;
602
+ const isDisabled = this.#controller.disabledMap.get(item.name) ?? false;
603
+ const flat = this.#controller.getFlatIndex(item.name);
604
+ btn.classList.toggle("dm-toolbar-button--active", isActive);
605
+ btn.disabled = isDisabled;
606
+ btn.setAttribute("aria-pressed", String(isActive));
607
+ btn.tabIndex = flat === focusedIndex ? 0 : -1;
608
+ if (item.emitEvent) {
609
+ const expanded = this.#controller.expandedMap.get(item.name) === true;
610
+ if (expanded) {
611
+ btn.setAttribute("aria-expanded", "true");
612
+ } else {
613
+ btn.removeAttribute("aria-expanded");
614
+ }
615
+ }
616
+ }
617
+ #updateDropdownTrigger(dd, focusedIndex) {
618
+ const btn = this.#buttonEls.get(dd.name);
619
+ if (!btn) return;
620
+ const isDisabled = this.#controller.disabledMap.get(dd.name) ?? false;
621
+ const isOpen = this.#controller.openDropdown === dd.name;
622
+ const flat = this.#controller.getFlatIndex(dd.name);
623
+ const isDropdownActive = dd.layout !== "grid" && !dd.dynamicLabel && dd.items.some((sub) => this.#controller.activeMap.get(sub.name) ?? false);
624
+ btn.classList.toggle("dm-toolbar-button--active", isDropdownActive);
625
+ btn.disabled = isDisabled;
626
+ btn.setAttribute("aria-expanded", String(isOpen));
627
+ btn.tabIndex = flat === focusedIndex ? 0 : -1;
628
+ const newHtml = this.#resolveDropdownTriggerHtml(dd);
629
+ if (btn.innerHTML !== newHtml) {
630
+ btn.innerHTML = newHtml;
631
+ }
632
+ }
633
+ #renderDropdown() {
634
+ const openName = this.#controller.openDropdown;
635
+ const currentPanel = this.#dropdownPanelEl;
636
+ if (openName === null && currentPanel === null) return;
637
+ if (openName === null) {
638
+ this.#cleanupFloating?.();
639
+ this.#cleanupFloating = null;
640
+ currentPanel?.remove();
641
+ this.#dropdownPanelEl = null;
642
+ return;
643
+ }
644
+ const dd = this.#findDropdown(openName);
645
+ if (!dd) return;
646
+ if (currentPanel?.dataset["dropdownPanel"] === openName) return;
647
+ this.#cleanupFloating?.();
648
+ this.#cleanupFloating = null;
649
+ currentPanel?.remove();
650
+ const panel = this.#createDropdownPanel(dd);
651
+ const trigger = this.#buttonEls.get(dd.name);
652
+ if (!trigger) return;
653
+ trigger.parentElement?.appendChild(panel);
654
+ this.#dropdownPanelEl = panel;
655
+ requestAnimationFrame(() => {
656
+ if (this.#destroyed || !panel.isConnected) return;
657
+ const placement = dd.layout === "grid" ? "bottom" : "bottom-start";
658
+ this.#cleanupFloating = positionFloatingOnce(trigger, panel, {
659
+ placement,
660
+ offsetValue: 4
661
+ });
662
+ });
663
+ this.dispatchEvent(new CustomEvent("dropdownopen", { detail: { name: openName } }));
664
+ }
665
+ #createDropdownPanel(dd) {
666
+ const panel = document.createElement("div");
667
+ panel.dataset["dropdownPanel"] = dd.name;
668
+ panel.setAttribute("role", "menu");
669
+ if (dd.layout === "grid") {
670
+ panel.className = "dm-toolbar-dropdown-panel dm-color-palette";
671
+ panel.style.setProperty("--dm-palette-columns", String(dd.gridColumns ?? 10));
672
+ for (const sub of dd.items) {
673
+ if (sub.color) {
674
+ const btn = document.createElement("button");
675
+ btn.type = "button";
676
+ btn.className = "dm-color-swatch";
677
+ if (this.#controller.activeMap.get(sub.name)) {
678
+ btn.classList.add("dm-color-swatch--active");
679
+ }
680
+ btn.setAttribute("role", "menuitem");
681
+ btn.tabIndex = -1;
682
+ btn.setAttribute("aria-label", sub.label);
683
+ btn.title = sub.label;
684
+ btn.style.backgroundColor = sub.color;
685
+ btn.addEventListener("mousedown", (e) => {
686
+ e.preventDefault();
687
+ });
688
+ btn.addEventListener("click", (e) => {
689
+ this.#onDropdownItemClick(sub, e);
690
+ });
691
+ panel.appendChild(btn);
692
+ } else {
693
+ const btn = document.createElement("button");
694
+ btn.type = "button";
695
+ btn.className = "dm-color-palette-reset";
696
+ btn.setAttribute("role", "menuitem");
697
+ btn.tabIndex = -1;
698
+ btn.setAttribute("aria-label", sub.label);
699
+ btn.innerHTML = this.#iconCache.getItemContent(sub.icon, sub.label);
700
+ btn.addEventListener("mousedown", (e) => {
701
+ e.preventDefault();
702
+ });
703
+ btn.addEventListener("click", (e) => {
704
+ this.#onDropdownItemClick(sub, e);
705
+ });
706
+ panel.appendChild(btn);
707
+ }
708
+ }
709
+ return panel;
710
+ }
711
+ panel.className = "dm-toolbar-dropdown-panel";
712
+ if (dd.displayMode) panel.setAttribute("data-display-mode", dd.displayMode);
713
+ for (const sub of dd.items) {
714
+ const btn = document.createElement("button");
715
+ btn.type = "button";
716
+ btn.className = "dm-toolbar-dropdown-item";
717
+ if (this.#controller.activeMap.get(sub.name)) {
718
+ btn.classList.add("dm-toolbar-dropdown-item--active");
719
+ }
720
+ btn.setAttribute("role", "menuitem");
721
+ btn.tabIndex = -1;
722
+ btn.setAttribute("aria-label", sub.label);
723
+ btn.title = sub.label;
724
+ btn.innerHTML = this.#iconCache.getItemContent(sub.icon, sub.label, dd.displayMode);
725
+ if (sub.style) btn.setAttribute("style", sub.style);
726
+ btn.addEventListener("mousedown", (e) => {
727
+ e.preventDefault();
728
+ });
729
+ btn.addEventListener("click", (e) => {
730
+ this.#onDropdownItemClick(sub, e);
731
+ });
732
+ panel.appendChild(btn);
733
+ }
734
+ return panel;
735
+ }
736
+ #findDropdown(name) {
737
+ for (const group of this.#controller.groups) {
738
+ for (const item of group.items) {
739
+ if (item.type === "dropdown" && item.name === name) {
740
+ return item;
741
+ }
742
+ }
743
+ }
744
+ return null;
745
+ }
746
+ // === Event handlers ===
747
+ #onButtonClick(item, event) {
748
+ if (this.#controller.openDropdown) {
749
+ this.closeDropdown();
750
+ }
751
+ if (item.emitEvent) {
752
+ const target = event.target;
753
+ const anchor = target?.closest(".dm-toolbar-button") ?? target ?? void 0;
754
+ this.#editor.emit(
755
+ item.emitEvent,
756
+ { anchorElement: anchor }
757
+ );
758
+ return;
759
+ }
760
+ this.#controller.executeCommand(item);
761
+ requestAnimationFrame(() => {
762
+ this.#editor.view.focus();
763
+ });
764
+ }
765
+ #onDropdownToggle(dd) {
766
+ const wasOpen = this.#controller.openDropdown === dd.name;
767
+ if (wasOpen) {
768
+ this.closeDropdown();
769
+ this.dispatchEvent(new CustomEvent("dropdownclose", { detail: { name: dd.name } }));
770
+ return;
771
+ }
772
+ if (this.#controller.openDropdown) {
773
+ const prev = this.#controller.openDropdown;
774
+ this.dispatchEvent(new CustomEvent("dropdownclose", { detail: { name: prev } }));
775
+ }
776
+ this.#cleanupFloating?.();
777
+ this.#cleanupFloating = null;
778
+ this.#controller.toggleDropdown(dd.name);
779
+ }
780
+ #onDropdownItemClick(item, event) {
781
+ let anchor;
782
+ if (item.emitEvent) {
783
+ const wrapper = event.target.closest(".dm-toolbar-dropdown-wrapper");
784
+ anchor = wrapper?.querySelector(".dm-toolbar-dropdown-trigger") ?? void 0;
785
+ }
786
+ this.closeDropdown();
787
+ if (item.emitEvent) {
788
+ this.#editor.emit(
789
+ item.emitEvent,
790
+ { anchorElement: anchor }
791
+ );
792
+ } else {
793
+ this.#controller.executeCommand(item);
794
+ }
795
+ requestAnimationFrame(() => {
796
+ this.#editor.view.focus();
797
+ });
798
+ }
799
+ #onButtonFocus(name) {
800
+ const index = this.#controller.getFlatIndex(name);
801
+ if (index >= 0) {
802
+ this.#controller.setFocusedIndex(index);
803
+ }
804
+ }
805
+ // === Keyboard navigation ===
806
+ #onKeyDown(event) {
807
+ switch (event.key) {
808
+ case "ArrowRight":
809
+ event.preventDefault();
810
+ this.#controller.navigateNext();
811
+ this.#focusCurrentButton();
812
+ break;
813
+ case "ArrowLeft":
814
+ event.preventDefault();
815
+ this.#controller.navigatePrev();
816
+ this.#focusCurrentButton();
817
+ break;
818
+ case "ArrowDown": {
819
+ event.preventDefault();
820
+ if (this.#controller.openDropdown) {
821
+ this.#focusDropdownItem(1);
822
+ } else {
823
+ const btn = document.activeElement;
824
+ if (btn instanceof HTMLElement && btn.getAttribute("aria-haspopup") && this.host.contains(btn)) {
825
+ btn.click();
826
+ requestAnimationFrame(() => {
827
+ this.#focusDropdownItem(0, true);
828
+ });
829
+ }
830
+ }
831
+ break;
832
+ }
833
+ case "ArrowUp":
834
+ event.preventDefault();
835
+ if (this.#controller.openDropdown) {
836
+ this.#focusDropdownItem(-1);
837
+ }
838
+ break;
839
+ case "Home":
840
+ event.preventDefault();
841
+ this.#controller.navigateFirst();
842
+ this.#focusCurrentButton();
843
+ break;
844
+ case "End":
845
+ event.preventDefault();
846
+ this.#controller.navigateLast();
847
+ this.#focusCurrentButton();
848
+ break;
849
+ case "Escape":
850
+ if (this.#controller.openDropdown) {
851
+ event.preventDefault();
852
+ this.closeDropdown();
853
+ this.#focusCurrentButton();
854
+ }
855
+ break;
856
+ }
857
+ }
858
+ #focusCurrentButton() {
859
+ const buttons = this.host.querySelectorAll(".dm-toolbar-button");
860
+ const btn = buttons.item(this.#controller.focusedIndex);
861
+ btn.focus();
862
+ }
863
+ #focusDropdownItem(direction, first) {
864
+ const panel = this.#dropdownPanelEl;
865
+ if (!panel) return;
866
+ const items = Array.from(panel.querySelectorAll('[role="menuitem"]'));
867
+ if (!items.length) return;
868
+ if (first) {
869
+ items[0]?.focus();
870
+ return;
871
+ }
872
+ const current = document.activeElement;
873
+ const idx = current instanceof HTMLElement ? items.indexOf(current) : -1;
874
+ const next = idx === -1 ? direction > 0 ? 0 : items.length - 1 : (idx + direction + items.length) % items.length;
875
+ items[next]?.focus();
876
+ }
877
+ };
878
+
879
+ // src/bubble-menu/itemResolver.ts
880
+ function buildItemMaps(editor) {
881
+ const itemMap = /* @__PURE__ */ new Map();
882
+ const dropdownMap = /* @__PURE__ */ new Map();
883
+ for (const item of editor.toolbarItems) {
884
+ if (item.type === "button") {
885
+ itemMap.set(item.name, item);
886
+ } else if (item.type === "dropdown") {
887
+ dropdownMap.set(item.name, item);
888
+ for (const sub of item.items) {
889
+ itemMap.set(sub.name, sub);
890
+ }
891
+ }
892
+ }
893
+ return {
894
+ itemMap,
895
+ dropdownMap,
896
+ bubbleDefaults: buildBubbleDefaults(editor)
897
+ };
898
+ }
899
+ function buildBubbleDefaults(editor) {
900
+ const byCtx = /* @__PURE__ */ new Map();
901
+ const addItem = (btn) => {
902
+ const ctx = btn["bubbleMenu"];
903
+ if (!ctx) return;
904
+ let arr = byCtx.get(ctx);
905
+ if (!arr) {
906
+ arr = [];
907
+ byCtx.set(ctx, arr);
908
+ }
909
+ arr.push(btn);
910
+ };
911
+ for (const item of editor.toolbarItems) {
912
+ if (item.type === "button") addItem(item);
913
+ else if (item.type === "dropdown") {
914
+ for (const sub of item.items) addItem(sub);
915
+ }
916
+ }
917
+ const result = /* @__PURE__ */ new Map();
918
+ for (const [ctx, ctxItems] of byCtx) {
919
+ ctxItems.sort((a, b) => (b.priority ?? 100) - (a.priority ?? 100));
920
+ const list = [];
921
+ let lastGroup;
922
+ let sepIdx = 0;
923
+ for (const item of ctxItems) {
924
+ if (lastGroup !== void 0 && item.group !== lastGroup) {
925
+ list.push({ type: "separator", name: `bsep-${String(sepIdx++)}` });
926
+ }
927
+ list.push(item);
928
+ lastGroup = item.group;
929
+ }
930
+ result.set(ctx, list);
931
+ }
932
+ return result;
933
+ }
934
+ function resolveNames(names, itemMap, dropdownMap) {
935
+ const result = [];
936
+ let sepIdx = 0;
937
+ for (const name of names) {
938
+ if (name === "|") {
939
+ result.push({ type: "separator", name: `sep-${String(sepIdx++)}` });
940
+ continue;
941
+ }
942
+ const dropdown = dropdownMap.get(name);
943
+ if (dropdown) {
944
+ result.push(dropdown);
945
+ continue;
946
+ }
947
+ const item = itemMap.get(name);
948
+ if (item) result.push(item);
949
+ }
950
+ return result;
951
+ }
952
+ function getFormatItems(itemMap) {
953
+ return Array.from(itemMap.values()).filter((item) => item.group === "format").sort((a, b) => (b.priority ?? 100) - (a.priority ?? 100));
954
+ }
955
+ function detectContext(selection, ctxs) {
956
+ if ("$anchorCell" in selection) return null;
957
+ if (selection.node) return selection.node.type.name;
958
+ if (selection.empty) return null;
959
+ const fromCell = findCellNode(selection.$from);
960
+ if (fromCell) {
961
+ const toCell = findCellNode(selection.$to);
962
+ if (toCell && fromCell !== toCell) return null;
963
+ return "table";
964
+ }
965
+ const fromName = selection.$from.parent.type.name;
966
+ if (fromName in ctxs) return fromName;
967
+ if ("text" in ctxs && selection.$from.parent.type.spec.marks !== "") return "text";
968
+ const toName = selection.$to.parent.type.name;
969
+ if (toName in ctxs) return toName;
970
+ if ("text" in ctxs && selection.$to.parent.type.spec.marks !== "") return "text";
971
+ return null;
972
+ }
973
+ function filterBySchema(editor, contextName, schemaItems) {
974
+ if (contextName === "text" || contextName === "table") return schemaItems;
975
+ const schema = editor.state.schema;
976
+ if (!schema) return schemaItems;
977
+ const nodeType = schema.nodes[contextName];
978
+ if (!nodeType) return schemaItems;
979
+ return schemaItems.filter((item) => {
980
+ const markName = typeof item.isActive === "string" ? item.isActive : null;
981
+ if (!markName) return true;
982
+ const markType = schema.marks[markName];
983
+ if (!markType) return true;
984
+ return nodeType.allowsMarkType(markType);
985
+ });
986
+ }
987
+ function isInsideTableCell($pos) {
988
+ for (let d = $pos.depth; d > 0; d--) {
989
+ const name = $pos.node(d).type.name;
990
+ if (name === "tableCell" || name === "tableHeader") return true;
991
+ }
992
+ return false;
993
+ }
994
+ function findCellNode(pos) {
995
+ for (let d = pos.depth; d > 0; d--) {
996
+ const node = pos.node(d);
997
+ if (node.type.name === "tableCell" || node.type.name === "tableHeader") return node;
998
+ }
999
+ return null;
1000
+ }
1001
+
1002
+ // src/bubble-menu/trailingState.ts
1003
+ var INITIAL_TRAILING_STATE = {
1004
+ isNodeSelection: false,
1005
+ showColorPickerButton: false,
1006
+ showBlockMenuButton: false,
1007
+ blockMenuButtonDisabled: false,
1008
+ currentTextColorVar: null,
1009
+ currentBgColorVar: null,
1010
+ hasAnyColor: false
1011
+ };
1012
+ function computeTrailingState(editor, opts) {
1013
+ const sel = editor.state.selection;
1014
+ const isNode = !!sel.node;
1015
+ let blockMenuDisabled = false;
1016
+ if (opts.hasBlockContextMenu) {
1017
+ const { $from, $to } = editor.state.selection;
1018
+ if ($from.depth < 1 || $to.depth < 1) {
1019
+ blockMenuDisabled = true;
1020
+ } else {
1021
+ blockMenuDisabled = $from.before(1) !== $to.before(1);
1022
+ }
1023
+ }
1024
+ let textVar = null;
1025
+ let bgVar = null;
1026
+ let hasAny = false;
1027
+ if (opts.hasNotionColorPicker) {
1028
+ const mark = editor.state.selection.$from.marks().find((m) => m.type.name === "textStyle");
1029
+ const attrs = mark?.attrs ?? {};
1030
+ const tToken = attrs.colorToken ?? null;
1031
+ const bToken = attrs.backgroundColorToken ?? null;
1032
+ textVar = tToken ? `var(--dm-block-text-${tToken})` : null;
1033
+ bgVar = bToken ? `var(--dm-block-bg-${bToken})` : null;
1034
+ hasAny = tToken !== null || bToken !== null;
1035
+ }
1036
+ return {
1037
+ isNodeSelection: isNode,
1038
+ showColorPickerButton: opts.hasNotionColorPicker,
1039
+ showBlockMenuButton: opts.hasBlockContextMenu,
1040
+ blockMenuButtonDisabled: blockMenuDisabled,
1041
+ currentTextColorVar: textVar,
1042
+ currentBgColorVar: bgVar,
1043
+ hasAnyColor: hasAny
1044
+ };
1045
+ }
1046
+
1047
+ // src/bubble-menu/DomternalBubbleMenu.ts
1048
+ var DROPDOWN_CARET2 = '<svg class="dm-dropdown-caret" width="10" height="10" viewBox="0 0 10 10"><path d="M2 4l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1049
+ var DomternalBubbleMenu = class extends EventTarget {
1050
+ host;
1051
+ get editor() {
1052
+ return this.#editor;
1053
+ }
1054
+ /** Current trailing-button state snapshot. */
1055
+ get trailing() {
1056
+ return this.#trailing;
1057
+ }
1058
+ /** Currently open dropdown name (e.g. text-align), or `null`. */
1059
+ get openDropdown() {
1060
+ return this.#openDropdown;
1061
+ }
1062
+ #editor;
1063
+ #pluginKey;
1064
+ #destroyed = false;
1065
+ /** Optional consumer-provided DOM appended after default items + trailing. */
1066
+ #customContent;
1067
+ // Configuration cached from constructor
1068
+ #shouldShowOpt;
1069
+ #placement;
1070
+ #offset;
1071
+ #updateDelay;
1072
+ #explicitItems;
1073
+ #explicitContexts;
1074
+ #icons;
1075
+ // Editor-derived caches (set once in #init)
1076
+ #maps = null;
1077
+ #hasNotionColorPicker = false;
1078
+ #hasBlockContextMenu = false;
1079
+ #effectiveContexts;
1080
+ #defaultItemList = [];
1081
+ #transactionHandler = null;
1082
+ // Live state
1083
+ #resolvedItems = [];
1084
+ #activeMap = /* @__PURE__ */ new Map();
1085
+ #disabledMap = /* @__PURE__ */ new Map();
1086
+ #trailing = { ...INITIAL_TRAILING_STATE };
1087
+ // Dropdown state (text-align dropdown inside bubble menu)
1088
+ #openDropdown = null;
1089
+ #dropdownPanelEl = null;
1090
+ #cleanupDropdownFloating = null;
1091
+ #dropdownAbortCtl = null;
1092
+ #renderPending = false;
1093
+ #renderRaf = 0;
1094
+ constructor(host, options) {
1095
+ super();
1096
+ assertBrowser("DomternalBubbleMenu");
1097
+ if (!(host instanceof HTMLElement)) {
1098
+ throw new TypeError(
1099
+ '[DomternalBubbleMenu] host must be an HTMLElement. Pass a DOM node (e.g. document.querySelector("#bubble")).'
1100
+ );
1101
+ }
1102
+ if (!options.editor) {
1103
+ throw new TypeError("[DomternalBubbleMenu] options.editor is required.");
1104
+ }
1105
+ this.host = host;
1106
+ this.#editor = options.editor;
1107
+ this.#shouldShowOpt = options.shouldShow;
1108
+ this.#placement = options.placement ?? "top";
1109
+ this.#offset = options.offset ?? 8;
1110
+ this.#updateDelay = options.updateDelay ?? 0;
1111
+ this.#explicitItems = options.items;
1112
+ this.#explicitContexts = options.contexts;
1113
+ this.#icons = options.icons;
1114
+ this.#customContent = options.customContent;
1115
+ this.#pluginKey = createPluginKey("vanillaBubbleMenu");
1116
+ this.host.classList.add("dm-bubble-menu");
1117
+ this.host.setAttribute("role", "toolbar");
1118
+ this.host.setAttribute("aria-label", "Text formatting");
1119
+ this.#init();
1120
+ }
1121
+ // === Public API ===
1122
+ /**
1123
+ * Emit `notionColorOpen` with the given anchor element. Listened to by
1124
+ * `DomternalNotionColorPicker` (Phase 4) which positions its panel against
1125
+ * the anchor. Typically called by the wrapper's internal "A" trigger but
1126
+ * exposed publicly for power users with custom UIs.
1127
+ */
1128
+ openColorPicker(anchor) {
1129
+ if (this.#destroyed) return;
1130
+ this.#editor.emit(
1131
+ "notionColorOpen",
1132
+ { anchorElement: anchor }
1133
+ );
1134
+ }
1135
+ /**
1136
+ * Open the block context menu against the cursor's containing block.
1137
+ * Dispatches `dm:block-context-menu-open` on `.dm-editor`. The
1138
+ * `BlockContextMenu` extension listens and positions itself against the
1139
+ * provided anchor.
1140
+ *
1141
+ * Walks one level up when the cursor's textblock is not a direct doc
1142
+ * child (e.g. cursor in `listItem > paragraph` targets the `listItem`),
1143
+ * so "Delete" / "Turn into" operate on the visual block.
1144
+ */
1145
+ openBlockContextMenu(anchor) {
1146
+ if (this.#destroyed) return;
1147
+ const $from = this.#editor.state.selection.$from;
1148
+ if ($from.depth < 1) return;
1149
+ const depth = $from.depth > 1 && $from.node($from.depth - 1).type.name !== "doc" ? $from.depth - 1 : $from.depth;
1150
+ const blockPos = $from.before(depth);
1151
+ const editorEl = this.#editor.view.dom.closest(".dm-editor");
1152
+ editorEl?.dispatchEvent(
1153
+ new CustomEvent("dm:block-context-menu-open", {
1154
+ bubbles: false,
1155
+ detail: { blockPos, anchorElement: anchor }
1156
+ })
1157
+ );
1158
+ }
1159
+ /**
1160
+ * Replace the explicit item list. When `undefined`, falls back to
1161
+ * context-aware resolution (per `contexts` or `defaultBubbleContexts`).
1162
+ * Re-renders on next transaction; call manually if needed:
1163
+ * `editor.dispatch(editor.state.tr)` to force an immediate re-resolve.
1164
+ */
1165
+ setItems(items) {
1166
+ if (this.#destroyed) return;
1167
+ this.#explicitItems = items;
1168
+ if (this.#maps) {
1169
+ this.#defaultItemList = items ? resolveNames(items, this.#maps.itemMap, this.#maps.dropdownMap) : resolveNames(["bold", "italic", "underline"], this.#maps.itemMap, this.#maps.dropdownMap);
1170
+ }
1171
+ this.#updateResolvedItems();
1172
+ this.#updateStates();
1173
+ this.#scheduleRender();
1174
+ }
1175
+ /**
1176
+ * Replace context map. When `undefined`, defaults to
1177
+ * `defaultBubbleContexts(editor)`. Note: `shouldShow` is baked into the
1178
+ * plugin at construction; this only changes WHICH items render once the
1179
+ * menu is visible.
1180
+ */
1181
+ setContexts(contexts) {
1182
+ if (this.#destroyed) return;
1183
+ this.#explicitContexts = contexts;
1184
+ this.#effectiveContexts = contexts ?? (this.#explicitItems ? void 0 : defaultBubbleContexts(this.#editor));
1185
+ this.#updateResolvedItems();
1186
+ this.#updateStates();
1187
+ this.#scheduleRender();
1188
+ }
1189
+ /** Replace the icon set. `undefined` restores default Phosphor icons. */
1190
+ setIcons(icons) {
1191
+ if (this.#destroyed) return;
1192
+ this.#icons = icons;
1193
+ this.#scheduleRender();
1194
+ }
1195
+ /** Close any open dropdown (text-align). No-op if nothing is open. */
1196
+ closeDropdown() {
1197
+ if (this.#openDropdown === null) return;
1198
+ const prev = this.#openDropdown;
1199
+ this.#openDropdown = null;
1200
+ this.#detachDropdown();
1201
+ this.dispatchEvent(new CustomEvent("dropdownclose", { detail: { name: prev } }));
1202
+ this.#scheduleRender();
1203
+ }
1204
+ destroy() {
1205
+ if (this.#destroyed) return;
1206
+ this.#destroyed = true;
1207
+ cancelAnimationFrame(this.#renderRaf);
1208
+ this.#detachDropdown();
1209
+ if (this.#transactionHandler) {
1210
+ this.#editor.off("transaction", this.#transactionHandler);
1211
+ this.#transactionHandler = null;
1212
+ }
1213
+ if (!this.#editor.isDestroyed) {
1214
+ this.#editor.unregisterPlugin(this.#pluginKey);
1215
+ }
1216
+ this.host.replaceChildren();
1217
+ }
1218
+ // === Init ===
1219
+ #init() {
1220
+ const ed = this.#editor;
1221
+ if (ed.isDestroyed) return;
1222
+ const exts = ed.extensionManager.extensions;
1223
+ this.#hasNotionColorPicker = exts.some((e) => e.name === "notionColorPicker");
1224
+ this.#hasBlockContextMenu = exts.some((e) => e.name === "blockContextMenu");
1225
+ this.#maps = buildItemMaps(ed);
1226
+ this.#effectiveContexts = this.#explicitContexts ?? (this.#explicitItems ? void 0 : defaultBubbleContexts(ed));
1227
+ this.#defaultItemList = this.#explicitItems ? resolveNames(this.#explicitItems, this.#maps.itemMap, this.#maps.dropdownMap) : resolveNames(["bold", "italic", "underline"], this.#maps.itemMap, this.#maps.dropdownMap);
1228
+ const shouldShow = this.#shouldShowOpt ?? this.#buildDefaultShouldShow();
1229
+ const plugin = createBubbleMenuPlugin({
1230
+ pluginKey: this.#pluginKey,
1231
+ editor: ed,
1232
+ element: this.host,
1233
+ shouldShow,
1234
+ placement: this.#placement,
1235
+ offset: this.#offset,
1236
+ updateDelay: this.#updateDelay
1237
+ });
1238
+ ed.registerPlugin(plugin);
1239
+ this.#updateResolvedItems();
1240
+ this.#updateStates();
1241
+ this.#trailing = computeTrailingState(ed, {
1242
+ hasNotionColorPicker: this.#hasNotionColorPicker,
1243
+ hasBlockContextMenu: this.#hasBlockContextMenu
1244
+ });
1245
+ this.#transactionHandler = () => {
1246
+ if (this.#destroyed) return;
1247
+ this.#updateResolvedItems();
1248
+ this.#updateStates();
1249
+ this.#trailing = computeTrailingState(ed, {
1250
+ hasNotionColorPicker: this.#hasNotionColorPicker,
1251
+ hasBlockContextMenu: this.#hasBlockContextMenu
1252
+ });
1253
+ this.#scheduleRender();
1254
+ };
1255
+ ed.on("transaction", this.#transactionHandler);
1256
+ this.#render();
1257
+ }
1258
+ #buildDefaultShouldShow() {
1259
+ const contexts = this.#effectiveContexts;
1260
+ const defaults = this.#maps?.bubbleDefaults;
1261
+ if (contexts) {
1262
+ return ({ state }) => {
1263
+ const ctx = detectContext(
1264
+ state.selection,
1265
+ contexts
1266
+ );
1267
+ if (!ctx) return false;
1268
+ if (ctx in contexts) {
1269
+ const val = contexts[ctx];
1270
+ if (val === null) return false;
1271
+ return val === true || Array.isArray(val) && val.length > 0;
1272
+ }
1273
+ return defaults?.has(ctx) ?? false;
1274
+ };
1275
+ }
1276
+ return ({ state }) => {
1277
+ const sel = state.selection;
1278
+ if (sel.empty) return false;
1279
+ if (sel.node) return defaults?.has(sel.node.type.name) ?? false;
1280
+ if (isInsideTableCell(sel.$from)) return false;
1281
+ return sel.$from.parent.type.spec.marks !== "" || sel.$to.parent.type.spec.marks !== "";
1282
+ };
1283
+ }
1284
+ // === State updates (called per transaction) ===
1285
+ #updateResolvedItems() {
1286
+ if (!this.#maps) return;
1287
+ const ed = this.#editor;
1288
+ const contexts = this.#effectiveContexts;
1289
+ if (contexts) {
1290
+ const ctx = detectContext(
1291
+ ed.state.selection,
1292
+ contexts
1293
+ );
1294
+ if (!ctx) {
1295
+ this.#resolvedItems = [];
1296
+ return;
1297
+ }
1298
+ if (ctx in contexts) {
1299
+ const val = contexts[ctx];
1300
+ if (val === null || Array.isArray(val) && val.length === 0) {
1301
+ this.#resolvedItems = [];
1302
+ return;
1303
+ }
1304
+ if (val === true) {
1305
+ this.#resolvedItems = filterBySchema(ed, ctx, getFormatItems(this.#maps.itemMap));
1306
+ return;
1307
+ }
1308
+ if (Array.isArray(val)) {
1309
+ const resolved = resolveNames(val, this.#maps.itemMap, this.#maps.dropdownMap);
1310
+ const buttons = resolved.filter(
1311
+ (i) => i.type !== "separator"
1312
+ );
1313
+ const allowed = new Set(filterBySchema(ed, ctx, buttons).map((b) => b.name));
1314
+ this.#resolvedItems = resolved.filter(
1315
+ (i) => i.type === "separator" || allowed.has(i.name)
1316
+ );
1317
+ return;
1318
+ }
1319
+ }
1320
+ this.#resolvedItems = this.#maps.bubbleDefaults.get(ctx) ?? [];
1321
+ return;
1322
+ }
1323
+ const sel = ed.state.selection;
1324
+ if (sel.node && this.#maps.bubbleDefaults.has(sel.node.type.name)) {
1325
+ this.#resolvedItems = this.#maps.bubbleDefaults.get(sel.node.type.name) ?? [];
1326
+ } else {
1327
+ this.#resolvedItems = this.#defaultItemList;
1328
+ }
1329
+ }
1330
+ #updateStates() {
1331
+ const ed = this.#editor;
1332
+ let canProxy = null;
1333
+ try {
1334
+ canProxy = ed.can();
1335
+ } catch {
1336
+ canProxy = null;
1337
+ }
1338
+ const trackButton = (btn) => {
1339
+ this.#activeMap.set(btn.name, ToolbarController.resolveActive(ed, btn));
1340
+ try {
1341
+ const canCmd = typeof btn.command === "string" ? canProxy?.[btn.command] : void 0;
1342
+ this.#disabledMap.set(
1343
+ btn.name,
1344
+ canCmd ? !(btn.commandArgs?.length ? canCmd(...btn.commandArgs) : canCmd()) : false
1345
+ );
1346
+ } catch {
1347
+ this.#disabledMap.set(btn.name, false);
1348
+ }
1349
+ };
1350
+ for (const item of this.#resolvedItems) {
1351
+ if (item.type === "separator") continue;
1352
+ if (item.type === "dropdown") {
1353
+ for (const sub of item.items) trackButton(sub);
1354
+ continue;
1355
+ }
1356
+ trackButton(item);
1357
+ }
1358
+ }
1359
+ // === Render ===
1360
+ #scheduleRender() {
1361
+ if (this.#renderPending) return;
1362
+ this.#renderPending = true;
1363
+ this.#renderRaf = requestAnimationFrame(() => {
1364
+ this.#renderPending = false;
1365
+ if (this.#destroyed) return;
1366
+ this.#render();
1367
+ });
1368
+ }
1369
+ #render() {
1370
+ this.host.replaceChildren();
1371
+ this.#cleanupDropdownFloating?.();
1372
+ this.#cleanupDropdownFloating = null;
1373
+ this.#dropdownAbortCtl?.abort();
1374
+ this.#dropdownAbortCtl = null;
1375
+ this.#dropdownPanelEl = null;
1376
+ if (this.#openDropdown !== null) {
1377
+ const stillExists = this.#resolvedItems.some(
1378
+ (item) => item.type === "dropdown" && item.name === this.#openDropdown
1379
+ );
1380
+ if (!stillExists) this.#openDropdown = null;
1381
+ }
1382
+ for (const item of this.#resolvedItems) {
1383
+ if (item.type === "separator") {
1384
+ this.host.appendChild(this.#createSeparator(item.name));
1385
+ } else if (item.type === "dropdown") {
1386
+ this.host.appendChild(this.#createDropdownTrigger(item));
1387
+ } else {
1388
+ this.host.appendChild(this.#createButton(item));
1389
+ }
1390
+ }
1391
+ const t = this.#trailing;
1392
+ if (t.showColorPickerButton && !t.isNodeSelection) {
1393
+ this.host.appendChild(this.#createSeparator("trailing-sep-color"));
1394
+ this.host.appendChild(this.#createColorTrigger());
1395
+ }
1396
+ if (t.showBlockMenuButton && !t.isNodeSelection) {
1397
+ this.host.appendChild(this.#createSeparator("trailing-sep-block"));
1398
+ this.host.appendChild(this.#createBlockMenuTrigger());
1399
+ }
1400
+ if (this.#customContent) {
1401
+ this.host.appendChild(this.#customContent);
1402
+ }
1403
+ }
1404
+ #createSeparator(name) {
1405
+ const sep = document.createElement("span");
1406
+ sep.className = "dm-toolbar-separator";
1407
+ sep.setAttribute("role", "separator");
1408
+ sep.dataset["name"] = name;
1409
+ return sep;
1410
+ }
1411
+ #createButton(item) {
1412
+ const isActive = this.#activeMap.get(item.name) ?? false;
1413
+ const isDisabled = this.#disabledMap.get(item.name) ?? false;
1414
+ const btn = document.createElement("button");
1415
+ btn.type = "button";
1416
+ btn.className = "dm-toolbar-button";
1417
+ if (isActive) btn.classList.add("dm-toolbar-button--active");
1418
+ btn.disabled = isDisabled;
1419
+ btn.setAttribute("aria-label", item.label);
1420
+ btn.setAttribute("aria-pressed", String(isActive));
1421
+ btn.title = item.label;
1422
+ btn.innerHTML = resolveIcon(item.icon, this.#icons);
1423
+ btn.addEventListener("mousedown", (e) => {
1424
+ e.preventDefault();
1425
+ });
1426
+ btn.addEventListener("click", (e) => {
1427
+ this.#onButtonClick(item, e);
1428
+ });
1429
+ return btn;
1430
+ }
1431
+ #createDropdownTrigger(dd) {
1432
+ const wrapper = document.createElement("div");
1433
+ wrapper.className = "dm-toolbar-dropdown-wrapper";
1434
+ wrapper.dataset["dropdownWrapper"] = dd.name;
1435
+ const dropdownActive = dd.items.some((sub) => this.#activeMap.get(sub.name) ?? false);
1436
+ const activeChild = dd.dynamicIcon ? dd.items.find((sub) => this.#activeMap.get(sub.name) ?? false) : void 0;
1437
+ const triggerIcon = activeChild?.icon ?? dd.icon;
1438
+ const triggerHtml = resolveIcon(triggerIcon, this.#icons) + DROPDOWN_CARET2;
1439
+ const trigger = document.createElement("button");
1440
+ trigger.type = "button";
1441
+ trigger.className = "dm-toolbar-button dm-toolbar-dropdown-trigger";
1442
+ if (dropdownActive) trigger.classList.add("dm-toolbar-button--active");
1443
+ trigger.setAttribute("aria-haspopup", "true");
1444
+ trigger.setAttribute("aria-expanded", String(this.#openDropdown === dd.name));
1445
+ trigger.setAttribute("aria-label", dd.label);
1446
+ trigger.title = dd.label;
1447
+ trigger.dataset["dropdown"] = dd.name;
1448
+ trigger.innerHTML = triggerHtml;
1449
+ trigger.addEventListener("mousedown", (e) => {
1450
+ e.preventDefault();
1451
+ });
1452
+ trigger.addEventListener("click", () => {
1453
+ this.#onDropdownToggle(dd);
1454
+ });
1455
+ wrapper.appendChild(trigger);
1456
+ if (this.#openDropdown === dd.name) {
1457
+ const panel = this.#createDropdownPanel(dd);
1458
+ wrapper.appendChild(panel);
1459
+ this.#dropdownPanelEl = panel;
1460
+ this.#attachDropdownListeners(trigger, panel);
1461
+ }
1462
+ return wrapper;
1463
+ }
1464
+ #createDropdownPanel(dd) {
1465
+ const panel = document.createElement("div");
1466
+ panel.className = "dm-toolbar-dropdown-panel";
1467
+ panel.setAttribute("role", "menu");
1468
+ panel.setAttribute("data-dm-editor-ui", "");
1469
+ panel.dataset["dropdownPanel"] = dd.name;
1470
+ for (const sub of dd.items) {
1471
+ const subActive = this.#activeMap.get(sub.name) ?? false;
1472
+ const subHtml = `${resolveIcon(sub.icon, this.#icons)} ${sub.label}`;
1473
+ const subBtn = document.createElement("button");
1474
+ subBtn.type = "button";
1475
+ subBtn.className = "dm-toolbar-dropdown-item";
1476
+ if (subActive) subBtn.classList.add("dm-toolbar-dropdown-item--active");
1477
+ subBtn.setAttribute("role", "menuitem");
1478
+ subBtn.setAttribute("aria-label", sub.label);
1479
+ subBtn.innerHTML = subHtml;
1480
+ subBtn.addEventListener("mousedown", (e) => {
1481
+ e.preventDefault();
1482
+ });
1483
+ subBtn.addEventListener("click", () => {
1484
+ this.#onDropdownItemClick(sub);
1485
+ });
1486
+ panel.appendChild(subBtn);
1487
+ }
1488
+ return panel;
1489
+ }
1490
+ #createColorTrigger() {
1491
+ const t = this.#trailing;
1492
+ const btn = document.createElement("button");
1493
+ btn.type = "button";
1494
+ btn.className = "dm-toolbar-button dm-ncp-trigger";
1495
+ if (t.hasAnyColor) btn.classList.add("dm-toolbar-button--active");
1496
+ btn.title = "Text and background color";
1497
+ btn.setAttribute("aria-label", "Text and background color");
1498
+ btn.setAttribute("aria-haspopup", "dialog");
1499
+ const glyph = document.createElement("span");
1500
+ glyph.className = "dm-ncp-trigger-glyph";
1501
+ glyph.textContent = "A";
1502
+ if (t.currentTextColorVar) glyph.style.color = t.currentTextColorVar;
1503
+ btn.appendChild(glyph);
1504
+ const underline = document.createElement("span");
1505
+ underline.className = "dm-ncp-trigger-underline";
1506
+ if (t.currentBgColorVar) underline.style.backgroundColor = t.currentBgColorVar;
1507
+ btn.appendChild(underline);
1508
+ btn.addEventListener("mousedown", (e) => {
1509
+ e.preventDefault();
1510
+ });
1511
+ btn.addEventListener("click", () => {
1512
+ this.openColorPicker(btn);
1513
+ });
1514
+ return btn;
1515
+ }
1516
+ #createBlockMenuTrigger() {
1517
+ const t = this.#trailing;
1518
+ const btn = document.createElement("button");
1519
+ btn.type = "button";
1520
+ btn.className = "dm-toolbar-button";
1521
+ btn.disabled = t.blockMenuButtonDisabled;
1522
+ btn.title = t.blockMenuButtonDisabled ? "Block actions (select within a single block)" : "More options";
1523
+ btn.setAttribute("aria-label", "More options");
1524
+ btn.setAttribute("aria-haspopup", "menu");
1525
+ btn.innerHTML = resolveIcon("dotsThree", this.#icons);
1526
+ btn.addEventListener("mousedown", (e) => {
1527
+ e.preventDefault();
1528
+ });
1529
+ btn.addEventListener("click", () => {
1530
+ this.openBlockContextMenu(btn);
1531
+ });
1532
+ return btn;
1533
+ }
1534
+ // === Event handlers ===
1535
+ #onButtonClick(item, event) {
1536
+ if (this.#openDropdown) this.closeDropdown();
1537
+ if (item.emitEvent) {
1538
+ const anchor = event.currentTarget ?? event.target;
1539
+ this.#editor.emit(
1540
+ item.emitEvent,
1541
+ { anchorElement: anchor }
1542
+ );
1543
+ return;
1544
+ }
1545
+ ToolbarController.executeItem(this.#editor, item);
1546
+ }
1547
+ #onDropdownToggle(dd) {
1548
+ if (this.#openDropdown === dd.name) {
1549
+ this.closeDropdown();
1550
+ return;
1551
+ }
1552
+ if (this.#openDropdown !== null) {
1553
+ const prev = this.#openDropdown;
1554
+ this.#detachDropdown();
1555
+ this.dispatchEvent(new CustomEvent("dropdownclose", { detail: { name: prev } }));
1556
+ }
1557
+ this.#openDropdown = dd.name;
1558
+ this.dispatchEvent(new CustomEvent("dropdownopen", { detail: { name: dd.name } }));
1559
+ this.#render();
1560
+ }
1561
+ #onDropdownItemClick(sub) {
1562
+ this.closeDropdown();
1563
+ ToolbarController.executeItem(this.#editor, sub);
1564
+ requestAnimationFrame(() => {
1565
+ this.#editor.view.focus();
1566
+ });
1567
+ }
1568
+ // === Dropdown lifecycle ===
1569
+ #attachDropdownListeners(trigger, panel) {
1570
+ this.#cleanupDropdownFloating?.();
1571
+ this.#cleanupDropdownFloating = positionFloatingOnce(trigger, panel, {
1572
+ placement: "bottom-start",
1573
+ offsetValue: 4
1574
+ });
1575
+ this.#dropdownAbortCtl?.abort();
1576
+ this.#dropdownAbortCtl = new AbortController();
1577
+ const { signal } = this.#dropdownAbortCtl;
1578
+ document.addEventListener(
1579
+ "mousedown",
1580
+ (e) => {
1581
+ const target = e.target;
1582
+ if (!target) return;
1583
+ if (panel.contains(target)) return;
1584
+ if (trigger.contains(target)) return;
1585
+ this.closeDropdown();
1586
+ },
1587
+ { signal }
1588
+ );
1589
+ document.addEventListener(
1590
+ "keydown",
1591
+ (e) => {
1592
+ if (e.key === "Escape") {
1593
+ e.preventDefault();
1594
+ this.closeDropdown();
1595
+ }
1596
+ },
1597
+ { signal }
1598
+ );
1599
+ const editorEl = this.#editor.view.dom.closest(".dm-editor");
1600
+ editorEl?.addEventListener(
1601
+ "dm:dismiss-overlays",
1602
+ () => {
1603
+ this.closeDropdown();
1604
+ },
1605
+ { signal }
1606
+ );
1607
+ }
1608
+ #detachDropdown() {
1609
+ this.#cleanupDropdownFloating?.();
1610
+ this.#cleanupDropdownFloating = null;
1611
+ this.#dropdownAbortCtl?.abort();
1612
+ this.#dropdownAbortCtl = null;
1613
+ this.#dropdownPanelEl?.remove();
1614
+ this.#dropdownPanelEl = null;
1615
+ }
1616
+ };
1617
+ var DomternalFloatingMenu = class extends EventTarget {
1618
+ host;
1619
+ get editor() {
1620
+ return this.#editor;
1621
+ }
1622
+ /**
1623
+ * Underlying controller. Exposed for power-user introspection.
1624
+ *
1625
+ * `null` when `customContent` is provided (consumer owns rendering, and
1626
+ * therefore the item-state machine). Use the `editor.floatingMenuItems`
1627
+ * direct API in that case.
1628
+ */
1629
+ get controller() {
1630
+ return this.#controller;
1631
+ }
1632
+ #editor;
1633
+ #pluginKey;
1634
+ #controller = null;
1635
+ #icons;
1636
+ #abortCtl = new AbortController();
1637
+ #destroyed = false;
1638
+ /** When `customContent` is provided, we skip controller + default render. */
1639
+ #hasCustomContent = false;
1640
+ #renderPending = false;
1641
+ #renderRaf = 0;
1642
+ constructor(host, options) {
1643
+ super();
1644
+ assertBrowser("DomternalFloatingMenu");
1645
+ if (!(host instanceof HTMLElement)) {
1646
+ throw new TypeError(
1647
+ '[DomternalFloatingMenu] host must be an HTMLElement. Pass a DOM node (e.g. document.querySelector("#floating")).'
1648
+ );
1649
+ }
1650
+ if (!options.editor) {
1651
+ throw new TypeError("[DomternalFloatingMenu] options.editor is required.");
1652
+ }
1653
+ this.host = host;
1654
+ this.#editor = options.editor;
1655
+ this.#icons = options.icons;
1656
+ this.#pluginKey = createPluginKey("vanillaFloatingMenu");
1657
+ this.host.classList.add("dm-floating-menu");
1658
+ this.host.setAttribute("role", "menu");
1659
+ this.host.setAttribute("aria-label", "Insert block");
1660
+ this.host.setAttribute("data-dm-editor-ui", "");
1661
+ const plugin = createFloatingMenuPlugin({
1662
+ pluginKey: this.#pluginKey,
1663
+ editor: this.#editor,
1664
+ element: this.host,
1665
+ ...options.shouldShow !== void 0 && { shouldShow: options.shouldShow },
1666
+ offset: options.offset ?? 0,
1667
+ ...options.keymap !== void 0 && { keymap: options.keymap },
1668
+ ...options.requireExplicitTrigger !== void 0 && {
1669
+ requireExplicitTrigger: options.requireExplicitTrigger
1670
+ }
1671
+ });
1672
+ this.#editor.registerPlugin(plugin);
1673
+ if (options.customContent) {
1674
+ this.#hasCustomContent = true;
1675
+ this.host.appendChild(options.customContent);
1676
+ return;
1677
+ }
1678
+ this.#controller = new FloatingMenuController(
1679
+ this.#editor,
1680
+ () => {
1681
+ this.#scheduleRender();
1682
+ },
1683
+ options.items
1684
+ );
1685
+ this.#controller.subscribe();
1686
+ this.host.addEventListener(
1687
+ "keydown",
1688
+ (e) => {
1689
+ this.#onKeyDown(e);
1690
+ },
1691
+ { signal: this.#abortCtl.signal }
1692
+ );
1693
+ this.#render();
1694
+ }
1695
+ /**
1696
+ * Swap the icon set. Re-renders to refresh button SVGs. No-op when
1697
+ * `customContent` is in use (consumer renders their own icons).
1698
+ */
1699
+ setIcons(icons) {
1700
+ if (this.#destroyed) return;
1701
+ this.#icons = icons;
1702
+ if (!this.#hasCustomContent) this.#scheduleRender();
1703
+ }
1704
+ destroy() {
1705
+ if (this.#destroyed) return;
1706
+ this.#destroyed = true;
1707
+ cancelAnimationFrame(this.#renderRaf);
1708
+ this.#abortCtl.abort();
1709
+ this.#controller?.destroy();
1710
+ if (!this.#editor.isDestroyed) {
1711
+ this.#editor.unregisterPlugin(this.#pluginKey);
1712
+ }
1713
+ this.host.replaceChildren();
1714
+ }
1715
+ // === Render ===
1716
+ #scheduleRender() {
1717
+ if (this.#renderPending || this.#hasCustomContent) return;
1718
+ this.#renderPending = true;
1719
+ this.#renderRaf = requestAnimationFrame(() => {
1720
+ this.#renderPending = false;
1721
+ if (this.#destroyed || !this.#controller) return;
1722
+ this.#render();
1723
+ const focusedIdx = this.#controller.focusedIndex;
1724
+ if (focusedIdx >= 0) {
1725
+ queueMicrotask(() => {
1726
+ const target = this.host.querySelector(
1727
+ `[data-floating-menu-index="${String(focusedIdx)}"]`
1728
+ );
1729
+ target?.focus();
1730
+ });
1731
+ }
1732
+ });
1733
+ }
1734
+ #render() {
1735
+ if (!this.#controller) return;
1736
+ this.host.replaceChildren();
1737
+ const groups = this.#controller.groups;
1738
+ const focusedIdx = this.#controller.focusedIndex;
1739
+ let flatIndex = 0;
1740
+ groups.forEach((group, gi) => {
1741
+ if (group.name) {
1742
+ const label = document.createElement("div");
1743
+ label.className = "dm-floating-menu-group-label";
1744
+ label.id = `dm-fm-g${String(gi)}`;
1745
+ label.textContent = group.name;
1746
+ this.host.appendChild(label);
1747
+ }
1748
+ const groupEl = document.createElement("div");
1749
+ groupEl.className = "dm-floating-menu-group";
1750
+ groupEl.setAttribute("role", "group");
1751
+ if (group.name) groupEl.setAttribute("aria-labelledby", `dm-fm-g${String(gi)}`);
1752
+ for (const item of group.items) {
1753
+ const currentFlat = flatIndex;
1754
+ flatIndex += 1;
1755
+ const btn = this.#createItem(item, currentFlat, focusedIdx);
1756
+ groupEl.appendChild(btn);
1757
+ }
1758
+ this.host.appendChild(groupEl);
1759
+ });
1760
+ }
1761
+ #createItem(item, flatIndex, focusedIndex) {
1762
+ const disabled = this.#controller?.isDisabled(item) ?? false;
1763
+ const btn = document.createElement("button");
1764
+ btn.type = "button";
1765
+ btn.className = "dm-floating-menu-item";
1766
+ btn.setAttribute("role", "menuitem");
1767
+ btn.dataset["floatingMenuItem"] = item.name;
1768
+ btn.dataset["floatingMenuIndex"] = String(flatIndex);
1769
+ btn.tabIndex = this.#tabIndexFor(flatIndex, focusedIndex);
1770
+ if (disabled) btn.setAttribute("aria-disabled", "true");
1771
+ if (item.shortcut) btn.setAttribute("aria-keyshortcuts", item.shortcut);
1772
+ btn.disabled = disabled;
1773
+ if (item.icon) {
1774
+ const iconHtml = resolveIcon(item.icon, this.#icons);
1775
+ if (iconHtml) {
1776
+ const iconSpan = document.createElement("span");
1777
+ iconSpan.className = "dm-floating-menu-item-icon";
1778
+ iconSpan.setAttribute("aria-hidden", "true");
1779
+ iconSpan.innerHTML = iconHtml;
1780
+ btn.appendChild(iconSpan);
1781
+ }
1782
+ }
1783
+ const labelSpan = document.createElement("span");
1784
+ labelSpan.className = "dm-floating-menu-item-label";
1785
+ labelSpan.textContent = item.label;
1786
+ btn.appendChild(labelSpan);
1787
+ if (item.shortcut) {
1788
+ const shortcutSpan = document.createElement("span");
1789
+ shortcutSpan.className = "dm-floating-menu-item-shortcut";
1790
+ shortcutSpan.setAttribute("aria-hidden", "true");
1791
+ shortcutSpan.textContent = item.shortcut;
1792
+ btn.appendChild(shortcutSpan);
1793
+ }
1794
+ btn.addEventListener("mousedown", (e) => {
1795
+ e.preventDefault();
1796
+ });
1797
+ btn.addEventListener("click", () => {
1798
+ this.#onItemClick(item);
1799
+ });
1800
+ return btn;
1801
+ }
1802
+ /**
1803
+ * Roving tabindex: focused item gets 0; when no item focused, first item
1804
+ * gets 0 (so tab traversal from outside lands naturally on the menu).
1805
+ */
1806
+ #tabIndexFor(flat, focused) {
1807
+ if (flat === focused) return 0;
1808
+ if (focused < 0 && flat === 0) return 0;
1809
+ return -1;
1810
+ }
1811
+ // === Event handlers ===
1812
+ #onItemClick(item) {
1813
+ if (this.#destroyed || !this.#controller) return;
1814
+ this.#controller.execute(item);
1815
+ requestAnimationFrame(() => {
1816
+ this.#editor.view.focus();
1817
+ });
1818
+ }
1819
+ #onKeyDown(event) {
1820
+ if (!this.#controller) return;
1821
+ const focused = this.#controller.focusedItem();
1822
+ switch (event.key) {
1823
+ case "ArrowDown":
1824
+ event.preventDefault();
1825
+ this.#controller.next();
1826
+ break;
1827
+ case "ArrowUp":
1828
+ event.preventDefault();
1829
+ this.#controller.prev();
1830
+ break;
1831
+ case "Home":
1832
+ event.preventDefault();
1833
+ this.#controller.first();
1834
+ break;
1835
+ case "End":
1836
+ event.preventDefault();
1837
+ this.#controller.last();
1838
+ break;
1839
+ case "Escape":
1840
+ event.preventDefault();
1841
+ event.stopPropagation();
1842
+ this.#controller.leaveMenu();
1843
+ this.#editor.view.focus();
1844
+ break;
1845
+ case "Enter":
1846
+ case " ":
1847
+ if (focused) {
1848
+ event.preventDefault();
1849
+ this.#onItemClick(focused);
1850
+ }
1851
+ break;
1852
+ }
1853
+ }
1854
+ };
1855
+ var TOKEN_LABELS = {
1856
+ gray: "Gray",
1857
+ brown: "Brown",
1858
+ orange: "Orange",
1859
+ yellow: "Yellow",
1860
+ green: "Green",
1861
+ blue: "Blue",
1862
+ purple: "Purple",
1863
+ pink: "Pink",
1864
+ red: "Red"
1865
+ };
1866
+ var DomternalNotionColorPicker = class extends EventTarget {
1867
+ /** The picker's panel element (created on first open, persistent for reuse). */
1868
+ get panel() {
1869
+ return this.#panel;
1870
+ }
1871
+ /**
1872
+ * The `.dm-editor` host into which the panel mounts. Auto-resolved from
1873
+ * `editor.view.dom.closest('.dm-editor')`. Returns `null` when the editor
1874
+ * view is not inside a `.dm-editor` ancestor (in which case the picker
1875
+ * silently no-ops on `open()`).
1876
+ */
1877
+ get host() {
1878
+ return this.#host;
1879
+ }
1880
+ get editor() {
1881
+ return this.#editor;
1882
+ }
1883
+ get isOpen() {
1884
+ return this.#isOpen;
1885
+ }
1886
+ get currentTextToken() {
1887
+ return this.#currentTextToken;
1888
+ }
1889
+ get currentBgToken() {
1890
+ return this.#currentBgToken;
1891
+ }
1892
+ get palette() {
1893
+ return this.#palette;
1894
+ }
1895
+ #editor;
1896
+ #destroyed = false;
1897
+ #isOpen = false;
1898
+ #anchor = null;
1899
+ /**
1900
+ * Bubble menu container captured when the picker was opened against an
1901
+ * anchor inside the bubble menu. Used by the virtual reference to re-resolve
1902
+ * `.dm-ncp-trigger` when the bubble menu rebuilds its DOM on transactions
1903
+ * and the original anchor element becomes disconnected. Null when the
1904
+ * picker was opened from a custom UI outside the bubble menu.
1905
+ */
1906
+ #anchorBubbleMenu = null;
1907
+ /** Last computed anchor rect; used as a fallback when no live anchor can be resolved. */
1908
+ #lastAnchorRect = null;
1909
+ #host = null;
1910
+ #panel = null;
1911
+ #currentTextToken = null;
1912
+ #currentBgToken = null;
1913
+ #palette = [];
1914
+ #onOpen = null;
1915
+ #onSelectionUpdate = null;
1916
+ /** Global listeners (mousedown + keydown + selectionUpdate) bound while open. */
1917
+ #openAbortCtl = null;
1918
+ #cleanupFloating = null;
1919
+ #rafIds = [];
1920
+ constructor(options) {
1921
+ super();
1922
+ assertBrowser("DomternalNotionColorPicker");
1923
+ if (!options.editor) {
1924
+ throw new TypeError("[DomternalNotionColorPicker] options.editor is required.");
1925
+ }
1926
+ this.#editor = options.editor;
1927
+ this.#host = this.#editor.view.dom.closest(".dm-editor");
1928
+ const ext = this.#editor.extensionManager.extensions.find(
1929
+ (e) => e.name === "notionColorPicker"
1930
+ );
1931
+ const extOpts = ext?.options ?? null;
1932
+ this.#palette = extOpts?.palette ? [...extOpts.palette] : [];
1933
+ this.#onOpen = (...args) => {
1934
+ const detail = args[0];
1935
+ const incoming = detail?.anchorElement;
1936
+ if (!incoming) return;
1937
+ this.open(incoming);
1938
+ };
1939
+ this.#onSelectionUpdate = () => {
1940
+ if (!this.#isOpen) return;
1941
+ if (!this.#anchor?.isConnected) {
1942
+ this.close();
1943
+ return;
1944
+ }
1945
+ if (this.#editor.state.selection.empty) {
1946
+ this.close();
1947
+ } else {
1948
+ this.#syncFromSelection();
1949
+ this.#updateActiveClasses();
1950
+ }
1951
+ };
1952
+ this.#editor.on("notionColorOpen", this.#onOpen);
1953
+ this.#editor.on("selectionUpdate", this.#onSelectionUpdate);
1954
+ }
1955
+ // === Public API ===
1956
+ /**
1957
+ * Open the picker against the given anchor element. Typically called
1958
+ * implicitly via the `notionColorOpen` editor event, but exposed publicly
1959
+ * for power users who emit the open from custom UI.
1960
+ *
1961
+ * Toggle semantics: calling `open(anchor)` with the SAME anchor while the
1962
+ * picker is already open closes the picker (with refocus). Calling with a
1963
+ * different anchor re-anchors against the new element.
1964
+ */
1965
+ open(anchor) {
1966
+ if (this.#destroyed) return;
1967
+ if (this.#isOpen && this.#isSameTrigger(anchor)) {
1968
+ this.close({ refocus: true });
1969
+ return;
1970
+ }
1971
+ this.#host ??= this.#editor.view.dom.closest(".dm-editor");
1972
+ this.#anchor = anchor;
1973
+ this.#anchorBubbleMenu = anchor.closest(".dm-bubble-menu");
1974
+ this.#lastAnchorRect = anchor.getBoundingClientRect();
1975
+ this.#syncFromSelection();
1976
+ this.#isOpen = true;
1977
+ this.#setStorageOpen(true);
1978
+ this.#renderOrUpdatePanel();
1979
+ this.#positionAndFocus();
1980
+ this.#attachGlobalListeners();
1981
+ this.dispatchEvent(new CustomEvent("openchange", { detail: { isOpen: true } }));
1982
+ }
1983
+ /**
1984
+ * Close the picker. When `refocus` is `true`, returns focus to the editor
1985
+ * view (NOT the anchor button - the anchor is on the bubble menu which
1986
+ * vanishes on focus return).
1987
+ */
1988
+ close(opts = {}) {
1989
+ if (!this.#isOpen) return;
1990
+ this.#isOpen = false;
1991
+ this.#setStorageOpen(false);
1992
+ this.#detachGlobalListeners();
1993
+ this.#cleanupFloating?.();
1994
+ this.#cleanupFloating = null;
1995
+ this.#cancelPendingRafs();
1996
+ if (this.#panel) {
1997
+ this.#panel.remove();
1998
+ }
1999
+ this.#anchor = null;
2000
+ this.#anchorBubbleMenu = null;
2001
+ this.#lastAnchorRect = null;
2002
+ if (opts.refocus && !this.#editor.isDestroyed) {
2003
+ this.#editor.view.focus();
2004
+ }
2005
+ this.dispatchEvent(new CustomEvent("openchange", { detail: { isOpen: false } }));
2006
+ }
2007
+ /** Apply a text color token to the current selection. Picker stays open. */
2008
+ applyText(token) {
2009
+ if (this.#destroyed) return;
2010
+ this.#editor.commands.setTextColorToken(token);
2011
+ this.#syncFromSelection();
2012
+ this.#updateActiveClasses();
2013
+ this.dispatchEvent(
2014
+ new CustomEvent("apply", { detail: { kind: "text", token } })
2015
+ );
2016
+ }
2017
+ /** Apply a background color token to the current selection. Picker stays open. */
2018
+ applyBg(token) {
2019
+ if (this.#destroyed) return;
2020
+ this.#editor.commands.setBackgroundColorToken(token);
2021
+ this.#syncFromSelection();
2022
+ this.#updateActiveClasses();
2023
+ this.dispatchEvent(
2024
+ new CustomEvent("apply", { detail: { kind: "bg", token } })
2025
+ );
2026
+ }
2027
+ /** Display label for a palette token (title-case fallback). */
2028
+ tokenLabel(token) {
2029
+ return TOKEN_LABELS[token] ?? token.charAt(0).toUpperCase() + token.slice(1);
2030
+ }
2031
+ destroy() {
2032
+ if (this.#destroyed) return;
2033
+ this.#destroyed = true;
2034
+ this.#detachGlobalListeners();
2035
+ this.#cleanupFloating?.();
2036
+ this.#cleanupFloating = null;
2037
+ this.#cancelPendingRafs();
2038
+ if (this.#onOpen) {
2039
+ this.#editor.off("notionColorOpen", this.#onOpen);
2040
+ this.#onOpen = null;
2041
+ }
2042
+ if (this.#onSelectionUpdate) {
2043
+ this.#editor.off("selectionUpdate", this.#onSelectionUpdate);
2044
+ this.#onSelectionUpdate = null;
2045
+ }
2046
+ if (this.#isOpen) this.#setStorageOpen(false);
2047
+ this.#panel?.remove();
2048
+ this.#panel = null;
2049
+ this.#isOpen = false;
2050
+ this.#anchor = null;
2051
+ this.#anchorBubbleMenu = null;
2052
+ this.#lastAnchorRect = null;
2053
+ }
2054
+ // === Internal ===
2055
+ /**
2056
+ * Test whether the incoming anchor is functionally the same trigger as
2057
+ * the currently stored anchor.
2058
+ *
2059
+ * - Exact DOM identity is preferred (cheap + unambiguous).
2060
+ * - Fallback: both anchors are `.dm-ncp-trigger` buttons inside the SAME
2061
+ * `.dm-bubble-menu` host. The bubble menu rebuilds its trigger button
2062
+ * on every transaction (new DOM node, same logical trigger), so the
2063
+ * ancestor check lets toggle-on-second-click work across rebuilds
2064
+ * without conflating with custom triggers in other host elements.
2065
+ */
2066
+ #isSameTrigger(incoming) {
2067
+ if (this.#anchor === incoming) return true;
2068
+ if (!incoming.classList.contains("dm-ncp-trigger")) return false;
2069
+ if (!(this.#anchor?.classList.contains("dm-ncp-trigger") ?? false)) return false;
2070
+ const currentBubble = this.#anchor?.closest(".dm-bubble-menu") ?? null;
2071
+ const incomingBubble = incoming.closest(".dm-bubble-menu");
2072
+ return currentBubble !== null && currentBubble === incomingBubble;
2073
+ }
2074
+ /**
2075
+ * Inspect the current selection and update the active text/bg tokens.
2076
+ * Empty selection: read stored marks. Non-empty: walk text nodes (because
2077
+ * `$from.nodeAfter` can land on a block at boundary positions).
2078
+ */
2079
+ #syncFromSelection() {
2080
+ const { selection } = this.#editor.state;
2081
+ let mark = null;
2082
+ if (selection.empty) {
2083
+ mark = selection.$from.marks().find((m) => m.type.name === "textStyle") ?? null;
2084
+ } else {
2085
+ this.#editor.state.doc.nodesBetween(selection.from, selection.to, (node) => {
2086
+ if (mark) return false;
2087
+ if (node.isText) {
2088
+ const found = node.marks.find((m) => m.type.name === "textStyle");
2089
+ if (found) mark = found;
2090
+ }
2091
+ return true;
2092
+ });
2093
+ }
2094
+ const attrs = mark?.attrs ?? {};
2095
+ this.#currentTextToken = attrs.colorToken ?? null;
2096
+ this.#currentBgToken = attrs.backgroundColorToken ?? null;
2097
+ }
2098
+ #setStorageOpen(open) {
2099
+ const slot = this.#editor.storage["notionColorPicker"];
2100
+ if (slot && typeof slot === "object") {
2101
+ slot.isOpen = open;
2102
+ }
2103
+ }
2104
+ #renderOrUpdatePanel() {
2105
+ if (!this.#host) return;
2106
+ if (!this.#panel) {
2107
+ this.#panel = this.#createPanel();
2108
+ } else {
2109
+ this.#panel.replaceChildren(...this.#renderSections());
2110
+ }
2111
+ this.#host.appendChild(this.#panel);
2112
+ }
2113
+ #createPanel() {
2114
+ const panel = document.createElement("div");
2115
+ panel.className = "dm-notion-color-picker";
2116
+ panel.setAttribute("data-show", "");
2117
+ panel.setAttribute("data-dm-editor-ui", "");
2118
+ panel.setAttribute("role", "dialog");
2119
+ panel.setAttribute("aria-label", "Text and background color");
2120
+ panel.setAttribute("aria-modal", "false");
2121
+ panel.addEventListener("keydown", (e) => {
2122
+ this.#onPanelKeydown(e);
2123
+ });
2124
+ panel.append(...this.#renderSections());
2125
+ return panel;
2126
+ }
2127
+ #renderSections() {
2128
+ return [
2129
+ this.#createSection({
2130
+ label: "Text color",
2131
+ variant: "text",
2132
+ activeToken: this.#currentTextToken,
2133
+ onApply: (t) => {
2134
+ this.applyText(t);
2135
+ }
2136
+ }),
2137
+ this.#createSection({
2138
+ label: "Background color",
2139
+ variant: "bg",
2140
+ activeToken: this.#currentBgToken,
2141
+ onApply: (t) => {
2142
+ this.applyBg(t);
2143
+ }
2144
+ })
2145
+ ];
2146
+ }
2147
+ #createSection(opts) {
2148
+ const section = document.createElement("div");
2149
+ section.className = "dm-ncp-section";
2150
+ const heading = document.createElement("div");
2151
+ heading.className = "dm-ncp-label";
2152
+ heading.textContent = opts.label;
2153
+ section.appendChild(heading);
2154
+ const grid = document.createElement("div");
2155
+ grid.className = "dm-ncp-grid";
2156
+ grid.appendChild(
2157
+ this.#createSwatch({
2158
+ token: null,
2159
+ variant: opts.variant,
2160
+ label: opts.variant === "text" ? "Default text color" : "Default background",
2161
+ activeToken: opts.activeToken,
2162
+ onApply: opts.onApply
2163
+ })
2164
+ );
2165
+ for (const t of this.#palette) {
2166
+ const label = opts.variant === "text" ? `${this.tokenLabel(t)} text` : `${this.tokenLabel(t)} background`;
2167
+ grid.appendChild(
2168
+ this.#createSwatch({
2169
+ token: t,
2170
+ variant: opts.variant,
2171
+ label,
2172
+ activeToken: opts.activeToken,
2173
+ onApply: opts.onApply
2174
+ })
2175
+ );
2176
+ }
2177
+ section.appendChild(grid);
2178
+ return section;
2179
+ }
2180
+ #createSwatch(opts) {
2181
+ const swatch = document.createElement("button");
2182
+ swatch.type = "button";
2183
+ swatch.className = `dm-ncp-swatch dm-ncp-swatch--${opts.variant}`;
2184
+ if (opts.activeToken === opts.token) swatch.classList.add("dm-ncp-active");
2185
+ swatch.setAttribute("aria-pressed", String(opts.activeToken === opts.token));
2186
+ swatch.dataset["color"] = opts.token ?? "null";
2187
+ swatch.title = opts.token === null ? opts.variant === "text" ? "Default text color" : "Default background" : this.tokenLabel(opts.token);
2188
+ swatch.setAttribute("aria-label", opts.label);
2189
+ swatch.addEventListener("mousedown", (e) => {
2190
+ e.preventDefault();
2191
+ });
2192
+ swatch.addEventListener("click", () => {
2193
+ opts.onApply(opts.token);
2194
+ });
2195
+ return swatch;
2196
+ }
2197
+ /** Update only active-class state without rebuilding DOM. */
2198
+ #updateActiveClasses() {
2199
+ if (!this.#panel) return;
2200
+ const textSwatches = Array.from(
2201
+ this.#panel.querySelectorAll(".dm-ncp-swatch--text")
2202
+ );
2203
+ const bgSwatches = Array.from(
2204
+ this.#panel.querySelectorAll(".dm-ncp-swatch--bg")
2205
+ );
2206
+ for (const sw of textSwatches) {
2207
+ const token = sw.dataset["color"] === "null" ? null : sw.dataset["color"] ?? null;
2208
+ const isActive = token === this.#currentTextToken;
2209
+ sw.classList.toggle("dm-ncp-active", isActive);
2210
+ sw.setAttribute("aria-pressed", String(isActive));
2211
+ }
2212
+ for (const sw of bgSwatches) {
2213
+ const token = sw.dataset["color"] === "null" ? null : sw.dataset["color"] ?? null;
2214
+ const isActive = token === this.#currentBgToken;
2215
+ sw.classList.toggle("dm-ncp-active", isActive);
2216
+ sw.setAttribute("aria-pressed", String(isActive));
2217
+ }
2218
+ }
2219
+ /**
2220
+ * Position + two-rAF focus chain. Floating-UI positions immediately; second
2221
+ * rAF waits for paint so focus doesn't race the bubble-menu blur. Focuses
2222
+ * the active swatch or falls back to the first default text swatch.
2223
+ */
2224
+ #positionAndFocus() {
2225
+ if (!this.#anchor || !this.#panel) return;
2226
+ this.#cleanupFloating?.();
2227
+ const virtualRef = {
2228
+ getBoundingClientRect: () => {
2229
+ if (this.#anchor?.isConnected) {
2230
+ const rect = this.#anchor.getBoundingClientRect();
2231
+ this.#lastAnchorRect = rect;
2232
+ return rect;
2233
+ }
2234
+ if (this.#anchorBubbleMenu?.isConnected) {
2235
+ const fresh = this.#anchorBubbleMenu.querySelector(
2236
+ ".dm-ncp-trigger"
2237
+ );
2238
+ if (fresh) {
2239
+ this.#anchor = fresh;
2240
+ const rect = fresh.getBoundingClientRect();
2241
+ this.#lastAnchorRect = rect;
2242
+ return rect;
2243
+ }
2244
+ }
2245
+ return this.#lastAnchorRect ?? new DOMRect(0, 0, 0, 0);
2246
+ }
2247
+ };
2248
+ this.#cleanupFloating = positionFloating(virtualRef, this.#panel, {
2249
+ placement: "bottom-start",
2250
+ offsetValue: 4
2251
+ });
2252
+ const id1 = requestAnimationFrame(() => {
2253
+ const id2 = requestAnimationFrame(() => {
2254
+ if (!this.#panel?.isConnected) return;
2255
+ const active = this.#panel.querySelector(".dm-ncp-swatch.dm-ncp-active");
2256
+ const fallback = this.#panel.querySelector(
2257
+ '.dm-ncp-swatch--text[data-color="null"]'
2258
+ );
2259
+ (active ?? fallback)?.focus({ preventScroll: true });
2260
+ });
2261
+ this.#rafIds.push(id2);
2262
+ });
2263
+ this.#rafIds.push(id1);
2264
+ }
2265
+ #cancelPendingRafs() {
2266
+ for (const id of this.#rafIds) cancelAnimationFrame(id);
2267
+ this.#rafIds = [];
2268
+ }
2269
+ #attachGlobalListeners() {
2270
+ this.#openAbortCtl?.abort();
2271
+ this.#openAbortCtl = new AbortController();
2272
+ const { signal } = this.#openAbortCtl;
2273
+ document.addEventListener(
2274
+ "mousedown",
2275
+ (e) => {
2276
+ const target = e.target;
2277
+ if (!target) return;
2278
+ if (this.#panel?.contains(target)) return;
2279
+ if (this.#anchor?.contains(target)) return;
2280
+ this.close();
2281
+ },
2282
+ { signal }
2283
+ );
2284
+ document.addEventListener(
2285
+ "keydown",
2286
+ (e) => {
2287
+ if (e.key === "Escape" && this.#isOpen) {
2288
+ e.preventDefault();
2289
+ this.close({ refocus: true });
2290
+ }
2291
+ },
2292
+ { signal }
2293
+ );
2294
+ }
2295
+ #detachGlobalListeners() {
2296
+ this.#openAbortCtl?.abort();
2297
+ this.#openAbortCtl = null;
2298
+ }
2299
+ /**
2300
+ * Arrow / Home / End nav across the 5-column swatch grid. Sequential focus
2301
+ * walks both Text and Background sections so keyboard users can reach any
2302
+ * swatch without re-grabbing Tab.
2303
+ */
2304
+ #onPanelKeydown(event) {
2305
+ const cols = 5;
2306
+ if (!this.#panel) return;
2307
+ const swatches = Array.from(
2308
+ this.#panel.querySelectorAll(".dm-ncp-swatch")
2309
+ );
2310
+ if (!swatches.length) return;
2311
+ const active = document.activeElement;
2312
+ if (!(active instanceof HTMLElement)) return;
2313
+ const idx = swatches.indexOf(active);
2314
+ if (idx === -1) return;
2315
+ let next = idx;
2316
+ switch (event.key) {
2317
+ case "ArrowRight":
2318
+ event.preventDefault();
2319
+ next = Math.min(idx + 1, swatches.length - 1);
2320
+ break;
2321
+ case "ArrowLeft":
2322
+ event.preventDefault();
2323
+ next = Math.max(idx - 1, 0);
2324
+ break;
2325
+ case "ArrowDown":
2326
+ event.preventDefault();
2327
+ next = Math.min(idx + cols, swatches.length - 1);
2328
+ break;
2329
+ case "ArrowUp":
2330
+ event.preventDefault();
2331
+ next = Math.max(idx - cols, 0);
2332
+ break;
2333
+ case "Home":
2334
+ event.preventDefault();
2335
+ next = 0;
2336
+ break;
2337
+ case "End":
2338
+ event.preventDefault();
2339
+ next = swatches.length - 1;
2340
+ break;
2341
+ default:
2342
+ return;
2343
+ }
2344
+ swatches[next]?.focus();
2345
+ }
2346
+ };
2347
+ var CATEGORY_ICONS = {
2348
+ "Smileys & Emotion": "\u{1F600}",
2349
+ "People & Body": "\u{1F44B}",
2350
+ "Animals & Nature": "\u{1F431}",
2351
+ "Food & Drink": "\u{1F355}",
2352
+ "Travel & Places": "\u{1F697}",
2353
+ Activities: "\u26BD",
2354
+ Objects: "\u{1F4A1}",
2355
+ Symbols: "\u{1F523}",
2356
+ Flags: "\u{1F3C1}"
2357
+ };
2358
+ var SCROLL_SETTLE_MS = 50;
2359
+ var DomternalEmojiPicker = class extends EventTarget {
2360
+ host;
2361
+ get editor() {
2362
+ return this.#editor;
2363
+ }
2364
+ get isOpen() {
2365
+ return this.#isOpen;
2366
+ }
2367
+ get searchQuery() {
2368
+ return this.#searchQuery;
2369
+ }
2370
+ get activeCategory() {
2371
+ return this.#activeCategory;
2372
+ }
2373
+ #editor;
2374
+ #emojis;
2375
+ #categories;
2376
+ #categoryNames;
2377
+ #destroyed = false;
2378
+ #isOpen = false;
2379
+ #searchQuery = "";
2380
+ #activeCategory = "";
2381
+ #anchor = null;
2382
+ #panel = null;
2383
+ #cleanupFloating = null;
2384
+ #openAbortCtl = null;
2385
+ #eventHandler = null;
2386
+ constructor(host, options) {
2387
+ super();
2388
+ assertBrowser("DomternalEmojiPicker");
2389
+ if (!(host instanceof HTMLElement)) {
2390
+ throw new TypeError(
2391
+ '[DomternalEmojiPicker] host must be an HTMLElement. Pass a DOM node (e.g. document.querySelector("#emoji-host")).'
2392
+ );
2393
+ }
2394
+ if (!options.editor) {
2395
+ throw new TypeError("[DomternalEmojiPicker] options.editor is required.");
2396
+ }
2397
+ if (!options.emojis) {
2398
+ throw new TypeError("[DomternalEmojiPicker] options.emojis is required.");
2399
+ }
2400
+ this.host = host;
2401
+ this.#editor = options.editor;
2402
+ this.#emojis = options.emojis;
2403
+ this.#categories = /* @__PURE__ */ new Map();
2404
+ for (const item of this.#emojis) {
2405
+ let list = this.#categories.get(item.group);
2406
+ if (!list) {
2407
+ list = [];
2408
+ this.#categories.set(item.group, list);
2409
+ }
2410
+ list.push(item);
2411
+ }
2412
+ this.#categoryNames = [...this.#categories.keys()];
2413
+ this.host.classList.add("dm-emoji-picker-host");
2414
+ this.#eventHandler = (...args) => {
2415
+ const data = args[0];
2416
+ if (this.#isOpen) {
2417
+ this.close();
2418
+ return;
2419
+ }
2420
+ this.open(data?.anchorElement ?? null);
2421
+ };
2422
+ this.#editor.on(
2423
+ "insertEmoji",
2424
+ this.#eventHandler
2425
+ );
2426
+ }
2427
+ // === Public API ===
2428
+ /**
2429
+ * Open the picker. If `anchor` is provided, panel positions against it;
2430
+ * otherwise the panel renders un-positioned inside the host (consumer's CSS
2431
+ * responsibility).
2432
+ */
2433
+ open(anchor) {
2434
+ if (this.#destroyed || this.#isOpen) return;
2435
+ this.#anchor = anchor;
2436
+ this.#isOpen = true;
2437
+ this.#searchQuery = "";
2438
+ if (this.#categoryNames[0]) {
2439
+ this.#activeCategory = this.#categoryNames[0];
2440
+ }
2441
+ this.#setStorageOpen(true);
2442
+ this.#renderPanel();
2443
+ this.#attachGlobalListeners();
2444
+ this.#positionAndFocus();
2445
+ this.dispatchEvent(new CustomEvent("openchange", { detail: { isOpen: true } }));
2446
+ }
2447
+ /**
2448
+ * Close the picker. Returns focus to the editor view (so the caret stays
2449
+ * visible). Idempotent.
2450
+ */
2451
+ close() {
2452
+ if (!this.#isOpen) return;
2453
+ this.#cleanupFloating?.();
2454
+ this.#cleanupFloating = null;
2455
+ this.#isOpen = false;
2456
+ this.#setStorageOpen(false);
2457
+ this.#searchQuery = "";
2458
+ this.#anchor = null;
2459
+ this.#detachGlobalListeners();
2460
+ this.#panel?.remove();
2461
+ this.#panel = null;
2462
+ if (!this.#editor.isDestroyed) {
2463
+ this.#editor.view.focus();
2464
+ }
2465
+ this.dispatchEvent(new CustomEvent("openchange", { detail: { isOpen: false } }));
2466
+ }
2467
+ destroy() {
2468
+ if (this.#destroyed) return;
2469
+ this.#destroyed = true;
2470
+ this.#detachGlobalListeners();
2471
+ this.#cleanupFloating?.();
2472
+ this.#cleanupFloating = null;
2473
+ if (this.#eventHandler) {
2474
+ this.#editor.off(
2475
+ "insertEmoji",
2476
+ this.#eventHandler
2477
+ );
2478
+ this.#eventHandler = null;
2479
+ }
2480
+ if (this.#isOpen) this.#setStorageOpen(false);
2481
+ this.#panel?.remove();
2482
+ this.#panel = null;
2483
+ this.#isOpen = false;
2484
+ }
2485
+ // === Internal ===
2486
+ /** Title-cased emoji name for tooltip/aria-label (replaces underscores). */
2487
+ #formatName(name) {
2488
+ return name.replace(/_/g, " ");
2489
+ }
2490
+ /** Display glyph for a category tab. Falls back to first character. */
2491
+ #categoryIcon(cat) {
2492
+ return CATEGORY_ICONS[cat] ?? cat.charAt(0);
2493
+ }
2494
+ #getEmojiStorage() {
2495
+ const storage = this.#editor.storage;
2496
+ return storage["emoji"] ?? null;
2497
+ }
2498
+ #setStorageOpen(open) {
2499
+ const storage = this.#getEmojiStorage();
2500
+ if (storage) storage["isOpen"] = open;
2501
+ if (!this.#editor.isDestroyed) {
2502
+ this.#editor.view.dispatch(this.#editor.view.state.tr);
2503
+ }
2504
+ }
2505
+ #getFilteredEmojis() {
2506
+ const query = this.#searchQuery.toLowerCase();
2507
+ if (!query) return [];
2508
+ const storage = this.#getEmojiStorage();
2509
+ const searchFn = storage?.["searchEmoji"];
2510
+ if (searchFn) return searchFn(query);
2511
+ return this.#emojis.filter(
2512
+ (item) => item.name.includes(query) || item.group.toLowerCase().includes(query)
2513
+ );
2514
+ }
2515
+ #getFrequentlyUsed() {
2516
+ const storage = this.#getEmojiStorage();
2517
+ if (!storage) return [];
2518
+ const getFreq = storage["getFrequentlyUsed"];
2519
+ if (!getFreq) return [];
2520
+ const names = getFreq();
2521
+ if (!names.length) return [];
2522
+ const nameMap = storage["_nameMap"];
2523
+ if (!nameMap) return [];
2524
+ return names.slice(0, 16).map((n) => nameMap.get(n)).filter((v) => Boolean(v));
2525
+ }
2526
+ /** Render or refresh the picker panel inside `host`. */
2527
+ #renderPanel() {
2528
+ if (!this.#panel) {
2529
+ this.#panel = this.#createPanel();
2530
+ this.host.appendChild(this.#panel);
2531
+ } else {
2532
+ this.#panel.replaceChildren(...this.#renderPanelChildren());
2533
+ }
2534
+ }
2535
+ #createPanel() {
2536
+ const panel = document.createElement("div");
2537
+ panel.className = "dm-emoji-picker";
2538
+ panel.append(...this.#renderPanelChildren());
2539
+ return panel;
2540
+ }
2541
+ #renderPanelChildren() {
2542
+ return [
2543
+ this.#renderSearch(),
2544
+ this.#renderTabs(),
2545
+ this.#renderGrid()
2546
+ ];
2547
+ }
2548
+ #renderSearch() {
2549
+ const wrapper = document.createElement("div");
2550
+ wrapper.className = "dm-emoji-picker-search";
2551
+ const input = document.createElement("input");
2552
+ input.type = "text";
2553
+ input.placeholder = "Search emoji...";
2554
+ input.value = this.#searchQuery;
2555
+ input.setAttribute("aria-label", "Search emoji");
2556
+ input.addEventListener("input", (e) => {
2557
+ this.#searchQuery = e.target.value;
2558
+ this.#refreshGrid();
2559
+ });
2560
+ input.addEventListener("keydown", (e) => {
2561
+ if (e.key === "Escape") this.close();
2562
+ });
2563
+ wrapper.appendChild(input);
2564
+ return wrapper;
2565
+ }
2566
+ #renderTabs() {
2567
+ const tabs = document.createElement("div");
2568
+ tabs.className = "dm-emoji-picker-tabs";
2569
+ tabs.setAttribute("role", "tablist");
2570
+ for (const cat of this.#categoryNames) {
2571
+ const tab = document.createElement("button");
2572
+ tab.type = "button";
2573
+ tab.className = "dm-emoji-picker-tab";
2574
+ if (this.#activeCategory === cat) {
2575
+ tab.classList.add("dm-emoji-picker-tab--active");
2576
+ }
2577
+ tab.setAttribute("role", "tab");
2578
+ tab.setAttribute("aria-selected", String(this.#activeCategory === cat));
2579
+ tab.title = cat;
2580
+ tab.setAttribute("aria-label", cat);
2581
+ tab.textContent = this.#categoryIcon(cat);
2582
+ tab.addEventListener("mousedown", (e) => {
2583
+ e.preventDefault();
2584
+ });
2585
+ tab.addEventListener("click", () => {
2586
+ this.#scrollToCategory(cat);
2587
+ });
2588
+ tabs.appendChild(tab);
2589
+ }
2590
+ return tabs;
2591
+ }
2592
+ #renderGrid() {
2593
+ const grid = document.createElement("div");
2594
+ grid.className = "dm-emoji-picker-grid";
2595
+ grid.addEventListener("scroll", () => {
2596
+ this.#onGridScroll();
2597
+ });
2598
+ grid.addEventListener("keydown", (e) => {
2599
+ this.#onGridKeydown(e);
2600
+ });
2601
+ if (this.#searchQuery) {
2602
+ const filtered = this.#getFilteredEmojis();
2603
+ if (filtered.length === 0) {
2604
+ const empty = document.createElement("div");
2605
+ empty.className = "dm-emoji-picker-empty";
2606
+ empty.textContent = "No emoji found";
2607
+ grid.appendChild(empty);
2608
+ } else {
2609
+ for (const item of filtered) {
2610
+ grid.appendChild(this.#createSwatch(item));
2611
+ }
2612
+ }
2613
+ return grid;
2614
+ }
2615
+ const freq = this.#getFrequentlyUsed();
2616
+ if (freq.length) {
2617
+ const freqLabel = document.createElement("div");
2618
+ freqLabel.className = "dm-emoji-picker-category-label";
2619
+ freqLabel.textContent = "Frequently Used";
2620
+ grid.appendChild(freqLabel);
2621
+ for (const item of freq) {
2622
+ grid.appendChild(this.#createSwatch(item));
2623
+ }
2624
+ }
2625
+ for (const cat of this.#categoryNames) {
2626
+ const label = document.createElement("div");
2627
+ label.className = "dm-emoji-picker-category-label";
2628
+ label.dataset["category"] = cat;
2629
+ label.textContent = cat;
2630
+ grid.appendChild(label);
2631
+ const items = this.#categories.get(cat) ?? [];
2632
+ for (const item of items) {
2633
+ grid.appendChild(this.#createSwatch(item));
2634
+ }
2635
+ }
2636
+ return grid;
2637
+ }
2638
+ #createSwatch(item) {
2639
+ const btn = document.createElement("button");
2640
+ btn.type = "button";
2641
+ btn.className = "dm-emoji-swatch";
2642
+ btn.tabIndex = -1;
2643
+ btn.title = this.#formatName(item.name);
2644
+ btn.setAttribute("aria-label", this.#formatName(item.name));
2645
+ btn.textContent = item.emoji;
2646
+ btn.addEventListener("mousedown", (e) => {
2647
+ e.preventDefault();
2648
+ });
2649
+ btn.addEventListener("click", () => {
2650
+ this.#selectEmoji(item);
2651
+ });
2652
+ return btn;
2653
+ }
2654
+ /**
2655
+ * Update `searchQuery` field AND sync the DOM input value. Centralised
2656
+ * so all callers see a consistent state - field is the source of truth
2657
+ * but the input element doesn't track it automatically.
2658
+ */
2659
+ #setSearchQuery(value) {
2660
+ this.#searchQuery = value;
2661
+ const input = this.#panel?.querySelector(
2662
+ ".dm-emoji-picker-search input"
2663
+ );
2664
+ if (input && input.value !== value) input.value = value;
2665
+ }
2666
+ /** Replace grid contents only (used when searchQuery changes). */
2667
+ #refreshGrid() {
2668
+ if (!this.#panel) return;
2669
+ const oldGrid = this.#panel.querySelector(".dm-emoji-picker-grid");
2670
+ const newGrid = this.#renderGrid();
2671
+ oldGrid?.replaceWith(newGrid);
2672
+ }
2673
+ #refreshTabs() {
2674
+ if (!this.#panel) return;
2675
+ const oldTabs = this.#panel.querySelector(".dm-emoji-picker-tabs");
2676
+ const newTabs = this.#renderTabs();
2677
+ oldTabs?.replaceWith(newTabs);
2678
+ }
2679
+ #selectEmoji(item) {
2680
+ const cmd = this.#editor.commands;
2681
+ cmd["insertEmoji"]?.(item.name);
2682
+ this.dispatchEvent(
2683
+ new CustomEvent("select", { detail: { name: item.name, emoji: item.emoji } })
2684
+ );
2685
+ this.close();
2686
+ }
2687
+ #scrollToCategory(cat) {
2688
+ if (cat !== this.#activeCategory) this.#setSearchQuery("");
2689
+ this.#activeCategory = cat;
2690
+ this.#refreshTabs();
2691
+ this.#refreshGrid();
2692
+ requestAnimationFrame(() => {
2693
+ const grid = this.#panel?.querySelector(".dm-emoji-picker-grid");
2694
+ if (!grid) return;
2695
+ const label = grid.querySelector(`[data-category="${cat}"]`);
2696
+ if (!label) return;
2697
+ grid.scrollTo({ top: label.offsetTop - grid.offsetTop });
2698
+ setTimeout(() => {
2699
+ const first = label.nextElementSibling;
2700
+ if (first instanceof HTMLElement && first.classList.contains("dm-emoji-swatch")) {
2701
+ first.focus();
2702
+ }
2703
+ }, SCROLL_SETTLE_MS);
2704
+ });
2705
+ }
2706
+ #onGridScroll() {
2707
+ if (this.#searchQuery) return;
2708
+ const grid = this.#panel?.querySelector(".dm-emoji-picker-grid");
2709
+ if (!grid) return;
2710
+ const labels = Array.from(
2711
+ grid.querySelectorAll(".dm-emoji-picker-category-label[data-category]")
2712
+ );
2713
+ let currentCat = "";
2714
+ for (const label of labels) {
2715
+ if (label.offsetTop - grid.offsetTop <= grid.scrollTop + 20) {
2716
+ currentCat = label.getAttribute("data-category") ?? "";
2717
+ }
2718
+ }
2719
+ if (currentCat && currentCat !== this.#activeCategory) {
2720
+ this.#activeCategory = currentCat;
2721
+ this.#refreshTabs();
2722
+ }
2723
+ }
2724
+ #onGridKeydown(event) {
2725
+ const grid = this.#panel?.querySelector(".dm-emoji-picker-grid");
2726
+ if (!grid) return;
2727
+ const swatches = Array.from(
2728
+ grid.querySelectorAll(".dm-emoji-swatch")
2729
+ );
2730
+ if (!swatches.length) return;
2731
+ const active = document.activeElement;
2732
+ const idx = active instanceof HTMLElement ? swatches.indexOf(active) : -1;
2733
+ if (idx === -1) {
2734
+ if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) {
2735
+ event.preventDefault();
2736
+ swatches[0]?.focus();
2737
+ }
2738
+ return;
2739
+ }
2740
+ const cols = 8;
2741
+ let next = idx;
2742
+ switch (event.key) {
2743
+ case "ArrowRight":
2744
+ event.preventDefault();
2745
+ next = Math.min(idx + 1, swatches.length - 1);
2746
+ break;
2747
+ case "ArrowLeft":
2748
+ event.preventDefault();
2749
+ next = Math.max(idx - 1, 0);
2750
+ break;
2751
+ case "ArrowDown":
2752
+ event.preventDefault();
2753
+ next = Math.min(idx + cols, swatches.length - 1);
2754
+ break;
2755
+ case "ArrowUp":
2756
+ event.preventDefault();
2757
+ next = Math.max(idx - cols, 0);
2758
+ break;
2759
+ case "Enter":
2760
+ case " ":
2761
+ event.preventDefault();
2762
+ swatches[idx]?.click();
2763
+ return;
2764
+ default:
2765
+ return;
2766
+ }
2767
+ swatches[next]?.focus();
2768
+ }
2769
+ #positionAndFocus() {
2770
+ requestAnimationFrame(() => {
2771
+ if (!this.#panel || this.#destroyed) return;
2772
+ if (this.#anchor) {
2773
+ this.#cleanupFloating?.();
2774
+ this.#cleanupFloating = positionFloatingOnce(this.#anchor, this.#panel, {
2775
+ placement: "bottom",
2776
+ offsetValue: 4
2777
+ });
2778
+ }
2779
+ const input = this.#panel.querySelector(
2780
+ ".dm-emoji-picker-search input"
2781
+ );
2782
+ input?.focus({ preventScroll: true });
2783
+ });
2784
+ }
2785
+ #attachGlobalListeners() {
2786
+ this.#openAbortCtl?.abort();
2787
+ this.#openAbortCtl = new AbortController();
2788
+ const { signal } = this.#openAbortCtl;
2789
+ document.addEventListener(
2790
+ "mousedown",
2791
+ (e) => {
2792
+ const target = e.target;
2793
+ if (!target || !this.#isOpen) return;
2794
+ if (this.#panel?.contains(target)) return;
2795
+ if (target === this.#anchor) return;
2796
+ if (this.#anchor?.contains(target)) return;
2797
+ requestAnimationFrame(() => {
2798
+ this.close();
2799
+ });
2800
+ },
2801
+ { signal }
2802
+ );
2803
+ document.addEventListener(
2804
+ "keydown",
2805
+ (e) => {
2806
+ if (e.key === "Escape" && this.#isOpen) {
2807
+ e.preventDefault();
2808
+ this.close();
2809
+ }
2810
+ },
2811
+ { signal }
2812
+ );
2813
+ }
2814
+ #detachGlobalListeners() {
2815
+ this.#openAbortCtl?.abort();
2816
+ this.#openAbortCtl = null;
2817
+ }
2818
+ };
2819
+
2820
+ export { DEFAULT_EXTENSIONS, DROPDOWN_CARET, DomternalBubbleMenu, DomternalEditor, DomternalEmojiPicker, DomternalFloatingMenu, DomternalNotionColorPicker, DomternalToolbar, INITIAL_TRAILING_STATE, assertBrowser, buildItemMaps, computeTrailingState, createIconCache, createPluginKey, detectContext, filterBySchema, getComputedStyleAtCursor, getFormatItems, getInlineStyleAtCursor, getTooltip, isBrowser, isInsideTableCell, renderIconInto, resolveIcon, resolveNames, subscribe };
2821
+ //# sourceMappingURL=index.js.map
2822
+ //# sourceMappingURL=index.js.map