@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/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/bubble-menu/index.d.ts +2 -0
- package/dist/bubble-menu/index.d.ts.map +1 -0
- package/dist/editor/index.d.ts +2 -0
- package/dist/editor/index.d.ts.map +1 -0
- package/dist/emoji-picker/index.d.ts +2 -0
- package/dist/emoji-picker/index.d.ts.map +1 -0
- package/dist/floating-menu/index.d.ts +2 -0
- package/dist/floating-menu/index.d.ts.map +1 -0
- package/dist/index.d.ts +871 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2822 -0
- package/dist/index.js.map +1 -0
- package/dist/notion-color-picker/index.d.ts +2 -0
- package/dist/notion-color-picker/index.d.ts.map +1 -0
- package/dist/shared/eventTarget.d.ts +22 -0
- package/dist/shared/eventTarget.d.ts.map +1 -0
- package/dist/shared/iconRenderer.d.ts +20 -0
- package/dist/shared/iconRenderer.d.ts.map +1 -0
- package/dist/shared/index.d.ts +6 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/isBrowser.d.ts +13 -0
- package/dist/shared/isBrowser.d.ts.map +1 -0
- package/dist/shared/pluginKey.d.ts +14 -0
- package/dist/shared/pluginKey.d.ts.map +1 -0
- package/dist/shared/types.d.ts +15 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/toolbar/index.d.ts +2 -0
- package/dist/toolbar/index.d.ts.map +1 -0
- package/package.json +50 -0
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
|