@kerebron/extension-menu 0.4.28 → 0.4.30

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