@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.
- package/esm/CustomMenuPlugin.js +1 -0
- package/esm/CustomMenuPlugin.js.map +1 -0
- package/esm/ExtensionCustomMenu.js +1 -0
- package/esm/ExtensionCustomMenu.js.map +1 -0
- package/esm/_dnt.shims.js +1 -0
- package/esm/_dnt.shims.js.map +1 -0
- package/esm/buildMenu.js +1 -0
- package/esm/buildMenu.js.map +1 -0
- package/esm/icons.js +1 -0
- package/esm/icons.js.map +1 -0
- package/esm/menu.js +1 -0
- package/esm/menu.js.map +1 -0
- package/esm/mod.js +1 -0
- package/esm/mod.js.map +1 -0
- package/esm/prompt.js +1 -0
- package/esm/prompt.js.map +1 -0
- package/package.json +6 -2
- package/src/CustomMenuPlugin.ts +1996 -0
- package/src/ExtensionCustomMenu.ts +29 -0
- package/src/_dnt.shims.ts +60 -0
- package/src/buildMenu.ts +560 -0
- package/src/icons.ts +262 -0
- package/src/menu.ts +473 -0
- package/src/mod.ts +9 -0
- package/src/prompt.ts +205 -0
- package/assets/custom-menu.css +0 -1133
- package/assets/menu.css +0 -919
|
@@ -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
|
+
}
|