@kerebron/extension-menu 0.4.27 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1634 @@
1
+ // deno-lint-ignore-file no-window
2
+ import * as dntShim from "./_dnt.shims.js";
3
+ import { Plugin } from 'prosemirror-state';
4
+ import { getIcon } from './icons.js';
5
+ const CSS_PREFIX = 'kb-custom-menu';
6
+ const STORAGE_KEY = 'kb-custom-menu-order';
7
+ // Minimum width for overflow toggle button + some padding
8
+ const OVERFLOW_BUTTON_WIDTH = 48;
9
+ // Approximate width per toolbar item
10
+ const ITEM_WIDTH = 40;
11
+ // Delay before drag starts (ms) - user must hold for 2 seconds before dragging activates
12
+ const DRAG_START_DELAY = 2000;
13
+ export class CustomMenuView {
14
+ editorView;
15
+ editor;
16
+ content;
17
+ wrapper;
18
+ toolbar;
19
+ overflowMenu;
20
+ pinnedDropdownMenu = null;
21
+ tools = [];
22
+ root;
23
+ resizeHandle;
24
+ editorContainer;
25
+ closeOverflowHandler = null;
26
+ closePinnedDropdownHandler = null;
27
+ keydownHandler = null;
28
+ submenuStack = [];
29
+ pinnedDropdownStack = [];
30
+ resizeObserver = null;
31
+ // Drag and drop state
32
+ draggedItem = null;
33
+ dragStartTimer = null;
34
+ isDragging = false;
35
+ isDraggingFromOverflow = false;
36
+ dragPlaceholder = null;
37
+ dragGhost = null;
38
+ dragSourceWrapper = null;
39
+ // Current overflow tools (calculated during render)
40
+ currentOverflowTools = [];
41
+ // Default order of tools (stored on initialization)
42
+ defaultOrder = [];
43
+ // Currently focused toolbar item index for keyboard navigation
44
+ focusedToolbarIndex = -1;
45
+ constructor(editorView, editor, content) {
46
+ this.editorView = editorView;
47
+ this.editor = editor;
48
+ this.content = content;
49
+ this.root = editorView.root;
50
+ // Create wrapper
51
+ this.wrapper = document.createElement('div');
52
+ this.wrapper.classList.add(CSS_PREFIX + '__wrapper');
53
+ // Create toolbar
54
+ this.toolbar = document.createElement('div');
55
+ this.toolbar.classList.add(CSS_PREFIX);
56
+ this.toolbar.setAttribute('role', 'toolbar');
57
+ // Create editor container with resize handle
58
+ this.editorContainer = document.createElement('div');
59
+ this.editorContainer.classList.add(CSS_PREFIX + '__editor-container');
60
+ this.editorContainer.style.height = '50vh'; // Start at 50% height
61
+ // Create resize handle
62
+ this.resizeHandle = document.createElement('div');
63
+ this.resizeHandle.classList.add(CSS_PREFIX + '__resize-handle');
64
+ this.resizeHandle.innerHTML = '<div class="' + CSS_PREFIX +
65
+ '__resize-handle-bar"></div>';
66
+ // Mount toolbar before the editor DOM
67
+ this.wrapper.appendChild(this.toolbar);
68
+ if (editorView.dom.parentNode) {
69
+ editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom);
70
+ }
71
+ // Wrap editor in container and add resize handle
72
+ this.editorContainer.appendChild(editorView.dom);
73
+ this.editorContainer.appendChild(this.resizeHandle);
74
+ this.wrapper.appendChild(this.editorContainer);
75
+ // Create overflow menu (initially hidden)
76
+ this.overflowMenu = document.createElement('div');
77
+ this.overflowMenu.classList.add(CSS_PREFIX + '__overflow-menu');
78
+ this.overflowMenu.style.display = 'none';
79
+ this.wrapper.insertBefore(this.overflowMenu, this.editorContainer);
80
+ // Initialize tools from content
81
+ this.initializeTools();
82
+ // Load order state from localStorage
83
+ this.loadOrderState();
84
+ // Setup resize functionality
85
+ this.setupResize();
86
+ // Initial render
87
+ this.render();
88
+ // Use ResizeObserver to dynamically show/hide items based on available width
89
+ if (typeof ResizeObserver !== 'undefined') {
90
+ this.resizeObserver = new ResizeObserver(() => {
91
+ this.render();
92
+ });
93
+ this.resizeObserver.observe(this.toolbar);
94
+ }
95
+ // Setup keyboard navigation for accessibility
96
+ this.setupKeyboardNavigation();
97
+ }
98
+ /**
99
+ * Close all open menus (overflow menu and pinned dropdowns).
100
+ * Optionally returns focus to a specific element.
101
+ */
102
+ closeAllMenus(returnFocusTo) {
103
+ // Close overflow menu
104
+ if (this.overflowMenu.style.display !== 'none') {
105
+ this.overflowMenu.style.display = 'none';
106
+ this.submenuStack = [];
107
+ const overflowToggle = this.toolbar.querySelector('.' + CSS_PREFIX + '__overflow-toggle');
108
+ if (overflowToggle) {
109
+ overflowToggle.setAttribute('aria-expanded', 'false');
110
+ }
111
+ }
112
+ // Close pinned dropdown
113
+ if (this.pinnedDropdownMenu) {
114
+ this.pinnedDropdownMenu.remove();
115
+ this.pinnedDropdownMenu = null;
116
+ this.pinnedDropdownStack = [];
117
+ }
118
+ // Return focus if specified
119
+ if (returnFocusTo) {
120
+ returnFocusTo.focus();
121
+ }
122
+ }
123
+ /**
124
+ * Get all focusable toolbar items (buttons).
125
+ */
126
+ getToolbarButtons() {
127
+ const buttons = [];
128
+ const items = this.toolbar.querySelectorAll('.' + CSS_PREFIX + '__item');
129
+ items.forEach((item) => {
130
+ const button = item.querySelector('button');
131
+ if (button)
132
+ buttons.push(button);
133
+ });
134
+ // Add overflow toggle if present
135
+ const overflowToggle = this.toolbar.querySelector('.' + CSS_PREFIX + '__overflow-toggle');
136
+ if (overflowToggle)
137
+ buttons.push(overflowToggle);
138
+ return buttons;
139
+ }
140
+ /**
141
+ * Setup keyboard navigation for toolbar (WCAG toolbar pattern).
142
+ * - Left/Right arrows move between items
143
+ * - Home/End jump to first/last item
144
+ * - Escape closes menus
145
+ */
146
+ setupKeyboardNavigation() {
147
+ this.keydownHandler = (e) => {
148
+ // Check if focus is within toolbar
149
+ const activeElement = document.activeElement;
150
+ const isInToolbar = this.toolbar.contains(activeElement);
151
+ const isInOverflowMenu = this.overflowMenu.contains(activeElement);
152
+ const isInPinnedDropdown = this.pinnedDropdownMenu?.contains(activeElement);
153
+ // Handle Escape key - close menus
154
+ if (e.key === 'Escape') {
155
+ if (isInPinnedDropdown || this.pinnedDropdownMenu) {
156
+ e.preventDefault();
157
+ const lastFocused = this.toolbar.querySelector('[data-last-focused="true"]');
158
+ this.closeAllMenus(lastFocused || undefined);
159
+ return;
160
+ }
161
+ if (isInOverflowMenu || this.overflowMenu.style.display !== 'none') {
162
+ e.preventDefault();
163
+ const overflowToggle = this.toolbar.querySelector('.' + CSS_PREFIX + '__overflow-toggle');
164
+ this.closeAllMenus(overflowToggle || undefined);
165
+ return;
166
+ }
167
+ }
168
+ // Arrow key navigation within toolbar
169
+ if (isInToolbar && !isInOverflowMenu && !isInPinnedDropdown) {
170
+ const buttons = this.getToolbarButtons();
171
+ const currentIndex = buttons.indexOf(activeElement);
172
+ if (e.key === 'ArrowRight') {
173
+ e.preventDefault();
174
+ const nextIndex = (currentIndex + 1) % buttons.length;
175
+ buttons[nextIndex]?.focus();
176
+ }
177
+ else if (e.key === 'ArrowLeft') {
178
+ e.preventDefault();
179
+ const prevIndex = (currentIndex - 1 + buttons.length) %
180
+ buttons.length;
181
+ buttons[prevIndex]?.focus();
182
+ }
183
+ else if (e.key === 'Home') {
184
+ e.preventDefault();
185
+ buttons[0]?.focus();
186
+ }
187
+ else if (e.key === 'End') {
188
+ e.preventDefault();
189
+ buttons[buttons.length - 1]?.focus();
190
+ }
191
+ }
192
+ // Arrow key navigation within overflow menu
193
+ if (isInOverflowMenu) {
194
+ const focusableItems = Array.from(this.overflowMenu.querySelectorAll('button, [role="menuitem"], .' + CSS_PREFIX + '__overflow-item'));
195
+ const currentIndex = focusableItems.indexOf(activeElement);
196
+ if (e.key === 'ArrowDown') {
197
+ e.preventDefault();
198
+ const nextIndex = (currentIndex + 1) % focusableItems.length;
199
+ focusableItems[nextIndex]?.focus();
200
+ }
201
+ else if (e.key === 'ArrowUp') {
202
+ e.preventDefault();
203
+ const prevIndex = (currentIndex - 1 + focusableItems.length) %
204
+ focusableItems.length;
205
+ focusableItems[prevIndex]?.focus();
206
+ }
207
+ }
208
+ };
209
+ document.addEventListener('keydown', this.keydownHandler);
210
+ }
211
+ initializeTools() {
212
+ let orderIndex = 0;
213
+ this.content.forEach((group, groupIndex) => {
214
+ group.forEach((element) => {
215
+ const { dom, update } = element.render(this.editorView);
216
+ // For dropdowns, get the label from the element's options directly
217
+ let label;
218
+ const dropdown = element;
219
+ if (dropdown.options && dropdown.options.label) {
220
+ label = dropdown.options.label;
221
+ }
222
+ else {
223
+ label = this.extractLabel(dom);
224
+ }
225
+ const id = this.generateStableId(label, dom);
226
+ this.tools.push({
227
+ id,
228
+ label,
229
+ element,
230
+ order: orderIndex++,
231
+ groupIndex,
232
+ });
233
+ });
234
+ });
235
+ // Store the default order (before any localStorage modifications)
236
+ this.defaultOrder = this.tools.map((t) => t.id);
237
+ }
238
+ /**
239
+ * Generate a stable ID from a label by converting it to a kebab-case slug.
240
+ * Falls back to a hash if the label is empty or contains only special characters.
241
+ */
242
+ generateStableId(label, dom) {
243
+ // Try to use aria-label or data-id if available
244
+ const ariaLabel = dom.getAttribute('aria-label');
245
+ const dataId = dom.getAttribute('data-id');
246
+ if (dataId)
247
+ return `tool-${dataId}`;
248
+ const baseLabel = ariaLabel || label;
249
+ // Convert label to kebab-case slug
250
+ const slug = baseLabel
251
+ .toLowerCase()
252
+ .trim()
253
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
254
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
255
+ // If slug is empty, generate a simple hash from the label
256
+ if (!slug) {
257
+ const hash = this.simpleHash(baseLabel);
258
+ return `tool-${hash}`;
259
+ }
260
+ return `tool-${slug}`;
261
+ }
262
+ /**
263
+ * Simple hash function for generating stable IDs from strings.
264
+ */
265
+ simpleHash(str) {
266
+ let hash = 0;
267
+ for (let i = 0; i < str.length; i++) {
268
+ const char = str.charCodeAt(i);
269
+ hash = ((hash << 5) - hash) + char;
270
+ hash = hash & hash; // Convert to 32-bit integer
271
+ }
272
+ return Math.abs(hash).toString(36);
273
+ }
274
+ extractLabel(dom) {
275
+ // For menu buttons, prioritize the visible label text over title
276
+ const buttonText = dom.querySelector('span')?.textContent?.trim();
277
+ if (buttonText)
278
+ return buttonText;
279
+ // For dropdowns, check the dropdown label
280
+ const dropdownLabel = dom.querySelector('.kb-dropdown__label')?.textContent
281
+ ?.trim();
282
+ if (dropdownLabel)
283
+ return dropdownLabel;
284
+ // Try aria-label as fallback
285
+ const ariaLabel = dom.getAttribute('aria-label');
286
+ if (ariaLabel)
287
+ return ariaLabel;
288
+ // Try title attribute
289
+ const title = dom.getAttribute('title');
290
+ if (title)
291
+ return title;
292
+ // Fallback to looking at SVG title
293
+ const svgTitle = dom.querySelector('svg title')?.textContent?.trim();
294
+ if (svgTitle)
295
+ return svgTitle;
296
+ return 'Unknown Tool';
297
+ }
298
+ loadOrderState() {
299
+ try {
300
+ const saved = localStorage.getItem(STORAGE_KEY);
301
+ if (saved) {
302
+ const orderedIds = JSON.parse(saved);
303
+ // Apply saved order to tools
304
+ this.tools.forEach((tool) => {
305
+ const savedIndex = orderedIds.indexOf(tool.id);
306
+ if (savedIndex !== -1) {
307
+ tool.order = savedIndex;
308
+ }
309
+ else {
310
+ // New tools get placed at the end
311
+ tool.order = orderedIds.length + tool.order;
312
+ }
313
+ });
314
+ // Sort tools by their order
315
+ this.tools.sort((a, b) => a.order - b.order);
316
+ }
317
+ // If no saved state, keep the default order from initialization
318
+ }
319
+ catch (e) {
320
+ console.error('Failed to load order state:', e);
321
+ // Keep default order on error
322
+ }
323
+ }
324
+ saveOrderState() {
325
+ try {
326
+ const orderedIds = this.tools.map((t) => t.id);
327
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(orderedIds));
328
+ }
329
+ catch (e) {
330
+ console.error('Failed to save order state:', e);
331
+ }
332
+ }
333
+ /**
334
+ * Check if the current order differs from the default order.
335
+ */
336
+ hasCustomOrder() {
337
+ const currentOrder = this.tools.map((t) => t.id);
338
+ if (currentOrder.length !== this.defaultOrder.length)
339
+ return true;
340
+ return currentOrder.some((id, index) => id !== this.defaultOrder[index]);
341
+ }
342
+ /**
343
+ * Reset toolbar to default order, clearing any customizations.
344
+ */
345
+ resetToDefault() {
346
+ // Clear localStorage
347
+ try {
348
+ localStorage.removeItem(STORAGE_KEY);
349
+ }
350
+ catch (e) {
351
+ console.error('Failed to clear order state:', e);
352
+ }
353
+ // Reset tool order based on default order
354
+ this.tools.forEach((tool) => {
355
+ const defaultIndex = this.defaultOrder.indexOf(tool.id);
356
+ if (defaultIndex !== -1) {
357
+ tool.order = defaultIndex;
358
+ }
359
+ });
360
+ // Sort tools by their order
361
+ this.tools.sort((a, b) => a.order - b.order);
362
+ // Close overflow menu
363
+ this.closeAllMenus();
364
+ // Re-render
365
+ this.render();
366
+ }
367
+ /**
368
+ * Create a reset button element.
369
+ * @param iconOnly - If true, show only the icon (for toolbar); if false, show icon + text (for overflow menu)
370
+ */
371
+ createResetButton(iconOnly) {
372
+ const resetButton = document.createElement('button');
373
+ resetButton.type = 'button';
374
+ resetButton.classList.add(CSS_PREFIX + '__reset-button');
375
+ if (iconOnly) {
376
+ resetButton.classList.add(CSS_PREFIX + '__reset-button--icon-only');
377
+ // In toolbar, use button role
378
+ resetButton.setAttribute('role', 'button');
379
+ }
380
+ else {
381
+ // In overflow menu, use menuitem role
382
+ resetButton.setAttribute('role', 'menuitem');
383
+ }
384
+ resetButton.setAttribute('tabindex', '0');
385
+ resetButton.setAttribute('aria-label', 'Reset toolbar to default order');
386
+ resetButton.title = 'Reset toolbar to default order';
387
+ const svg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
388
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
389
+ <path d="M3 3v5h5"/>
390
+ </svg>`;
391
+ if (iconOnly) {
392
+ resetButton.innerHTML = svg;
393
+ }
394
+ else {
395
+ resetButton.innerHTML = `${svg}<span>Reset to Default</span>`;
396
+ }
397
+ resetButton.addEventListener('click', (e) => {
398
+ e.preventDefault();
399
+ e.stopPropagation();
400
+ this.resetToDefault();
401
+ });
402
+ resetButton.addEventListener('keydown', (e) => {
403
+ if (e.key === 'Enter' || e.key === ' ') {
404
+ e.preventDefault();
405
+ e.stopPropagation();
406
+ this.resetToDefault();
407
+ }
408
+ });
409
+ return resetButton;
410
+ }
411
+ showSubmenu(tool) {
412
+ const dropdown = tool.element;
413
+ if (!dropdown.content)
414
+ return;
415
+ // Extract sub-items from dropdown
416
+ const subItems = dropdown.content.map((element, index) => {
417
+ const { dom } = element.render(this.editorView);
418
+ // For nested dropdowns, get the label from options directly
419
+ let label;
420
+ const nestedDropdown = element;
421
+ if (nestedDropdown.options && nestedDropdown.options.label) {
422
+ label = nestedDropdown.options.label;
423
+ }
424
+ else {
425
+ label = this.extractLabel(dom);
426
+ }
427
+ return {
428
+ id: `${tool.id}-sub-${index}`,
429
+ label,
430
+ element,
431
+ isPinned: false,
432
+ };
433
+ });
434
+ // Save current state to stack
435
+ this.submenuStack.push({
436
+ title: tool.label,
437
+ tools: subItems,
438
+ });
439
+ // Re-render to show submenu
440
+ this.renderOverflowMenu();
441
+ }
442
+ goBack() {
443
+ // Remove last submenu from stack
444
+ this.submenuStack.pop();
445
+ // Re-render
446
+ this.renderOverflowMenu();
447
+ }
448
+ showPinnedDropdown(tool, triggerElement) {
449
+ // Close overflow menu if open (ensure only one menu is open at a time)
450
+ if (this.overflowMenu.style.display !== 'none') {
451
+ this.overflowMenu.style.display = 'none';
452
+ this.submenuStack = [];
453
+ const overflowToggle = this.toolbar.querySelector('.' + CSS_PREFIX + '__overflow-toggle');
454
+ if (overflowToggle) {
455
+ overflowToggle.setAttribute('aria-expanded', 'false');
456
+ }
457
+ }
458
+ // Close any existing pinned dropdown
459
+ if (this.pinnedDropdownMenu) {
460
+ this.pinnedDropdownMenu.remove();
461
+ this.pinnedDropdownMenu = null;
462
+ }
463
+ // Mark trigger as last focused for focus return on Escape
464
+ this.toolbar.querySelectorAll('[data-last-focused]').forEach((el) => {
465
+ el.removeAttribute('data-last-focused');
466
+ });
467
+ triggerElement.setAttribute('data-last-focused', 'true');
468
+ const dropdown = tool.element;
469
+ if (!dropdown.content)
470
+ return;
471
+ // Extract sub-items from dropdown
472
+ const subItems = dropdown.content.map((element, index) => {
473
+ const { dom } = element.render(this.editorView);
474
+ let label;
475
+ const nestedDropdown = element;
476
+ if (nestedDropdown.options && nestedDropdown.options.label) {
477
+ label = nestedDropdown.options.label;
478
+ }
479
+ else {
480
+ label = this.extractLabel(dom);
481
+ }
482
+ return {
483
+ id: `${tool.id}-sub-${index}`,
484
+ label,
485
+ element,
486
+ isPinned: false,
487
+ };
488
+ });
489
+ // Initialize stack with root menu
490
+ this.pinnedDropdownStack = [{
491
+ title: tool.label,
492
+ tools: subItems,
493
+ rootTool: tool,
494
+ }];
495
+ // Render the dropdown
496
+ this.renderPinnedDropdown(triggerElement);
497
+ }
498
+ pinnedDropdownGoBack() {
499
+ // Remove last item from stack
500
+ this.pinnedDropdownStack.pop();
501
+ // If stack is empty, close the dropdown
502
+ if (this.pinnedDropdownStack.length === 0) {
503
+ if (this.pinnedDropdownMenu) {
504
+ this.pinnedDropdownMenu.remove();
505
+ this.pinnedDropdownMenu = null;
506
+ }
507
+ return;
508
+ }
509
+ // Re-render with previous menu
510
+ const triggerElement = this.toolbar.querySelector(`[data-tool-id="${this.pinnedDropdownStack[0].rootTool.id}"]`);
511
+ if (triggerElement) {
512
+ this.renderPinnedDropdown(triggerElement);
513
+ }
514
+ }
515
+ pinnedDropdownShowSubmenu(tool, triggerElement) {
516
+ const dropdown = tool.element;
517
+ if (!dropdown.content)
518
+ return;
519
+ // Extract sub-items
520
+ const subItems = dropdown.content.map((element, index) => {
521
+ const { dom } = element.render(this.editorView);
522
+ let label;
523
+ const nestedDropdown = element;
524
+ if (nestedDropdown.options && nestedDropdown.options.label) {
525
+ label = nestedDropdown.options.label;
526
+ }
527
+ else {
528
+ label = this.extractLabel(dom);
529
+ }
530
+ return {
531
+ id: `${tool.id}-sub-${index}`,
532
+ label,
533
+ element,
534
+ isPinned: false,
535
+ };
536
+ });
537
+ // Add to stack
538
+ this.pinnedDropdownStack.push({
539
+ title: tool.label,
540
+ tools: subItems,
541
+ rootTool: this.pinnedDropdownStack[0].rootTool,
542
+ });
543
+ // Re-render
544
+ this.renderPinnedDropdown(triggerElement);
545
+ }
546
+ renderPinnedDropdown(triggerElement) {
547
+ // Remove existing dropdown
548
+ if (this.pinnedDropdownMenu) {
549
+ this.pinnedDropdownMenu.remove();
550
+ }
551
+ const currentLevel = this.pinnedDropdownStack[this.pinnedDropdownStack.length - 1];
552
+ const isSubmenu = this.pinnedDropdownStack.length > 1;
553
+ // Create dropdown menu
554
+ this.pinnedDropdownMenu = document.createElement('div');
555
+ this.pinnedDropdownMenu.classList.add(CSS_PREFIX + '__pinned-dropdown');
556
+ this.pinnedDropdownMenu.setAttribute('role', 'menu');
557
+ this.pinnedDropdownMenu.setAttribute('aria-label', currentLevel.title);
558
+ // Position it below the trigger element
559
+ const rect = triggerElement.getBoundingClientRect();
560
+ this.pinnedDropdownMenu.style.position = 'absolute';
561
+ this.pinnedDropdownMenu.style.top = `${rect.bottom + 4}px`;
562
+ this.pinnedDropdownMenu.style.left = `${rect.left}px`;
563
+ this.pinnedDropdownMenu.style.zIndex = '1000';
564
+ // Create scrollable content container
565
+ const overflowContent = document.createElement('div');
566
+ overflowContent.classList.add(CSS_PREFIX + '__overflow-content');
567
+ // Add back button if in submenu
568
+ if (isSubmenu) {
569
+ const header = document.createElement('div');
570
+ header.classList.add(CSS_PREFIX + '__overflow-submenu-header');
571
+ const backButton = document.createElement('button');
572
+ backButton.type = 'button';
573
+ backButton.classList.add(CSS_PREFIX + '__overflow-back-button');
574
+ backButton.setAttribute('aria-label', 'Go back to ' + currentLevel.title);
575
+ backButton.innerHTML =
576
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 19l-7-7 7-7"/></svg>';
577
+ backButton.addEventListener('click', (e) => {
578
+ e.preventDefault();
579
+ e.stopPropagation();
580
+ this.pinnedDropdownGoBack();
581
+ });
582
+ const title = document.createElement('span');
583
+ title.classList.add(CSS_PREFIX + '__overflow-submenu-title');
584
+ title.textContent = currentLevel.title;
585
+ header.appendChild(backButton);
586
+ header.appendChild(title);
587
+ overflowContent.appendChild(header);
588
+ }
589
+ // Render menu items
590
+ currentLevel.tools.forEach((subTool) => {
591
+ const isNestedDropdown = subTool.element.content !== undefined;
592
+ const wrapper = document.createElement('div');
593
+ wrapper.classList.add(CSS_PREFIX + '__overflow-item');
594
+ wrapper.setAttribute('data-tool-id', subTool.id);
595
+ wrapper.setAttribute('role', 'menuitem');
596
+ wrapper.setAttribute('tabindex', '0');
597
+ if (isNestedDropdown) {
598
+ // For nested dropdowns, show label and chevron
599
+ const label = document.createElement('span');
600
+ label.classList.add(CSS_PREFIX + '__overflow-item-label');
601
+ label.textContent = subTool.label;
602
+ wrapper.appendChild(label);
603
+ const chevron = document.createElement('span');
604
+ chevron.classList.add(CSS_PREFIX + '__overflow-item-chevron');
605
+ chevron.innerHTML =
606
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5l7 7-7 7"/></svg>';
607
+ wrapper.appendChild(chevron);
608
+ wrapper.addEventListener('click', (e) => {
609
+ e.preventDefault();
610
+ e.stopPropagation();
611
+ this.pinnedDropdownShowSubmenu(subTool, triggerElement);
612
+ });
613
+ }
614
+ else {
615
+ // Regular menu item
616
+ const { dom } = subTool.element.render(this.editorView);
617
+ // Hide button's internal label
618
+ const spans = dom.querySelectorAll('span');
619
+ spans.forEach((span) => {
620
+ const isInsideIcon = span.closest('.kb-icon') !== null;
621
+ const isDirectChild = span.parentElement === dom;
622
+ if (span.textContent && span.textContent.trim() &&
623
+ !isInsideIcon &&
624
+ isDirectChild &&
625
+ !span.querySelector('svg') &&
626
+ !span.classList.contains('kb-icon')) {
627
+ span.style.display = 'none';
628
+ }
629
+ });
630
+ // Add label
631
+ const label = document.createElement('span');
632
+ label.classList.add(CSS_PREFIX + '__overflow-item-label');
633
+ label.textContent = subTool.label;
634
+ wrapper.appendChild(dom);
635
+ wrapper.appendChild(label);
636
+ // Make wrapper clickable
637
+ wrapper.addEventListener('mousedown', (e) => {
638
+ if (e.target !== dom) {
639
+ e.preventDefault();
640
+ const mousedownEvent = new MouseEvent('mousedown', {
641
+ bubbles: true,
642
+ cancelable: true,
643
+ view: dntShim.dntGlobalThis,
644
+ });
645
+ dom.dispatchEvent(mousedownEvent);
646
+ }
647
+ // Close dropdown after click
648
+ if (this.pinnedDropdownMenu) {
649
+ this.pinnedDropdownMenu.remove();
650
+ this.pinnedDropdownMenu = null;
651
+ this.pinnedDropdownStack = [];
652
+ }
653
+ });
654
+ }
655
+ overflowContent.appendChild(wrapper);
656
+ });
657
+ // Add content to dropdown
658
+ this.pinnedDropdownMenu.appendChild(overflowContent);
659
+ // Add to document
660
+ document.body.appendChild(this.pinnedDropdownMenu);
661
+ // Close on click outside
662
+ const doc = this.editorView.dom.ownerDocument || document;
663
+ setTimeout(() => {
664
+ if (this.closePinnedDropdownHandler) {
665
+ doc.removeEventListener('click', this.closePinnedDropdownHandler);
666
+ }
667
+ this.closePinnedDropdownHandler = (e) => {
668
+ const target = e.target;
669
+ if (this.pinnedDropdownMenu &&
670
+ !this.pinnedDropdownMenu.contains(target) &&
671
+ !triggerElement.contains(target)) {
672
+ this.pinnedDropdownMenu.remove();
673
+ this.pinnedDropdownMenu = null;
674
+ this.pinnedDropdownStack = [];
675
+ if (this.closePinnedDropdownHandler) {
676
+ doc.removeEventListener('click', this.closePinnedDropdownHandler);
677
+ }
678
+ }
679
+ };
680
+ doc.addEventListener('click', this.closePinnedDropdownHandler);
681
+ }, 0);
682
+ }
683
+ renderOverflowMenu() {
684
+ // Clear overflow menu
685
+ this.overflowMenu.innerHTML = '';
686
+ // Set ARIA attributes for overflow menu
687
+ this.overflowMenu.setAttribute('role', 'menu');
688
+ this.overflowMenu.setAttribute('aria-label', 'More tools');
689
+ // Check if we're showing a submenu
690
+ const isSubmenu = this.submenuStack.length > 0;
691
+ const currentSubmenu = isSubmenu
692
+ ? this.submenuStack[this.submenuStack.length - 1]
693
+ : null;
694
+ // Create scrollable content container - use grid layout for Google Docs style
695
+ const overflowContent = document.createElement('div');
696
+ overflowContent.classList.add(CSS_PREFIX + '__overflow-content');
697
+ // Apply flex-wrap style for Google Docs-like icon layout
698
+ if (!isSubmenu) {
699
+ overflowContent.classList.add(CSS_PREFIX + '__overflow-content--grid');
700
+ }
701
+ if (isSubmenu && currentSubmenu) {
702
+ // Render submenu header with back button
703
+ const header = document.createElement('div');
704
+ header.classList.add(CSS_PREFIX + '__overflow-submenu-header');
705
+ const backButton = document.createElement('button');
706
+ backButton.type = 'button';
707
+ backButton.classList.add(CSS_PREFIX + '__overflow-back-button');
708
+ backButton.setAttribute('aria-label', 'Go back to previous menu');
709
+ backButton.innerHTML =
710
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 19l-7-7 7-7"/></svg>';
711
+ backButton.addEventListener('click', (e) => {
712
+ e.preventDefault();
713
+ e.stopPropagation();
714
+ this.goBack();
715
+ });
716
+ const title = document.createElement('span');
717
+ title.classList.add(CSS_PREFIX + '__overflow-submenu-title');
718
+ title.textContent = currentSubmenu.title;
719
+ header.appendChild(backButton);
720
+ header.appendChild(title);
721
+ overflowContent.appendChild(header);
722
+ // Render submenu items
723
+ currentSubmenu.tools.forEach((tool) => {
724
+ // Check if this is a nested dropdown
725
+ const isDropdown = tool.element.content !== undefined;
726
+ const wrapper = document.createElement('div');
727
+ wrapper.classList.add(CSS_PREFIX + '__overflow-item');
728
+ wrapper.setAttribute('role', 'menuitem');
729
+ wrapper.setAttribute('tabindex', '0');
730
+ if (isDropdown) {
731
+ // For nested dropdowns, just show label and chevron (no icon/button)
732
+ wrapper.setAttribute('aria-haspopup', 'true');
733
+ // Add label
734
+ const label = document.createElement('span');
735
+ label.classList.add(CSS_PREFIX + '__overflow-item-label');
736
+ label.textContent = tool.label;
737
+ wrapper.appendChild(label);
738
+ // Add chevron to indicate submenu
739
+ const chevron = document.createElement('span');
740
+ chevron.classList.add(CSS_PREFIX + '__overflow-item-chevron');
741
+ chevron.setAttribute('aria-hidden', 'true');
742
+ chevron.innerHTML =
743
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5l7 7-7 7"/></svg>';
744
+ wrapper.appendChild(chevron);
745
+ // Click and keyboard handler for nested dropdown - navigate deeper
746
+ const handleActivate = (e) => {
747
+ e.preventDefault();
748
+ e.stopPropagation();
749
+ this.showSubmenu(tool);
750
+ };
751
+ wrapper.addEventListener('click', handleActivate);
752
+ wrapper.addEventListener('keydown', (e) => {
753
+ if (e.key === 'Enter' || e.key === ' ') {
754
+ handleActivate(e);
755
+ }
756
+ });
757
+ }
758
+ else {
759
+ // Regular menu item
760
+ const { dom, update } = tool.element.render(this.editorView);
761
+ // Hide the button's internal label to avoid duplication
762
+ const internalLabel = dom.querySelector('span');
763
+ if (internalLabel) {
764
+ internalLabel.style.display = 'none';
765
+ }
766
+ // Add our own label to the item
767
+ const label = document.createElement('span');
768
+ label.classList.add(CSS_PREFIX + '__overflow-item-label');
769
+ label.textContent = tool.label;
770
+ // Restructure the DOM to show icon + label
771
+ wrapper.appendChild(dom);
772
+ wrapper.appendChild(label);
773
+ // Make the entire wrapper clickable by dispatching mousedown to the button
774
+ wrapper.addEventListener('mousedown', (e) => {
775
+ if (e.target !== dom) {
776
+ e.preventDefault();
777
+ const mousedownEvent = new MouseEvent('mousedown', {
778
+ bubbles: true,
779
+ cancelable: true,
780
+ view: dntShim.dntGlobalThis,
781
+ });
782
+ dom.dispatchEvent(mousedownEvent);
783
+ }
784
+ });
785
+ // Add keyboard support for Enter/Space
786
+ wrapper.addEventListener('keydown', (e) => {
787
+ if (e.key === 'Enter' || e.key === ' ') {
788
+ e.preventDefault();
789
+ const mousedownEvent = new MouseEvent('mousedown', {
790
+ bubbles: true,
791
+ cancelable: true,
792
+ view: dntShim.dntGlobalThis,
793
+ });
794
+ dom.dispatchEvent(mousedownEvent);
795
+ }
796
+ });
797
+ }
798
+ overflowContent.appendChild(wrapper);
799
+ });
800
+ }
801
+ else {
802
+ // Render main overflow menu - use the stored overflow tools from render()
803
+ const allOverflowItems = this.currentOverflowTools;
804
+ // Render all overflow tools as icon grid (Google Docs style)
805
+ allOverflowItems.forEach((tool) => {
806
+ // Skip tools with empty or invalid labels
807
+ if (!tool.label || tool.label.trim() === '' ||
808
+ tool.label === 'Unknown Tool') {
809
+ return;
810
+ }
811
+ // Check if this is a dropdown with sub-items
812
+ const isDropdown = tool.element.content !== undefined;
813
+ const wrapper = document.createElement('div');
814
+ wrapper.classList.add(CSS_PREFIX + '__overflow-item');
815
+ wrapper.classList.add(CSS_PREFIX + '__overflow-item--grid');
816
+ wrapper.setAttribute('title', tool.label); // Tooltip on hover
817
+ wrapper.setAttribute('role', 'menuitem');
818
+ wrapper.setAttribute('tabindex', '0');
819
+ wrapper.setAttribute('aria-label', tool.label);
820
+ if (isDropdown) {
821
+ // For dropdowns, create a custom button with dropdown indicator
822
+ wrapper.classList.add(CSS_PREFIX + '__overflow-item--dropdown');
823
+ wrapper.setAttribute('aria-haspopup', 'true');
824
+ const button = document.createElement('button');
825
+ button.type = 'button';
826
+ button.classList.add('kb-menu__button');
827
+ button.setAttribute('tabindex', '-1'); // Parent is focusable
828
+ button.setAttribute('aria-hidden', 'true');
829
+ // Get the icon from the dropdown's options
830
+ const dropdown = tool.element;
831
+ if (dropdown.options?.icon) {
832
+ const icon = getIcon(this.root, dropdown.options.icon);
833
+ button.appendChild(icon);
834
+ }
835
+ wrapper.appendChild(button);
836
+ // Add small dropdown indicator
837
+ const indicator = document.createElement('span');
838
+ indicator.classList.add(CSS_PREFIX + '__overflow-item-indicator');
839
+ indicator.setAttribute('aria-hidden', 'true');
840
+ // CSS creates the triangle using borders
841
+ wrapper.appendChild(indicator);
842
+ // Click handler for dropdown - navigate to submenu
843
+ wrapper.addEventListener('click', (e) => {
844
+ e.preventDefault();
845
+ e.stopPropagation();
846
+ this.showSubmenu(tool);
847
+ });
848
+ // Add keyboard support for Enter/Space
849
+ wrapper.addEventListener('keydown', (e) => {
850
+ if (e.key === 'Enter' || e.key === ' ') {
851
+ e.preventDefault();
852
+ e.stopPropagation();
853
+ this.showSubmenu(tool);
854
+ }
855
+ });
856
+ }
857
+ else {
858
+ // Regular menu item - show icon only with tooltip
859
+ const { dom, update } = tool.element.render(this.editorView);
860
+ // Hide any text labels, keep only icons
861
+ const spans = dom.querySelectorAll('span');
862
+ spans.forEach((span) => {
863
+ const isInsideIcon = span.closest('.kb-icon') !== null;
864
+ if (!isInsideIcon && !span.querySelector('svg')) {
865
+ span.style.display = 'none';
866
+ }
867
+ });
868
+ // Disable pointer events on the button itself - wrapper handles clicks
869
+ dom.style.pointerEvents = 'none';
870
+ wrapper.appendChild(dom);
871
+ // Store the button DOM for later use (click after drag timeout)
872
+ wrapper.dataset.hasButton = 'true';
873
+ wrapper._buttonDom =
874
+ dom;
875
+ // Add keyboard support for Enter/Space
876
+ wrapper.addEventListener('keydown', (e) => {
877
+ if (e.key === 'Enter' || e.key === ' ') {
878
+ e.preventDefault();
879
+ // Close the overflow menu FIRST
880
+ this.closeAllMenus();
881
+ // Then trigger the action after menu is closed
882
+ requestAnimationFrame(() => {
883
+ const menuItem = tool.element;
884
+ if (menuItem.spec && typeof menuItem.spec.run === 'function') {
885
+ menuItem.spec.run(this.editorView.state, this.editorView.dispatch);
886
+ }
887
+ });
888
+ }
889
+ });
890
+ }
891
+ // Add drag handlers for overflow items (only in main menu, not submenus)
892
+ if (!isSubmenu) {
893
+ this.setupOverflowDragHandlers(wrapper, tool);
894
+ }
895
+ overflowContent.appendChild(wrapper);
896
+ });
897
+ }
898
+ // Add the scrollable content to overflow menu
899
+ this.overflowMenu.appendChild(overflowContent);
900
+ // Add "Reset to Default" button if order has been customized (only in main menu, not submenus)
901
+ if (!isSubmenu && this.hasCustomOrder()) {
902
+ const footer = document.createElement('div');
903
+ footer.classList.add(CSS_PREFIX + '__overflow-footer');
904
+ const resetButton = this.createResetButton(false);
905
+ footer.appendChild(resetButton);
906
+ this.overflowMenu.appendChild(footer);
907
+ }
908
+ }
909
+ render() {
910
+ // Skip render if currently dragging (to avoid interference)
911
+ if (this.isDragging)
912
+ return;
913
+ // Clear toolbar and overflow menu
914
+ this.toolbar.innerHTML = '';
915
+ this.overflowMenu.innerHTML = '';
916
+ // Get available width for toolbar items
917
+ const toolbarWidth = this.toolbar.offsetWidth || this.wrapper.offsetWidth;
918
+ const availableWidth = toolbarWidth - OVERFLOW_BUTTON_WIDTH - 16; // Reserve space for overflow button + padding
919
+ // First pass: render all items to measure their widths
920
+ const renderedItems = [];
921
+ this.tools.forEach((tool) => {
922
+ const wrapper = document.createElement('span');
923
+ wrapper.classList.add(CSS_PREFIX + '__item');
924
+ wrapper.setAttribute('data-tool-id', tool.id);
925
+ wrapper.setAttribute('draggable', 'false');
926
+ const isDropdown = tool.element.content !== undefined;
927
+ if (isDropdown) {
928
+ const { dom } = tool.element.render(this.editorView);
929
+ const dropdownMenu = dom.querySelector('.kb-dropdown__content');
930
+ if (dropdownMenu) {
931
+ dropdownMenu.style.display = 'none';
932
+ }
933
+ wrapper.appendChild(dom);
934
+ }
935
+ else {
936
+ const { dom } = tool.element.render(this.editorView);
937
+ wrapper.appendChild(dom);
938
+ }
939
+ // Temporarily add to toolbar to measure
940
+ this.toolbar.appendChild(wrapper);
941
+ const width = wrapper.getBoundingClientRect().width;
942
+ renderedItems.push({ tool, wrapper, width });
943
+ });
944
+ // Second pass: determine which items fit
945
+ // Account for gaps between items (4px gap) and separators between groups
946
+ const GAP_SIZE = 4;
947
+ const SEPARATOR_WIDTH = 9; // 1px width + 4px margin on each side
948
+ let usedWidth = 0;
949
+ let previousGroupIndex = undefined;
950
+ const visibleItems = [];
951
+ const overflowItems = [];
952
+ for (const item of renderedItems) {
953
+ // Calculate extra width needed for gap and potentially a separator
954
+ let extraWidth = 0;
955
+ if (visibleItems.length > 0) {
956
+ extraWidth += GAP_SIZE; // Gap between items
957
+ // Add separator width if group is changing
958
+ if (previousGroupIndex !== undefined &&
959
+ item.tool.groupIndex !== previousGroupIndex) {
960
+ extraWidth += SEPARATOR_WIDTH + GAP_SIZE; // Separator + its gap
961
+ }
962
+ }
963
+ if (usedWidth + extraWidth + item.width <= availableWidth) {
964
+ visibleItems.push(item);
965
+ usedWidth += extraWidth + item.width;
966
+ previousGroupIndex = item.tool.groupIndex;
967
+ }
968
+ else {
969
+ overflowItems.push(item);
970
+ }
971
+ }
972
+ // Clear toolbar again and re-render only visible items
973
+ this.toolbar.innerHTML = '';
974
+ // Track previous group index for inserting separators between groups
975
+ let prevGroupIndex = undefined;
976
+ // Render visible tools in toolbar with proper event handlers
977
+ visibleItems.forEach(({ tool, wrapper }) => {
978
+ // Insert separator if group changed (and not first item)
979
+ if (prevGroupIndex !== undefined &&
980
+ tool.groupIndex !== prevGroupIndex) {
981
+ const separator = document.createElement('div');
982
+ separator.classList.add(CSS_PREFIX + '__separator');
983
+ this.toolbar.appendChild(separator);
984
+ }
985
+ prevGroupIndex = tool.groupIndex;
986
+ const isDropdown = tool.element.content !== undefined;
987
+ if (isDropdown) {
988
+ const button = wrapper.querySelector('button');
989
+ const label = wrapper.querySelector('.kb-dropdown__label');
990
+ const clickHandler = (e) => {
991
+ // Don't open dropdown if we were dragging
992
+ if (this.isDragging) {
993
+ e.preventDefault();
994
+ e.stopPropagation();
995
+ e.stopImmediatePropagation();
996
+ return;
997
+ }
998
+ e.preventDefault();
999
+ e.stopPropagation();
1000
+ e.stopImmediatePropagation();
1001
+ this.showPinnedDropdown(tool, wrapper);
1002
+ };
1003
+ // Mousedown handler - prevents built-in dropdown from opening
1004
+ // but allows our drag handler on the wrapper to work
1005
+ const mousedownHandler = (e) => {
1006
+ // Stop the built-in dropdown's mousedown handler from firing
1007
+ // This prevents the double-menu issue
1008
+ e.stopImmediatePropagation();
1009
+ // If dragging, also stop propagation
1010
+ if (this.isDragging) {
1011
+ e.stopPropagation();
1012
+ }
1013
+ // Note: We don't call preventDefault() so the wrapper's
1014
+ // capture-phase drag handler can still work
1015
+ };
1016
+ if (button) {
1017
+ button.addEventListener('click', clickHandler, { capture: true });
1018
+ button.addEventListener('mousedown', mousedownHandler, {
1019
+ capture: true,
1020
+ });
1021
+ }
1022
+ if (label) {
1023
+ label.addEventListener('click', clickHandler, { capture: true });
1024
+ label.addEventListener('mousedown', mousedownHandler, {
1025
+ capture: true,
1026
+ });
1027
+ }
1028
+ }
1029
+ // Add drag-and-drop handlers
1030
+ this.setupDragHandlers(wrapper, tool);
1031
+ this.toolbar.appendChild(wrapper);
1032
+ });
1033
+ // Store overflow tools for the overflow menu
1034
+ this.currentOverflowTools = overflowItems.map((item) => item.tool);
1035
+ // Add separator before overflow button if there are overflow items
1036
+ if (this.currentOverflowTools.length > 0) {
1037
+ const separator = document.createElement('div');
1038
+ separator.classList.add(CSS_PREFIX + '__separator');
1039
+ this.toolbar.appendChild(separator);
1040
+ }
1041
+ // Add overflow toggle button if there are overflow items
1042
+ if (this.currentOverflowTools.length > 0) {
1043
+ const overflowToggle = document.createElement('button');
1044
+ overflowToggle.type = 'button';
1045
+ overflowToggle.className = CSS_PREFIX + '__overflow-toggle';
1046
+ overflowToggle.setAttribute('aria-haspopup', 'menu');
1047
+ overflowToggle.setAttribute('aria-expanded', 'false');
1048
+ overflowToggle.setAttribute('aria-label', 'More tools');
1049
+ overflowToggle.title = 'More tools';
1050
+ overflowToggle.innerHTML = `
1051
+ <svg viewBox="0 0 24 24" aria-hidden="true">
1052
+ <circle cx="5" cy="12" r="2"/>
1053
+ <circle cx="12" cy="12" r="2"/>
1054
+ <circle cx="19" cy="12" r="2"/>
1055
+ </svg>
1056
+ `;
1057
+ overflowToggle.addEventListener('click', (e) => {
1058
+ e.preventDefault();
1059
+ e.stopPropagation();
1060
+ // Close any open pinned dropdown first (only one menu open at a time)
1061
+ if (this.pinnedDropdownMenu) {
1062
+ this.pinnedDropdownMenu.remove();
1063
+ this.pinnedDropdownMenu = null;
1064
+ this.pinnedDropdownStack = [];
1065
+ }
1066
+ const isOpen = this.overflowMenu.style.display !== 'none';
1067
+ this.overflowMenu.style.display = isOpen ? 'none' : 'block';
1068
+ overflowToggle.setAttribute('aria-expanded', String(!isOpen));
1069
+ // Add/remove close handler based on open state
1070
+ const doc = this.editorView.dom.ownerDocument || document;
1071
+ if (!isOpen) {
1072
+ // Opening - reset submenu stack to show main menu
1073
+ this.submenuStack = [];
1074
+ this.renderOverflowMenu();
1075
+ // Opening - add close handler after a short delay
1076
+ setTimeout(() => {
1077
+ if (this.closeOverflowHandler) {
1078
+ doc.removeEventListener('click', this.closeOverflowHandler);
1079
+ }
1080
+ this.closeOverflowHandler = (e) => {
1081
+ // Don't interfere with editor clicks
1082
+ const target = e.target;
1083
+ const editorDom = this.editorView.dom;
1084
+ // Check if click is inside editor
1085
+ if (editorDom.contains(target)) {
1086
+ return; // Let editor handle it
1087
+ }
1088
+ if (!this.overflowMenu.contains(target) &&
1089
+ !this.toolbar.contains(target)) {
1090
+ this.overflowMenu.style.display = 'none';
1091
+ overflowToggle.setAttribute('aria-expanded', 'false');
1092
+ // Clear submenu stack when closing
1093
+ this.submenuStack = [];
1094
+ if (this.closeOverflowHandler) {
1095
+ doc.removeEventListener('click', this.closeOverflowHandler);
1096
+ }
1097
+ }
1098
+ };
1099
+ doc.addEventListener('click', this.closeOverflowHandler);
1100
+ }, 0);
1101
+ }
1102
+ else {
1103
+ // Closing - remove close handler and clear submenu stack
1104
+ if (this.closeOverflowHandler) {
1105
+ doc.removeEventListener('click', this.closeOverflowHandler);
1106
+ }
1107
+ this.submenuStack = [];
1108
+ }
1109
+ });
1110
+ this.toolbar.appendChild(overflowToggle);
1111
+ }
1112
+ else if (this.hasCustomOrder()) {
1113
+ // No overflow items but order has been customized - show reset button in toolbar
1114
+ const separator = document.createElement('div');
1115
+ separator.classList.add(CSS_PREFIX + '__separator');
1116
+ this.toolbar.appendChild(separator);
1117
+ const resetButton = this.createResetButton(true);
1118
+ this.toolbar.appendChild(resetButton);
1119
+ }
1120
+ // Render overflow menu content
1121
+ this.renderOverflowMenu();
1122
+ }
1123
+ setupDragHandlers(wrapper, tool) {
1124
+ let startX = 0;
1125
+ let startY = 0;
1126
+ const onMouseDown = (e) => {
1127
+ // Only handle left mouse button
1128
+ if (e.button !== 0)
1129
+ return;
1130
+ startX = e.clientX;
1131
+ startY = e.clientY;
1132
+ // Start a timer for delayed drag initiation
1133
+ this.dragStartTimer = setTimeout(() => {
1134
+ this.startDrag(wrapper, tool, e);
1135
+ }, DRAG_START_DELAY);
1136
+ // Listen for mouse up to cancel if released early
1137
+ const onMouseUp = () => {
1138
+ if (this.dragStartTimer) {
1139
+ clearTimeout(this.dragStartTimer);
1140
+ this.dragStartTimer = null;
1141
+ }
1142
+ document.removeEventListener('mouseup', onMouseUp);
1143
+ document.removeEventListener('mousemove', onEarlyMove);
1144
+ };
1145
+ // If mouse moves too much before timer, cancel drag start
1146
+ const onEarlyMove = (moveEvent) => {
1147
+ const dx = Math.abs(moveEvent.clientX - startX);
1148
+ const dy = Math.abs(moveEvent.clientY - startY);
1149
+ // Allow some tolerance for slight movement
1150
+ if (dx > 5 || dy > 5) {
1151
+ if (this.dragStartTimer) {
1152
+ clearTimeout(this.dragStartTimer);
1153
+ this.dragStartTimer = null;
1154
+ }
1155
+ }
1156
+ };
1157
+ document.addEventListener('mouseup', onMouseUp);
1158
+ document.addEventListener('mousemove', onEarlyMove);
1159
+ };
1160
+ // Use capture phase to ensure we get events from child elements (like dropdown buttons)
1161
+ wrapper.addEventListener('mousedown', onMouseDown, { capture: true });
1162
+ }
1163
+ /**
1164
+ * Set up drag handlers for overflow menu items.
1165
+ * Long-hold initiates drag to toolbar, short click activates the item.
1166
+ */
1167
+ setupOverflowDragHandlers(wrapper, tool) {
1168
+ let startX = 0;
1169
+ let startY = 0;
1170
+ let dragInitiated = false;
1171
+ const onMouseDown = (e) => {
1172
+ // Only handle left mouse button
1173
+ if (e.button !== 0)
1174
+ return;
1175
+ startX = e.clientX;
1176
+ startY = e.clientY;
1177
+ dragInitiated = false;
1178
+ // Prevent default to stop button action from firing immediately
1179
+ e.preventDefault();
1180
+ // Start a timer for delayed drag initiation
1181
+ this.dragStartTimer = setTimeout(() => {
1182
+ dragInitiated = true;
1183
+ this.startOverflowDrag(wrapper, tool, e);
1184
+ }, DRAG_START_DELAY);
1185
+ // Listen for mouse up to cancel if released early (allows normal click)
1186
+ const onMouseUp = () => {
1187
+ if (this.dragStartTimer) {
1188
+ clearTimeout(this.dragStartTimer);
1189
+ this.dragStartTimer = null;
1190
+ }
1191
+ // If drag wasn't initiated, this was a short click - trigger the button action
1192
+ if (!dragInitiated) {
1193
+ // Close the overflow menu FIRST to prevent it from capturing modal events
1194
+ this.closeAllMenus();
1195
+ // Then trigger the action after the menu is closed
1196
+ // Use requestAnimationFrame to ensure DOM is updated
1197
+ requestAnimationFrame(() => {
1198
+ // Call the spec's run method directly instead of dispatching DOM events
1199
+ const menuItem = tool.element;
1200
+ if (menuItem.spec && typeof menuItem.spec.run === 'function') {
1201
+ menuItem.spec.run(this.editorView.state, this.editorView.dispatch);
1202
+ }
1203
+ });
1204
+ }
1205
+ document.removeEventListener('mouseup', onMouseUp);
1206
+ document.removeEventListener('mousemove', onEarlyMove);
1207
+ };
1208
+ // If mouse moves too much before timer, cancel drag start
1209
+ const onEarlyMove = (moveEvent) => {
1210
+ const dx = Math.abs(moveEvent.clientX - startX);
1211
+ const dy = Math.abs(moveEvent.clientY - startY);
1212
+ // Allow some tolerance for slight movement
1213
+ if (dx > 5 || dy > 5) {
1214
+ if (this.dragStartTimer) {
1215
+ clearTimeout(this.dragStartTimer);
1216
+ this.dragStartTimer = null;
1217
+ }
1218
+ }
1219
+ };
1220
+ document.addEventListener('mouseup', onMouseUp);
1221
+ document.addEventListener('mousemove', onEarlyMove);
1222
+ };
1223
+ // Use capture phase and stop propagation to prevent other handlers
1224
+ wrapper.addEventListener('mousedown', onMouseDown, { capture: true });
1225
+ }
1226
+ /**
1227
+ * Start dragging an item from the overflow menu to the toolbar.
1228
+ */
1229
+ startOverflowDrag(wrapper, tool, e) {
1230
+ this.isDragging = true;
1231
+ this.isDraggingFromOverflow = true;
1232
+ this.draggedItem = tool;
1233
+ this.dragSourceWrapper = wrapper;
1234
+ // Add dragging class to wrapper
1235
+ wrapper.classList.add(CSS_PREFIX + '__overflow-item--dragging');
1236
+ this.wrapper.classList.add(CSS_PREFIX + '__wrapper--dragging');
1237
+ // Close the overflow menu to allow dropping on toolbar
1238
+ this.closeOverflowMenu();
1239
+ // Create a ghost element for visual feedback
1240
+ const rect = wrapper.getBoundingClientRect();
1241
+ this.dragGhost = wrapper.cloneNode(true);
1242
+ this.dragGhost.classList.add(CSS_PREFIX + '__drag-ghost');
1243
+ this.dragGhost.style.position = 'fixed';
1244
+ this.dragGhost.style.left = `${rect.left}px`;
1245
+ this.dragGhost.style.top = `${rect.top}px`;
1246
+ this.dragGhost.style.width = `${rect.width}px`;
1247
+ this.dragGhost.style.height = `${rect.height}px`;
1248
+ this.dragGhost.style.pointerEvents = 'none';
1249
+ this.dragGhost.style.zIndex = '10000';
1250
+ document.body.appendChild(this.dragGhost);
1251
+ // Create a placeholder to show where the item will be dropped
1252
+ this.dragPlaceholder = document.createElement('span');
1253
+ this.dragPlaceholder.classList.add(CSS_PREFIX + '__drop-placeholder');
1254
+ const onMouseMove = (moveEvent) => {
1255
+ if (!this.isDragging || !this.dragGhost)
1256
+ return;
1257
+ // Update ghost position
1258
+ const ghostRect = this.dragGhost.getBoundingClientRect();
1259
+ this.dragGhost.style.left = `${moveEvent.clientX - ghostRect.width / 2}px`;
1260
+ this.dragGhost.style.top = `${moveEvent.clientY - ghostRect.height / 2}px`;
1261
+ // Check if we're over the toolbar area
1262
+ const toolbarRect = this.toolbar.getBoundingClientRect();
1263
+ const isOverToolbar = moveEvent.clientY >= toolbarRect.top &&
1264
+ moveEvent.clientY <= toolbarRect.bottom &&
1265
+ moveEvent.clientX >= toolbarRect.left &&
1266
+ moveEvent.clientX <= toolbarRect.right;
1267
+ // Find the toolbar item we're hovering over
1268
+ const toolbarItems = Array.from(this.toolbar.querySelectorAll('.' + CSS_PREFIX + '__item'));
1269
+ let insertBefore = null;
1270
+ if (isOverToolbar) {
1271
+ for (let i = 0; i < toolbarItems.length; i++) {
1272
+ const item = toolbarItems[i];
1273
+ const itemRect = item.getBoundingClientRect();
1274
+ const itemCenter = itemRect.left + itemRect.width / 2;
1275
+ if (moveEvent.clientX < itemCenter && insertBefore === null) {
1276
+ insertBefore = item;
1277
+ }
1278
+ }
1279
+ }
1280
+ // Remove existing placeholder
1281
+ if (this.dragPlaceholder && this.dragPlaceholder.parentNode) {
1282
+ this.dragPlaceholder.remove();
1283
+ }
1284
+ // Only show placeholder if over toolbar
1285
+ if (isOverToolbar && this.dragPlaceholder) {
1286
+ if (insertBefore) {
1287
+ insertBefore.parentNode?.insertBefore(this.dragPlaceholder, insertBefore);
1288
+ }
1289
+ else {
1290
+ // Insert at end (before separator or overflow toggle)
1291
+ const separator = this.toolbar.querySelector('.' + CSS_PREFIX + '__separator');
1292
+ if (separator) {
1293
+ separator.parentNode?.insertBefore(this.dragPlaceholder, separator);
1294
+ }
1295
+ else {
1296
+ const overflowToggle = this.toolbar.querySelector('.' + CSS_PREFIX + '__overflow-toggle');
1297
+ if (overflowToggle) {
1298
+ overflowToggle.parentNode?.insertBefore(this.dragPlaceholder, overflowToggle);
1299
+ }
1300
+ else {
1301
+ this.toolbar.appendChild(this.dragPlaceholder);
1302
+ }
1303
+ }
1304
+ }
1305
+ }
1306
+ };
1307
+ const onMouseUp = (_upEvent) => {
1308
+ if (!this.isDragging)
1309
+ return;
1310
+ // Calculate new position based on placeholder
1311
+ if (this.dragPlaceholder && this.dragPlaceholder.parentNode) {
1312
+ // Count toolbar items before the placeholder to determine insert position
1313
+ let insertAtIndex = 0;
1314
+ for (let i = 0; i < this.toolbar.children.length; i++) {
1315
+ const child = this.toolbar.children[i];
1316
+ if (child === this.dragPlaceholder)
1317
+ break;
1318
+ if (child.classList.contains(CSS_PREFIX + '__item')) {
1319
+ insertAtIndex++;
1320
+ }
1321
+ }
1322
+ // Find the tool's current index in the tools array
1323
+ const currentToolIndex = this.tools.indexOf(tool);
1324
+ if (currentToolIndex !== -1) {
1325
+ // Remove the tool from its current position
1326
+ this.tools.splice(currentToolIndex, 1);
1327
+ // Insert at new position (at the target index)
1328
+ this.tools.splice(insertAtIndex, 0, tool);
1329
+ // Update order values
1330
+ this.tools.forEach((t, i) => {
1331
+ t.order = i;
1332
+ });
1333
+ // Save to localStorage
1334
+ this.saveOrderState();
1335
+ }
1336
+ }
1337
+ // Clean up
1338
+ this.cleanupOverflowDrag();
1339
+ document.removeEventListener('mousemove', onMouseMove);
1340
+ document.removeEventListener('mouseup', onMouseUp);
1341
+ // Re-render after a short delay
1342
+ setTimeout(() => {
1343
+ this.render();
1344
+ }, 0);
1345
+ };
1346
+ document.addEventListener('mousemove', onMouseMove);
1347
+ document.addEventListener('mouseup', onMouseUp);
1348
+ }
1349
+ /**
1350
+ * Close the overflow menu programmatically.
1351
+ */
1352
+ closeOverflowMenu() {
1353
+ if (this.overflowMenu) {
1354
+ this.overflowMenu.style.display = 'none';
1355
+ }
1356
+ // Update the overflow toggle button state
1357
+ const overflowToggle = this.toolbar.querySelector('.' + CSS_PREFIX + '__overflow-toggle');
1358
+ if (overflowToggle) {
1359
+ overflowToggle.setAttribute('aria-expanded', 'false');
1360
+ }
1361
+ // Clear submenu stack
1362
+ this.submenuStack = [];
1363
+ // Remove close handler
1364
+ const doc = this.editorView.dom.ownerDocument || document;
1365
+ if (this.closeOverflowHandler) {
1366
+ doc.removeEventListener('click', this.closeOverflowHandler);
1367
+ this.closeOverflowHandler = null;
1368
+ }
1369
+ }
1370
+ cleanupOverflowDrag() {
1371
+ this.isDragging = false;
1372
+ this.isDraggingFromOverflow = false;
1373
+ this.draggedItem = null;
1374
+ if (this.dragSourceWrapper) {
1375
+ this.dragSourceWrapper.classList.remove(CSS_PREFIX + '__overflow-item--dragging');
1376
+ this.dragSourceWrapper = null;
1377
+ }
1378
+ this.wrapper.classList.remove(CSS_PREFIX + '__wrapper--dragging');
1379
+ if (this.dragGhost) {
1380
+ this.dragGhost.remove();
1381
+ this.dragGhost = null;
1382
+ }
1383
+ // Also clean up any orphaned ghost elements (fallback)
1384
+ const orphanedGhost = document.querySelector('.' + CSS_PREFIX + '__drag-ghost');
1385
+ if (orphanedGhost) {
1386
+ orphanedGhost.remove();
1387
+ }
1388
+ if (this.dragPlaceholder) {
1389
+ this.dragPlaceholder.remove();
1390
+ this.dragPlaceholder = null;
1391
+ }
1392
+ if (this.dragStartTimer) {
1393
+ clearTimeout(this.dragStartTimer);
1394
+ this.dragStartTimer = null;
1395
+ }
1396
+ }
1397
+ startDrag(wrapper, tool, e) {
1398
+ this.isDragging = true;
1399
+ this.draggedItem = tool;
1400
+ // Add dragging class to wrapper
1401
+ wrapper.classList.add(CSS_PREFIX + '__item--dragging');
1402
+ this.wrapper.classList.add(CSS_PREFIX + '__wrapper--dragging');
1403
+ // Create a ghost element for visual feedback
1404
+ const rect = wrapper.getBoundingClientRect();
1405
+ this.dragGhost = wrapper.cloneNode(true);
1406
+ this.dragGhost.classList.add(CSS_PREFIX + '__drag-ghost');
1407
+ this.dragGhost.style.position = 'fixed';
1408
+ this.dragGhost.style.left = `${rect.left}px`;
1409
+ this.dragGhost.style.top = `${rect.top}px`;
1410
+ this.dragGhost.style.width = `${rect.width}px`;
1411
+ this.dragGhost.style.height = `${rect.height}px`;
1412
+ this.dragGhost.style.pointerEvents = 'none';
1413
+ this.dragGhost.style.zIndex = '10000';
1414
+ document.body.appendChild(this.dragGhost);
1415
+ // Create a placeholder to show where the item will be dropped
1416
+ this.dragPlaceholder = document.createElement('span');
1417
+ this.dragPlaceholder.classList.add(CSS_PREFIX + '__drop-placeholder');
1418
+ const onMouseMove = (moveEvent) => {
1419
+ if (!this.isDragging || !this.dragGhost)
1420
+ return;
1421
+ // Update ghost position
1422
+ const ghostRect = this.dragGhost.getBoundingClientRect();
1423
+ this.dragGhost.style.left = `${moveEvent.clientX - ghostRect.width / 2}px`;
1424
+ this.dragGhost.style.top = `${moveEvent.clientY - ghostRect.height / 2}px`;
1425
+ // Find the toolbar item we're hovering over
1426
+ const toolbarItems = Array.from(this.toolbar.querySelectorAll('.' + CSS_PREFIX + '__item'));
1427
+ let insertBefore = null;
1428
+ let insertIndex = -1;
1429
+ for (let i = 0; i < toolbarItems.length; i++) {
1430
+ const item = toolbarItems[i];
1431
+ if (item === wrapper)
1432
+ continue; // Skip the dragged item itself
1433
+ const itemRect = item.getBoundingClientRect();
1434
+ const itemCenter = itemRect.left + itemRect.width / 2;
1435
+ if (moveEvent.clientX < itemCenter && insertBefore === null) {
1436
+ insertBefore = item;
1437
+ insertIndex = i;
1438
+ }
1439
+ }
1440
+ // Remove existing placeholder
1441
+ if (this.dragPlaceholder && this.dragPlaceholder.parentNode) {
1442
+ this.dragPlaceholder.remove();
1443
+ }
1444
+ // Insert placeholder at the appropriate position
1445
+ if (insertBefore && this.dragPlaceholder) {
1446
+ insertBefore.parentNode?.insertBefore(this.dragPlaceholder, insertBefore);
1447
+ }
1448
+ else if (this.dragPlaceholder) {
1449
+ // Insert at end (before separator or overflow toggle)
1450
+ const separator = this.toolbar.querySelector('.' + CSS_PREFIX + '__separator');
1451
+ if (separator) {
1452
+ separator.parentNode?.insertBefore(this.dragPlaceholder, separator);
1453
+ }
1454
+ else {
1455
+ const overflowToggle = this.toolbar.querySelector('.' + CSS_PREFIX + '__overflow-toggle');
1456
+ if (overflowToggle) {
1457
+ overflowToggle.parentNode?.insertBefore(this.dragPlaceholder, overflowToggle);
1458
+ }
1459
+ else {
1460
+ this.toolbar.appendChild(this.dragPlaceholder);
1461
+ }
1462
+ }
1463
+ }
1464
+ };
1465
+ const onMouseUp = (upEvent) => {
1466
+ if (!this.isDragging)
1467
+ return;
1468
+ // Calculate new position based on placeholder
1469
+ if (this.dragPlaceholder && this.dragPlaceholder.parentNode) {
1470
+ const toolbarItems = Array.from(this.toolbar.querySelectorAll('.' + CSS_PREFIX + '__item'));
1471
+ const placeholderIndex = Array.from(this.toolbar.children).indexOf(this.dragPlaceholder);
1472
+ const draggedIndex = toolbarItems.indexOf(wrapper);
1473
+ // Calculate target index in visible items
1474
+ let visibleTargetIndex = 0;
1475
+ for (let i = 0; i < this.toolbar.children.length; i++) {
1476
+ const child = this.toolbar.children[i];
1477
+ if (child === this.dragPlaceholder)
1478
+ break;
1479
+ if (child.classList.contains(CSS_PREFIX + '__item')) {
1480
+ visibleTargetIndex++;
1481
+ }
1482
+ }
1483
+ // Find the tool's current index in the tools array
1484
+ const currentToolIndex = this.tools.indexOf(tool);
1485
+ if (currentToolIndex !== -1 && visibleTargetIndex !== currentToolIndex) {
1486
+ // Remove the tool from its current position
1487
+ this.tools.splice(currentToolIndex, 1);
1488
+ // Insert at new position
1489
+ const insertAtIndex = visibleTargetIndex > currentToolIndex
1490
+ ? visibleTargetIndex - 1
1491
+ : visibleTargetIndex;
1492
+ this.tools.splice(insertAtIndex, 0, tool);
1493
+ // Update order values
1494
+ this.tools.forEach((t, i) => {
1495
+ t.order = i;
1496
+ });
1497
+ // Save to localStorage
1498
+ this.saveOrderState();
1499
+ }
1500
+ }
1501
+ // Clean up
1502
+ this.cleanupDrag(wrapper);
1503
+ document.removeEventListener('mousemove', onMouseMove);
1504
+ document.removeEventListener('mouseup', onMouseUp);
1505
+ // Re-render after a short delay
1506
+ setTimeout(() => {
1507
+ this.render();
1508
+ }, 0);
1509
+ };
1510
+ document.addEventListener('mousemove', onMouseMove);
1511
+ document.addEventListener('mouseup', onMouseUp);
1512
+ }
1513
+ cleanupDrag(wrapper) {
1514
+ this.isDragging = false;
1515
+ this.draggedItem = null;
1516
+ wrapper.classList.remove(CSS_PREFIX + '__item--dragging');
1517
+ this.wrapper.classList.remove(CSS_PREFIX + '__wrapper--dragging');
1518
+ if (this.dragGhost) {
1519
+ this.dragGhost.remove();
1520
+ this.dragGhost = null;
1521
+ }
1522
+ // Also clean up any orphaned ghost elements (fallback)
1523
+ const orphanedGhost = document.querySelector('.' + CSS_PREFIX + '__drag-ghost');
1524
+ if (orphanedGhost) {
1525
+ orphanedGhost.remove();
1526
+ }
1527
+ if (this.dragPlaceholder) {
1528
+ this.dragPlaceholder.remove();
1529
+ this.dragPlaceholder = null;
1530
+ }
1531
+ if (this.dragStartTimer) {
1532
+ clearTimeout(this.dragStartTimer);
1533
+ this.dragStartTimer = null;
1534
+ }
1535
+ }
1536
+ setupResize() {
1537
+ let isResizing = false;
1538
+ let startY = 0;
1539
+ let startHeight = 0;
1540
+ const onMouseDown = (e) => {
1541
+ isResizing = true;
1542
+ startY = e.clientY;
1543
+ startHeight = this.editorContainer.offsetHeight;
1544
+ // Add resizing class for visual feedback
1545
+ this.wrapper.classList.add(CSS_PREFIX + '__wrapper--resizing');
1546
+ // Prevent text selection while dragging
1547
+ e.preventDefault();
1548
+ document.addEventListener('mousemove', onMouseMove);
1549
+ document.addEventListener('mouseup', onMouseUp);
1550
+ };
1551
+ const onMouseMove = (e) => {
1552
+ if (!isResizing)
1553
+ return;
1554
+ const deltaY = e.clientY - startY;
1555
+ const newHeight = startHeight + deltaY;
1556
+ // Set minimum and maximum heights
1557
+ const minHeight = 200;
1558
+ const maxHeight = globalThis.innerHeight - 100;
1559
+ if (newHeight >= minHeight && newHeight <= maxHeight) {
1560
+ this.editorContainer.style.height = newHeight + 'px';
1561
+ }
1562
+ };
1563
+ const onMouseUp = () => {
1564
+ if (!isResizing)
1565
+ return;
1566
+ isResizing = false;
1567
+ this.wrapper.classList.remove(CSS_PREFIX + '__wrapper--resizing');
1568
+ document.removeEventListener('mousemove', onMouseMove);
1569
+ document.removeEventListener('mouseup', onMouseUp);
1570
+ };
1571
+ this.resizeHandle.addEventListener('mousedown', onMouseDown);
1572
+ }
1573
+ update(view, prevState) {
1574
+ // Re-render tools to update their state (original approach)
1575
+ // Note: This is less efficient but more reliable than storing update functions
1576
+ this.tools.forEach((tool) => {
1577
+ tool.element.render(this.editorView);
1578
+ });
1579
+ }
1580
+ destroy() {
1581
+ // Clean up event listeners
1582
+ const doc = this.editorView.dom.ownerDocument || document;
1583
+ if (this.closeOverflowHandler) {
1584
+ doc.removeEventListener('click', this.closeOverflowHandler);
1585
+ this.closeOverflowHandler = null;
1586
+ }
1587
+ if (this.closePinnedDropdownHandler) {
1588
+ doc.removeEventListener('click', this.closePinnedDropdownHandler);
1589
+ this.closePinnedDropdownHandler = null;
1590
+ }
1591
+ // Clean up keyboard handler
1592
+ if (this.keydownHandler) {
1593
+ document.removeEventListener('keydown', this.keydownHandler);
1594
+ this.keydownHandler = null;
1595
+ }
1596
+ // Clean up ResizeObserver
1597
+ if (this.resizeObserver) {
1598
+ this.resizeObserver.disconnect();
1599
+ this.resizeObserver = null;
1600
+ }
1601
+ // Clean up pinned dropdown
1602
+ if (this.pinnedDropdownMenu) {
1603
+ this.pinnedDropdownMenu.remove();
1604
+ this.pinnedDropdownMenu = null;
1605
+ }
1606
+ // Clean up drag state
1607
+ if (this.dragGhost) {
1608
+ this.dragGhost.remove();
1609
+ this.dragGhost = null;
1610
+ }
1611
+ if (this.dragPlaceholder) {
1612
+ this.dragPlaceholder.remove();
1613
+ this.dragPlaceholder = null;
1614
+ }
1615
+ if (this.dragStartTimer) {
1616
+ clearTimeout(this.dragStartTimer);
1617
+ this.dragStartTimer = null;
1618
+ }
1619
+ // Clean up DOM
1620
+ if (this.wrapper.parentNode) {
1621
+ this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper);
1622
+ }
1623
+ }
1624
+ }
1625
+ export class CustomMenuPlugin extends Plugin {
1626
+ constructor(editor, options) {
1627
+ super({
1628
+ view(editorView) {
1629
+ return new CustomMenuView(editorView, editor, options.content);
1630
+ },
1631
+ });
1632
+ }
1633
+ }
1634
+ //# sourceMappingURL=CustomMenuPlugin.js.map