@kerebron/extension-menu 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/assets/custom-menu.css +838 -0
- package/assets/menu.css +353 -26
- package/esm/_dnt.shims.d.ts +6 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/_dnt.shims.js +61 -0
- package/esm/editor/src/CoreEditor.d.ts +13 -4
- package/esm/editor/src/CoreEditor.d.ts.map +1 -1
- package/esm/editor/src/CoreEditor.js +64 -12
- package/esm/editor/src/Extension.d.ts +6 -1
- package/esm/editor/src/Extension.d.ts.map +1 -1
- package/esm/editor/src/Extension.js +21 -1
- package/esm/editor/src/ExtensionManager.d.ts +5 -6
- package/esm/editor/src/ExtensionManager.d.ts.map +1 -1
- package/esm/editor/src/ExtensionManager.js +43 -55
- package/esm/editor/src/Mark.d.ts +3 -0
- package/esm/editor/src/Mark.d.ts.map +1 -1
- package/esm/editor/src/Mark.js +11 -0
- package/esm/editor/src/Node.d.ts +5 -2
- package/esm/editor/src/Node.d.ts.map +1 -1
- package/esm/editor/src/Node.js +13 -2
- package/esm/editor/src/commands/CommandManager.d.ts +13 -6
- package/esm/editor/src/commands/CommandManager.d.ts.map +1 -1
- package/esm/editor/src/commands/CommandManager.js +59 -2
- package/esm/editor/src/commands/baseCommandFactories.d.ts +3 -0
- package/esm/editor/src/commands/baseCommandFactories.d.ts.map +1 -0
- package/esm/editor/src/commands/baseCommandFactories.js +836 -0
- package/esm/editor/src/commands/keyCommandFactories.d.ts +3 -0
- package/esm/editor/src/commands/keyCommandFactories.d.ts.map +1 -0
- package/esm/editor/src/commands/keyCommandFactories.js +10 -0
- package/esm/editor/src/commands/mod.d.ts +5 -53
- package/esm/editor/src/commands/mod.d.ts.map +1 -1
- package/esm/editor/src/commands/mod.js +14 -821
- package/esm/editor/src/commands/replaceCommandFactories.d.ts +3 -0
- package/esm/editor/src/commands/replaceCommandFactories.d.ts.map +1 -0
- package/esm/editor/src/commands/replaceCommandFactories.js +94 -0
- package/esm/editor/src/commands/types.d.ts +18 -0
- package/esm/editor/src/commands/types.d.ts.map +1 -0
- package/esm/editor/src/commands/types.js +1 -0
- package/esm/editor/src/mod.d.ts +2 -0
- package/esm/editor/src/mod.d.ts.map +1 -1
- package/esm/editor/src/mod.js +2 -0
- package/esm/editor/src/plugins/TrackSelecionPlugin.d.ts +6 -0
- package/esm/editor/src/plugins/TrackSelecionPlugin.d.ts.map +1 -0
- package/esm/editor/src/plugins/TrackSelecionPlugin.js +24 -0
- package/esm/editor/src/types.d.ts +19 -1
- package/esm/editor/src/types.d.ts.map +1 -1
- package/esm/editor/src/ui.d.ts +15 -0
- package/esm/editor/src/ui.d.ts.map +1 -0
- package/esm/editor/src/ui.js +16 -0
- package/esm/editor/src/utilities/SmartOutput.d.ts +9 -7
- package/esm/editor/src/utilities/SmartOutput.d.ts.map +1 -1
- package/esm/editor/src/utilities/SmartOutput.js +35 -20
- package/esm/extension-menu/src/CustomMenuPlugin.d.ts +61 -0
- package/esm/extension-menu/src/CustomMenuPlugin.d.ts.map +1 -0
- package/esm/extension-menu/src/CustomMenuPlugin.js +1130 -0
- package/esm/extension-menu/src/ExtensionCustomMenu.d.ts +11 -0
- package/esm/extension-menu/src/ExtensionCustomMenu.d.ts.map +1 -0
- package/esm/extension-menu/src/ExtensionCustomMenu.js +23 -0
- package/esm/extension-menu/src/buildMenu.d.ts +5 -0
- package/esm/extension-menu/src/buildMenu.d.ts.map +1 -0
- package/esm/extension-menu/src/{ExtensionMenu.js → buildMenu.js} +88 -75
- package/esm/extension-menu/src/icons.d.ts.map +1 -1
- package/esm/extension-menu/src/icons.js +5 -0
- package/esm/extension-menu/src/menu.d.ts +1 -8
- package/esm/extension-menu/src/menu.d.ts.map +1 -1
- package/esm/extension-menu/src/menu.js +9 -51
- package/esm/extension-menu/src/mod.d.ts +3 -0
- package/esm/extension-menu/src/mod.d.ts.map +1 -0
- package/esm/extension-menu/src/mod.js +2 -0
- package/package.json +8 -4
- package/esm/extension-menu/src/ExtensionMenu.d.ts +0 -17
- package/esm/extension-menu/src/ExtensionMenu.d.ts.map +0 -1
- package/esm/extension-menu/src/MenuPlugin.d.ts +0 -9
- package/esm/extension-menu/src/MenuPlugin.d.ts.map +0 -1
- package/esm/extension-menu/src/MenuPlugin.js +0 -245
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
// deno-lint-ignore-file no-window
|
|
2
|
+
import * as dntShim from "../../_dnt.shims.js";
|
|
3
|
+
import { Plugin } from 'prosemirror-state';
|
|
4
|
+
const CSS_PREFIX = 'kb-custom-menu';
|
|
5
|
+
const MAX_PINNED_ITEMS = 8;
|
|
6
|
+
const STORAGE_KEY = 'kb-custom-menu-pinned';
|
|
7
|
+
const DEBUG_STORAGE_KEY = 'kb-custom-menu-debug';
|
|
8
|
+
// Bootstrap md breakpoint: 768px (mobile is < 768px, desktop is >= 768px)
|
|
9
|
+
const MOBILE_BREAKPOINT = 768;
|
|
10
|
+
// Debug helper function
|
|
11
|
+
function debug(...args) {
|
|
12
|
+
try {
|
|
13
|
+
if (typeof localStorage !== 'undefined' &&
|
|
14
|
+
localStorage.getItem(DEBUG_STORAGE_KEY) === 'true') {
|
|
15
|
+
console.log('[kb-custom-menu]', ...args);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
// Ignore localStorage errors
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class CustomMenuView {
|
|
23
|
+
constructor(editorView, editor, content) {
|
|
24
|
+
Object.defineProperty(this, "editorView", {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
configurable: true,
|
|
27
|
+
writable: true,
|
|
28
|
+
value: editorView
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(this, "editor", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
value: editor
|
|
35
|
+
});
|
|
36
|
+
Object.defineProperty(this, "content", {
|
|
37
|
+
enumerable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
value: content
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(this, "wrapper", {
|
|
43
|
+
enumerable: true,
|
|
44
|
+
configurable: true,
|
|
45
|
+
writable: true,
|
|
46
|
+
value: void 0
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(this, "toolbar", {
|
|
49
|
+
enumerable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
value: void 0
|
|
53
|
+
});
|
|
54
|
+
Object.defineProperty(this, "overflowMenu", {
|
|
55
|
+
enumerable: true,
|
|
56
|
+
configurable: true,
|
|
57
|
+
writable: true,
|
|
58
|
+
value: void 0
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(this, "pinnedDropdownMenu", {
|
|
61
|
+
enumerable: true,
|
|
62
|
+
configurable: true,
|
|
63
|
+
writable: true,
|
|
64
|
+
value: null
|
|
65
|
+
});
|
|
66
|
+
Object.defineProperty(this, "modal", {
|
|
67
|
+
enumerable: true,
|
|
68
|
+
configurable: true,
|
|
69
|
+
writable: true,
|
|
70
|
+
value: null
|
|
71
|
+
});
|
|
72
|
+
Object.defineProperty(this, "tools", {
|
|
73
|
+
enumerable: true,
|
|
74
|
+
configurable: true,
|
|
75
|
+
writable: true,
|
|
76
|
+
value: []
|
|
77
|
+
});
|
|
78
|
+
Object.defineProperty(this, "root", {
|
|
79
|
+
enumerable: true,
|
|
80
|
+
configurable: true,
|
|
81
|
+
writable: true,
|
|
82
|
+
value: void 0
|
|
83
|
+
});
|
|
84
|
+
Object.defineProperty(this, "resizeHandle", {
|
|
85
|
+
enumerable: true,
|
|
86
|
+
configurable: true,
|
|
87
|
+
writable: true,
|
|
88
|
+
value: void 0
|
|
89
|
+
});
|
|
90
|
+
Object.defineProperty(this, "editorContainer", {
|
|
91
|
+
enumerable: true,
|
|
92
|
+
configurable: true,
|
|
93
|
+
writable: true,
|
|
94
|
+
value: void 0
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(this, "closeOverflowHandler", {
|
|
97
|
+
enumerable: true,
|
|
98
|
+
configurable: true,
|
|
99
|
+
writable: true,
|
|
100
|
+
value: null
|
|
101
|
+
});
|
|
102
|
+
Object.defineProperty(this, "closePinnedDropdownHandler", {
|
|
103
|
+
enumerable: true,
|
|
104
|
+
configurable: true,
|
|
105
|
+
writable: true,
|
|
106
|
+
value: null
|
|
107
|
+
});
|
|
108
|
+
Object.defineProperty(this, "submenuStack", {
|
|
109
|
+
enumerable: true,
|
|
110
|
+
configurable: true,
|
|
111
|
+
writable: true,
|
|
112
|
+
value: []
|
|
113
|
+
});
|
|
114
|
+
Object.defineProperty(this, "pinnedDropdownStack", {
|
|
115
|
+
enumerable: true,
|
|
116
|
+
configurable: true,
|
|
117
|
+
writable: true,
|
|
118
|
+
value: []
|
|
119
|
+
});
|
|
120
|
+
debug('CustomMenuView constructor called');
|
|
121
|
+
debug('Content groups:', content.length);
|
|
122
|
+
this.root = editorView.root;
|
|
123
|
+
// Create wrapper
|
|
124
|
+
this.wrapper = document.createElement('div');
|
|
125
|
+
this.wrapper.classList.add(CSS_PREFIX + '__wrapper');
|
|
126
|
+
debug('Wrapper created:', this.wrapper);
|
|
127
|
+
// Create toolbar
|
|
128
|
+
this.toolbar = document.createElement('div');
|
|
129
|
+
this.toolbar.classList.add(CSS_PREFIX);
|
|
130
|
+
this.toolbar.setAttribute('role', 'toolbar');
|
|
131
|
+
// Create editor container with resize handle
|
|
132
|
+
this.editorContainer = document.createElement('div');
|
|
133
|
+
this.editorContainer.classList.add(CSS_PREFIX + '__editor-container');
|
|
134
|
+
this.editorContainer.style.height = '50vh'; // Start at 50% height
|
|
135
|
+
// Create resize handle
|
|
136
|
+
this.resizeHandle = document.createElement('div');
|
|
137
|
+
this.resizeHandle.classList.add(CSS_PREFIX + '__resize-handle');
|
|
138
|
+
this.resizeHandle.innerHTML = '<div class="' + CSS_PREFIX +
|
|
139
|
+
'__resize-handle-bar"></div>';
|
|
140
|
+
// Mount toolbar before the editor DOM
|
|
141
|
+
this.wrapper.appendChild(this.toolbar);
|
|
142
|
+
if (editorView.dom.parentNode) {
|
|
143
|
+
editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom);
|
|
144
|
+
}
|
|
145
|
+
// Wrap editor in container and add resize handle
|
|
146
|
+
this.editorContainer.appendChild(editorView.dom);
|
|
147
|
+
this.editorContainer.appendChild(this.resizeHandle);
|
|
148
|
+
this.wrapper.appendChild(this.editorContainer);
|
|
149
|
+
// Create overflow menu (initially hidden)
|
|
150
|
+
this.overflowMenu = document.createElement('div');
|
|
151
|
+
this.overflowMenu.classList.add(CSS_PREFIX + '__overflow-menu');
|
|
152
|
+
this.overflowMenu.style.display = 'none';
|
|
153
|
+
this.wrapper.insertBefore(this.overflowMenu, this.editorContainer);
|
|
154
|
+
// Initialize tools from content
|
|
155
|
+
this.initializeTools();
|
|
156
|
+
// Load pinned state from localStorage
|
|
157
|
+
this.loadPinnedState();
|
|
158
|
+
// Setup resize functionality
|
|
159
|
+
this.setupResize();
|
|
160
|
+
// Initial render
|
|
161
|
+
this.render();
|
|
162
|
+
// Add window resize listener to re-render on mobile/desktop changes
|
|
163
|
+
if (typeof dntShim.dntGlobalThis !== 'undefined') {
|
|
164
|
+
globalThis.addEventListener('resize', () => {
|
|
165
|
+
this.render();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
initializeTools() {
|
|
170
|
+
debug('Initializing tools from content');
|
|
171
|
+
let toolCount = 0;
|
|
172
|
+
this.content.forEach((group, groupIndex) => {
|
|
173
|
+
debug(`Processing group ${groupIndex}, elements:`, group.length);
|
|
174
|
+
group.forEach((element, elementIndex) => {
|
|
175
|
+
const { dom, update } = element.render(this.editorView);
|
|
176
|
+
// For dropdowns, get the label from the element's options directly
|
|
177
|
+
let label;
|
|
178
|
+
const dropdown = element;
|
|
179
|
+
if (dropdown.options && dropdown.options.label) {
|
|
180
|
+
label = dropdown.options.label;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
label = this.extractLabel(dom);
|
|
184
|
+
}
|
|
185
|
+
const id = this.generateStableId(label, dom);
|
|
186
|
+
this.tools.push({
|
|
187
|
+
id,
|
|
188
|
+
label,
|
|
189
|
+
element,
|
|
190
|
+
isPinned: false,
|
|
191
|
+
});
|
|
192
|
+
toolCount++;
|
|
193
|
+
debug(`Tool ${toolCount}: id="${id}", label="${label}"`);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
debug(`Total tools initialized: ${this.tools.length}`);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Generate a stable ID from a label by converting it to a kebab-case slug.
|
|
200
|
+
* Falls back to a hash if the label is empty or contains only special characters.
|
|
201
|
+
*/
|
|
202
|
+
generateStableId(label, dom) {
|
|
203
|
+
// Try to use aria-label or data-id if available
|
|
204
|
+
const ariaLabel = dom.getAttribute('aria-label');
|
|
205
|
+
const dataId = dom.getAttribute('data-id');
|
|
206
|
+
if (dataId)
|
|
207
|
+
return `tool-${dataId}`;
|
|
208
|
+
const baseLabel = ariaLabel || label;
|
|
209
|
+
// Convert label to kebab-case slug
|
|
210
|
+
const slug = baseLabel
|
|
211
|
+
.toLowerCase()
|
|
212
|
+
.trim()
|
|
213
|
+
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
|
|
214
|
+
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
|
215
|
+
// If slug is empty, generate a simple hash from the label
|
|
216
|
+
if (!slug) {
|
|
217
|
+
const hash = this.simpleHash(baseLabel);
|
|
218
|
+
return `tool-${hash}`;
|
|
219
|
+
}
|
|
220
|
+
return `tool-${slug}`;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Simple hash function for generating stable IDs from strings.
|
|
224
|
+
*/
|
|
225
|
+
simpleHash(str) {
|
|
226
|
+
let hash = 0;
|
|
227
|
+
for (let i = 0; i < str.length; i++) {
|
|
228
|
+
const char = str.charCodeAt(i);
|
|
229
|
+
hash = ((hash << 5) - hash) + char;
|
|
230
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
231
|
+
}
|
|
232
|
+
return Math.abs(hash).toString(36);
|
|
233
|
+
}
|
|
234
|
+
extractLabel(dom) {
|
|
235
|
+
// For menu buttons, prioritize the visible label text over title
|
|
236
|
+
const buttonText = dom.querySelector('span')?.textContent?.trim();
|
|
237
|
+
if (buttonText)
|
|
238
|
+
return buttonText;
|
|
239
|
+
// For dropdowns, check the dropdown label
|
|
240
|
+
const dropdownLabel = dom.querySelector('.kb-dropdown__label')?.textContent
|
|
241
|
+
?.trim();
|
|
242
|
+
if (dropdownLabel)
|
|
243
|
+
return dropdownLabel;
|
|
244
|
+
// Try aria-label as fallback
|
|
245
|
+
const ariaLabel = dom.getAttribute('aria-label');
|
|
246
|
+
if (ariaLabel)
|
|
247
|
+
return ariaLabel;
|
|
248
|
+
// Try title attribute
|
|
249
|
+
const title = dom.getAttribute('title');
|
|
250
|
+
if (title)
|
|
251
|
+
return title;
|
|
252
|
+
// Fallback to looking at SVG title
|
|
253
|
+
const svgTitle = dom.querySelector('svg title')?.textContent?.trim();
|
|
254
|
+
if (svgTitle)
|
|
255
|
+
return svgTitle;
|
|
256
|
+
return 'Unknown Tool';
|
|
257
|
+
}
|
|
258
|
+
loadPinnedState() {
|
|
259
|
+
debug('Loading pinned state from localStorage');
|
|
260
|
+
try {
|
|
261
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
262
|
+
debug('Saved pinned state:', saved);
|
|
263
|
+
if (saved) {
|
|
264
|
+
const pinnedIds = JSON.parse(saved);
|
|
265
|
+
debug('Pinned tool IDs:', pinnedIds);
|
|
266
|
+
// If the saved array is empty, treat it as no saved state (use defaults)
|
|
267
|
+
if (pinnedIds.length === 0) {
|
|
268
|
+
debug('Saved state is empty array, using default (first 8 tools)');
|
|
269
|
+
this.tools.slice(0, MAX_PINNED_ITEMS).forEach((tool) => {
|
|
270
|
+
tool.isPinned = true;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
this.tools.forEach((tool) => {
|
|
275
|
+
tool.isPinned = pinnedIds.includes(tool.id);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
// Default pinned items (first 8)
|
|
281
|
+
debug('No saved state, using default (first 8 tools)');
|
|
282
|
+
this.tools.slice(0, MAX_PINNED_ITEMS).forEach((tool) => {
|
|
283
|
+
tool.isPinned = true;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const pinnedCount = this.tools.filter((t) => t.isPinned).length;
|
|
287
|
+
debug(`Loaded pinned state: ${pinnedCount} tools pinned`);
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
console.error('[kb-custom-menu] Failed to load pinned state:', e);
|
|
291
|
+
// Default to first 8 items
|
|
292
|
+
this.tools.slice(0, MAX_PINNED_ITEMS).forEach((tool) => {
|
|
293
|
+
tool.isPinned = true;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
savePinnedState() {
|
|
298
|
+
try {
|
|
299
|
+
const pinnedIds = this.tools.filter((t) => t.isPinned).map((t) => t.id);
|
|
300
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(pinnedIds));
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
console.error('Failed to save pinned state:', e);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
showSubmenu(tool) {
|
|
307
|
+
const dropdown = tool.element;
|
|
308
|
+
if (!dropdown.content)
|
|
309
|
+
return;
|
|
310
|
+
// Extract sub-items from dropdown
|
|
311
|
+
const subItems = dropdown.content.map((element, index) => {
|
|
312
|
+
const { dom } = element.render(this.editorView);
|
|
313
|
+
// For nested dropdowns, get the label from options directly
|
|
314
|
+
let label;
|
|
315
|
+
const nestedDropdown = element;
|
|
316
|
+
if (nestedDropdown.options && nestedDropdown.options.label) {
|
|
317
|
+
label = nestedDropdown.options.label;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
label = this.extractLabel(dom);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
id: `${tool.id}-sub-${index}`,
|
|
324
|
+
label,
|
|
325
|
+
element,
|
|
326
|
+
isPinned: false,
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
// Save current state to stack
|
|
330
|
+
this.submenuStack.push({
|
|
331
|
+
title: tool.label,
|
|
332
|
+
tools: subItems,
|
|
333
|
+
});
|
|
334
|
+
// Re-render to show submenu
|
|
335
|
+
this.renderOverflowMenu();
|
|
336
|
+
}
|
|
337
|
+
goBack() {
|
|
338
|
+
// Remove last submenu from stack
|
|
339
|
+
this.submenuStack.pop();
|
|
340
|
+
// Re-render
|
|
341
|
+
this.renderOverflowMenu();
|
|
342
|
+
}
|
|
343
|
+
showPinnedDropdown(tool, triggerElement) {
|
|
344
|
+
// Close any existing pinned dropdown
|
|
345
|
+
if (this.pinnedDropdownMenu) {
|
|
346
|
+
this.pinnedDropdownMenu.remove();
|
|
347
|
+
this.pinnedDropdownMenu = null;
|
|
348
|
+
}
|
|
349
|
+
const dropdown = tool.element;
|
|
350
|
+
if (!dropdown.content)
|
|
351
|
+
return;
|
|
352
|
+
// Extract sub-items from dropdown
|
|
353
|
+
const subItems = dropdown.content.map((element, index) => {
|
|
354
|
+
const { dom } = element.render(this.editorView);
|
|
355
|
+
let label;
|
|
356
|
+
const nestedDropdown = element;
|
|
357
|
+
if (nestedDropdown.options && nestedDropdown.options.label) {
|
|
358
|
+
label = nestedDropdown.options.label;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
label = this.extractLabel(dom);
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
id: `${tool.id}-sub-${index}`,
|
|
365
|
+
label,
|
|
366
|
+
element,
|
|
367
|
+
isPinned: false,
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
// Initialize stack with root menu
|
|
371
|
+
this.pinnedDropdownStack = [{
|
|
372
|
+
title: tool.label,
|
|
373
|
+
tools: subItems,
|
|
374
|
+
rootTool: tool,
|
|
375
|
+
}];
|
|
376
|
+
// Render the dropdown
|
|
377
|
+
this.renderPinnedDropdown(triggerElement);
|
|
378
|
+
}
|
|
379
|
+
pinnedDropdownGoBack() {
|
|
380
|
+
// Remove last item from stack
|
|
381
|
+
this.pinnedDropdownStack.pop();
|
|
382
|
+
// If stack is empty, close the dropdown
|
|
383
|
+
if (this.pinnedDropdownStack.length === 0) {
|
|
384
|
+
if (this.pinnedDropdownMenu) {
|
|
385
|
+
this.pinnedDropdownMenu.remove();
|
|
386
|
+
this.pinnedDropdownMenu = null;
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Re-render with previous menu
|
|
391
|
+
const triggerElement = this.toolbar.querySelector(`[data-tool-id="${this.pinnedDropdownStack[0].rootTool.id}"]`);
|
|
392
|
+
if (triggerElement) {
|
|
393
|
+
this.renderPinnedDropdown(triggerElement);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
pinnedDropdownShowSubmenu(tool, triggerElement) {
|
|
397
|
+
const dropdown = tool.element;
|
|
398
|
+
if (!dropdown.content)
|
|
399
|
+
return;
|
|
400
|
+
// Extract sub-items
|
|
401
|
+
const subItems = dropdown.content.map((element, index) => {
|
|
402
|
+
const { dom } = element.render(this.editorView);
|
|
403
|
+
let label;
|
|
404
|
+
const nestedDropdown = element;
|
|
405
|
+
if (nestedDropdown.options && nestedDropdown.options.label) {
|
|
406
|
+
label = nestedDropdown.options.label;
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
label = this.extractLabel(dom);
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
id: `${tool.id}-sub-${index}`,
|
|
413
|
+
label,
|
|
414
|
+
element,
|
|
415
|
+
isPinned: false,
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
// Add to stack
|
|
419
|
+
this.pinnedDropdownStack.push({
|
|
420
|
+
title: tool.label,
|
|
421
|
+
tools: subItems,
|
|
422
|
+
rootTool: this.pinnedDropdownStack[0].rootTool,
|
|
423
|
+
});
|
|
424
|
+
// Re-render
|
|
425
|
+
this.renderPinnedDropdown(triggerElement);
|
|
426
|
+
}
|
|
427
|
+
renderPinnedDropdown(triggerElement) {
|
|
428
|
+
// Remove existing dropdown
|
|
429
|
+
if (this.pinnedDropdownMenu) {
|
|
430
|
+
this.pinnedDropdownMenu.remove();
|
|
431
|
+
}
|
|
432
|
+
const currentLevel = this.pinnedDropdownStack[this.pinnedDropdownStack.length - 1];
|
|
433
|
+
const isSubmenu = this.pinnedDropdownStack.length > 1;
|
|
434
|
+
// Create dropdown menu
|
|
435
|
+
this.pinnedDropdownMenu = document.createElement('div');
|
|
436
|
+
this.pinnedDropdownMenu.classList.add(CSS_PREFIX + '__pinned-dropdown');
|
|
437
|
+
// Position it below the trigger element
|
|
438
|
+
const rect = triggerElement.getBoundingClientRect();
|
|
439
|
+
this.pinnedDropdownMenu.style.position = 'absolute';
|
|
440
|
+
this.pinnedDropdownMenu.style.top = `${rect.bottom + 4}px`;
|
|
441
|
+
this.pinnedDropdownMenu.style.left = `${rect.left}px`;
|
|
442
|
+
this.pinnedDropdownMenu.style.zIndex = '1000';
|
|
443
|
+
// Create scrollable content container
|
|
444
|
+
const overflowContent = document.createElement('div');
|
|
445
|
+
overflowContent.classList.add(CSS_PREFIX + '__overflow-content');
|
|
446
|
+
// Add back button if in submenu
|
|
447
|
+
if (isSubmenu) {
|
|
448
|
+
const header = document.createElement('div');
|
|
449
|
+
header.classList.add(CSS_PREFIX + '__overflow-submenu-header');
|
|
450
|
+
const backButton = document.createElement('button');
|
|
451
|
+
backButton.type = 'button';
|
|
452
|
+
backButton.classList.add(CSS_PREFIX + '__overflow-back-button');
|
|
453
|
+
backButton.innerHTML =
|
|
454
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 19l-7-7 7-7"/></svg>';
|
|
455
|
+
backButton.addEventListener('click', (e) => {
|
|
456
|
+
e.preventDefault();
|
|
457
|
+
e.stopPropagation();
|
|
458
|
+
this.pinnedDropdownGoBack();
|
|
459
|
+
});
|
|
460
|
+
const title = document.createElement('span');
|
|
461
|
+
title.classList.add(CSS_PREFIX + '__overflow-submenu-title');
|
|
462
|
+
title.textContent = currentLevel.title;
|
|
463
|
+
header.appendChild(backButton);
|
|
464
|
+
header.appendChild(title);
|
|
465
|
+
overflowContent.appendChild(header);
|
|
466
|
+
}
|
|
467
|
+
// Render menu items
|
|
468
|
+
currentLevel.tools.forEach((subTool) => {
|
|
469
|
+
const isNestedDropdown = subTool.element.content !== undefined;
|
|
470
|
+
const wrapper = document.createElement('div');
|
|
471
|
+
wrapper.classList.add(CSS_PREFIX + '__overflow-item');
|
|
472
|
+
wrapper.setAttribute('data-tool-id', subTool.id);
|
|
473
|
+
if (isNestedDropdown) {
|
|
474
|
+
// For nested dropdowns, show label and chevron
|
|
475
|
+
const label = document.createElement('span');
|
|
476
|
+
label.classList.add(CSS_PREFIX + '__overflow-item-label');
|
|
477
|
+
label.textContent = subTool.label;
|
|
478
|
+
wrapper.appendChild(label);
|
|
479
|
+
const chevron = document.createElement('span');
|
|
480
|
+
chevron.classList.add(CSS_PREFIX + '__overflow-item-chevron');
|
|
481
|
+
chevron.innerHTML =
|
|
482
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5l7 7-7 7"/></svg>';
|
|
483
|
+
wrapper.appendChild(chevron);
|
|
484
|
+
wrapper.addEventListener('click', (e) => {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
e.stopPropagation();
|
|
487
|
+
this.pinnedDropdownShowSubmenu(subTool, triggerElement);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
// Regular menu item
|
|
492
|
+
const { dom } = subTool.element.render(this.editorView);
|
|
493
|
+
// Hide button's internal label
|
|
494
|
+
const spans = dom.querySelectorAll('span');
|
|
495
|
+
spans.forEach((span) => {
|
|
496
|
+
const isInsideIcon = span.closest('.kb-icon') !== null;
|
|
497
|
+
const isDirectChild = span.parentElement === dom;
|
|
498
|
+
if (span.textContent && span.textContent.trim() &&
|
|
499
|
+
!isInsideIcon &&
|
|
500
|
+
isDirectChild &&
|
|
501
|
+
!span.querySelector('svg') &&
|
|
502
|
+
!span.classList.contains('kb-icon')) {
|
|
503
|
+
span.style.display = 'none';
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
// Add label
|
|
507
|
+
const label = document.createElement('span');
|
|
508
|
+
label.classList.add(CSS_PREFIX + '__overflow-item-label');
|
|
509
|
+
label.textContent = subTool.label;
|
|
510
|
+
wrapper.appendChild(dom);
|
|
511
|
+
wrapper.appendChild(label);
|
|
512
|
+
// Make wrapper clickable
|
|
513
|
+
wrapper.addEventListener('mousedown', (e) => {
|
|
514
|
+
if (e.target !== dom) {
|
|
515
|
+
e.preventDefault();
|
|
516
|
+
const mousedownEvent = new MouseEvent('mousedown', {
|
|
517
|
+
bubbles: true,
|
|
518
|
+
cancelable: true,
|
|
519
|
+
view: dntShim.dntGlobalThis,
|
|
520
|
+
});
|
|
521
|
+
dom.dispatchEvent(mousedownEvent);
|
|
522
|
+
}
|
|
523
|
+
// Close dropdown after click
|
|
524
|
+
if (this.pinnedDropdownMenu) {
|
|
525
|
+
this.pinnedDropdownMenu.remove();
|
|
526
|
+
this.pinnedDropdownMenu = null;
|
|
527
|
+
this.pinnedDropdownStack = [];
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
overflowContent.appendChild(wrapper);
|
|
532
|
+
});
|
|
533
|
+
// Add content to dropdown
|
|
534
|
+
this.pinnedDropdownMenu.appendChild(overflowContent);
|
|
535
|
+
// Add to document
|
|
536
|
+
document.body.appendChild(this.pinnedDropdownMenu);
|
|
537
|
+
// Close on click outside
|
|
538
|
+
const doc = this.editorView.dom.ownerDocument || document;
|
|
539
|
+
setTimeout(() => {
|
|
540
|
+
if (this.closePinnedDropdownHandler) {
|
|
541
|
+
doc.removeEventListener('click', this.closePinnedDropdownHandler);
|
|
542
|
+
}
|
|
543
|
+
this.closePinnedDropdownHandler = (e) => {
|
|
544
|
+
const target = e.target;
|
|
545
|
+
if (this.pinnedDropdownMenu &&
|
|
546
|
+
!this.pinnedDropdownMenu.contains(target) &&
|
|
547
|
+
!triggerElement.contains(target)) {
|
|
548
|
+
this.pinnedDropdownMenu.remove();
|
|
549
|
+
this.pinnedDropdownMenu = null;
|
|
550
|
+
this.pinnedDropdownStack = [];
|
|
551
|
+
if (this.closePinnedDropdownHandler) {
|
|
552
|
+
doc.removeEventListener('click', this.closePinnedDropdownHandler);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
doc.addEventListener('click', this.closePinnedDropdownHandler);
|
|
557
|
+
}, 0);
|
|
558
|
+
}
|
|
559
|
+
renderOverflowMenu() {
|
|
560
|
+
// Clear overflow menu
|
|
561
|
+
this.overflowMenu.innerHTML = '';
|
|
562
|
+
// Check if we're showing a submenu
|
|
563
|
+
const isSubmenu = this.submenuStack.length > 0;
|
|
564
|
+
const currentSubmenu = isSubmenu
|
|
565
|
+
? this.submenuStack[this.submenuStack.length - 1]
|
|
566
|
+
: null;
|
|
567
|
+
// Create scrollable content container
|
|
568
|
+
const overflowContent = document.createElement('div');
|
|
569
|
+
overflowContent.classList.add(CSS_PREFIX + '__overflow-content');
|
|
570
|
+
if (isSubmenu && currentSubmenu) {
|
|
571
|
+
// Render submenu header with back button
|
|
572
|
+
const header = document.createElement('div');
|
|
573
|
+
header.classList.add(CSS_PREFIX + '__overflow-submenu-header');
|
|
574
|
+
const backButton = document.createElement('button');
|
|
575
|
+
backButton.type = 'button';
|
|
576
|
+
backButton.classList.add(CSS_PREFIX + '__overflow-back-button');
|
|
577
|
+
backButton.innerHTML =
|
|
578
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 19l-7-7 7-7"/></svg>';
|
|
579
|
+
backButton.addEventListener('click', (e) => {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
e.stopPropagation();
|
|
582
|
+
this.goBack();
|
|
583
|
+
});
|
|
584
|
+
const title = document.createElement('span');
|
|
585
|
+
title.classList.add(CSS_PREFIX + '__overflow-submenu-title');
|
|
586
|
+
title.textContent = currentSubmenu.title;
|
|
587
|
+
header.appendChild(backButton);
|
|
588
|
+
header.appendChild(title);
|
|
589
|
+
overflowContent.appendChild(header);
|
|
590
|
+
// Render submenu items
|
|
591
|
+
currentSubmenu.tools.forEach((tool) => {
|
|
592
|
+
// Check if this is a nested dropdown
|
|
593
|
+
const isDropdown = tool.element.content !== undefined;
|
|
594
|
+
const wrapper = document.createElement('div');
|
|
595
|
+
wrapper.classList.add(CSS_PREFIX + '__overflow-item');
|
|
596
|
+
if (isDropdown) {
|
|
597
|
+
// For nested dropdowns, just show label and chevron (no icon/button)
|
|
598
|
+
// Add label
|
|
599
|
+
const label = document.createElement('span');
|
|
600
|
+
label.classList.add(CSS_PREFIX + '__overflow-item-label');
|
|
601
|
+
label.textContent = tool.label;
|
|
602
|
+
wrapper.appendChild(label);
|
|
603
|
+
// Add chevron to indicate submenu
|
|
604
|
+
const chevron = document.createElement('span');
|
|
605
|
+
chevron.classList.add(CSS_PREFIX + '__overflow-item-chevron');
|
|
606
|
+
chevron.innerHTML =
|
|
607
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5l7 7-7 7"/></svg>';
|
|
608
|
+
wrapper.appendChild(chevron);
|
|
609
|
+
// Click handler for nested dropdown - navigate deeper
|
|
610
|
+
wrapper.addEventListener('click', (e) => {
|
|
611
|
+
e.preventDefault();
|
|
612
|
+
e.stopPropagation();
|
|
613
|
+
this.showSubmenu(tool);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
// Regular menu item
|
|
618
|
+
const { dom, update } = tool.element.render(this.editorView);
|
|
619
|
+
// Hide the button's internal label to avoid duplication
|
|
620
|
+
const internalLabel = dom.querySelector('span');
|
|
621
|
+
if (internalLabel) {
|
|
622
|
+
internalLabel.style.display = 'none';
|
|
623
|
+
}
|
|
624
|
+
// Add our own label to the item
|
|
625
|
+
const label = document.createElement('span');
|
|
626
|
+
label.classList.add(CSS_PREFIX + '__overflow-item-label');
|
|
627
|
+
label.textContent = tool.label;
|
|
628
|
+
// Restructure the DOM to show icon + label
|
|
629
|
+
wrapper.appendChild(dom);
|
|
630
|
+
wrapper.appendChild(label);
|
|
631
|
+
// Make the entire wrapper clickable by dispatching mousedown to the button
|
|
632
|
+
wrapper.addEventListener('mousedown', (e) => {
|
|
633
|
+
if (e.target !== dom) {
|
|
634
|
+
e.preventDefault();
|
|
635
|
+
const mousedownEvent = new MouseEvent('mousedown', {
|
|
636
|
+
bubbles: true,
|
|
637
|
+
cancelable: true,
|
|
638
|
+
view: dntShim.dntGlobalThis,
|
|
639
|
+
});
|
|
640
|
+
dom.dispatchEvent(mousedownEvent);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
overflowContent.appendChild(wrapper);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// Render main overflow menu
|
|
649
|
+
const pinnedTools = this.tools.filter((t) => t.isPinned);
|
|
650
|
+
const overflowTools = this.tools.filter((t) => !t.isPinned);
|
|
651
|
+
// Check if we're in mobile view
|
|
652
|
+
const isMobile = typeof dntShim.dntGlobalThis !== 'undefined' &&
|
|
653
|
+
globalThis.innerWidth < MOBILE_BREAKPOINT;
|
|
654
|
+
const mobileLimit = 4;
|
|
655
|
+
const mobileOverflowPinned = isMobile
|
|
656
|
+
? pinnedTools.slice(mobileLimit)
|
|
657
|
+
: [];
|
|
658
|
+
// In mobile view, add the pinned overflow tools at the top with a label
|
|
659
|
+
if (isMobile && mobileOverflowPinned.length > 0) {
|
|
660
|
+
// Add section header
|
|
661
|
+
const header = document.createElement('div');
|
|
662
|
+
header.classList.add(CSS_PREFIX + '__overflow-header');
|
|
663
|
+
header.textContent = 'More Tools';
|
|
664
|
+
overflowContent.appendChild(header);
|
|
665
|
+
// Add the overflow pinned tools
|
|
666
|
+
mobileOverflowPinned.forEach((tool) => {
|
|
667
|
+
// Skip tools with empty or invalid labels
|
|
668
|
+
if (!tool.label || tool.label.trim() === '' ||
|
|
669
|
+
tool.label === 'Unknown Tool') {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const { dom, update } = tool.element.render(this.editorView);
|
|
673
|
+
const wrapper = document.createElement('div');
|
|
674
|
+
wrapper.classList.add(CSS_PREFIX + '__overflow-item');
|
|
675
|
+
// Add label to the item
|
|
676
|
+
const label = document.createElement('span');
|
|
677
|
+
label.classList.add(CSS_PREFIX + '__overflow-item-label');
|
|
678
|
+
label.textContent = tool.label;
|
|
679
|
+
// Restructure the DOM to show icon + label
|
|
680
|
+
wrapper.appendChild(dom);
|
|
681
|
+
wrapper.appendChild(label);
|
|
682
|
+
// Make the entire wrapper clickable by dispatching mousedown to the button
|
|
683
|
+
wrapper.addEventListener('mousedown', (e) => {
|
|
684
|
+
if (e.target !== dom) {
|
|
685
|
+
e.preventDefault();
|
|
686
|
+
const mousedownEvent = new MouseEvent('mousedown', {
|
|
687
|
+
bubbles: true,
|
|
688
|
+
cancelable: true,
|
|
689
|
+
view: dntShim.dntGlobalThis,
|
|
690
|
+
});
|
|
691
|
+
dom.dispatchEvent(mousedownEvent);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
overflowContent.appendChild(wrapper);
|
|
695
|
+
});
|
|
696
|
+
// Add separator after mobile overflow pinned section
|
|
697
|
+
if (overflowTools.length > 0) {
|
|
698
|
+
const separator = document.createElement('div');
|
|
699
|
+
separator.classList.add(CSS_PREFIX + '__overflow-separator');
|
|
700
|
+
overflowContent.appendChild(separator);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// Render overflow tools with labels
|
|
704
|
+
overflowTools.forEach((tool) => {
|
|
705
|
+
// Skip tools with empty or invalid labels
|
|
706
|
+
if (!tool.label || tool.label.trim() === '' ||
|
|
707
|
+
tool.label === 'Unknown Tool') {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// Check if this is a dropdown with sub-items
|
|
711
|
+
const isDropdown = tool.element.content !== undefined;
|
|
712
|
+
const wrapper = document.createElement('div');
|
|
713
|
+
wrapper.classList.add(CSS_PREFIX + '__overflow-item');
|
|
714
|
+
if (isDropdown) {
|
|
715
|
+
// For dropdowns, create a custom button with icon and chevron
|
|
716
|
+
const button = document.createElement('button');
|
|
717
|
+
button.type = 'button';
|
|
718
|
+
button.classList.add('kb-menu__button');
|
|
719
|
+
// Add an icon (we'll use a document icon for Type menu)
|
|
720
|
+
const icon = document.createElement('svg');
|
|
721
|
+
icon.setAttribute('viewBox', '0 0 24 24');
|
|
722
|
+
icon.setAttribute('fill', 'none');
|
|
723
|
+
icon.setAttribute('stroke', 'currentColor');
|
|
724
|
+
icon.setAttribute('stroke-width', '2');
|
|
725
|
+
icon.innerHTML =
|
|
726
|
+
'<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>';
|
|
727
|
+
button.appendChild(icon);
|
|
728
|
+
wrapper.appendChild(button);
|
|
729
|
+
// Add label
|
|
730
|
+
const label = document.createElement('span');
|
|
731
|
+
label.classList.add(CSS_PREFIX + '__overflow-item-label');
|
|
732
|
+
label.textContent = tool.label;
|
|
733
|
+
wrapper.appendChild(label);
|
|
734
|
+
// Add chevron to indicate submenu
|
|
735
|
+
const chevron = document.createElement('span');
|
|
736
|
+
chevron.classList.add(CSS_PREFIX + '__overflow-item-chevron');
|
|
737
|
+
chevron.innerHTML =
|
|
738
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5l7 7-7 7"/></svg>';
|
|
739
|
+
wrapper.appendChild(chevron);
|
|
740
|
+
// Click handler for dropdown - navigate to submenu
|
|
741
|
+
wrapper.addEventListener('click', (e) => {
|
|
742
|
+
e.preventDefault();
|
|
743
|
+
e.stopPropagation();
|
|
744
|
+
this.showSubmenu(tool);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
// Regular menu item
|
|
749
|
+
const { dom, update } = tool.element.render(this.editorView);
|
|
750
|
+
// Add label to the item
|
|
751
|
+
const label = document.createElement('span');
|
|
752
|
+
label.classList.add(CSS_PREFIX + '__overflow-item-label');
|
|
753
|
+
label.textContent = tool.label;
|
|
754
|
+
// Restructure the DOM to show icon + label
|
|
755
|
+
wrapper.appendChild(dom);
|
|
756
|
+
wrapper.appendChild(label);
|
|
757
|
+
// Make the entire wrapper clickable by dispatching mousedown to the button
|
|
758
|
+
wrapper.addEventListener('mousedown', (e) => {
|
|
759
|
+
if (e.target !== dom) {
|
|
760
|
+
e.preventDefault();
|
|
761
|
+
const mousedownEvent = new MouseEvent('mousedown', {
|
|
762
|
+
bubbles: true,
|
|
763
|
+
cancelable: true,
|
|
764
|
+
view: dntShim.dntGlobalThis,
|
|
765
|
+
});
|
|
766
|
+
dom.dispatchEvent(mousedownEvent);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
overflowContent.appendChild(wrapper);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
// Add the scrollable content to overflow menu
|
|
774
|
+
this.overflowMenu.appendChild(overflowContent);
|
|
775
|
+
// Create sticky footer for manage button (only in main menu, not submenu)
|
|
776
|
+
if (!isSubmenu &&
|
|
777
|
+
(this.tools.filter((t) => !t.isPinned).length > 0 ||
|
|
778
|
+
this.tools.filter((t) => t.isPinned).length > 0)) {
|
|
779
|
+
const overflowFooter = document.createElement('div');
|
|
780
|
+
overflowFooter.classList.add(CSS_PREFIX + '__overflow-footer');
|
|
781
|
+
const manageButton = document.createElement('button');
|
|
782
|
+
manageButton.type = 'button';
|
|
783
|
+
manageButton.className = CSS_PREFIX + '__manage-button';
|
|
784
|
+
manageButton.innerHTML = `
|
|
785
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
786
|
+
<path d="M12 15v2m0 0v2m0-2h2m-2 0H10m11-7l-1.5-1.5M21 12l-1.5 1.5M3 12l1.5 1.5M3 12l1.5-1.5M12 3v2m0 14v2"></path>
|
|
787
|
+
</svg>
|
|
788
|
+
<span>Manage Pinned Tools</span>
|
|
789
|
+
`;
|
|
790
|
+
manageButton.addEventListener('click', (e) => {
|
|
791
|
+
e.preventDefault();
|
|
792
|
+
this.openManageModal();
|
|
793
|
+
});
|
|
794
|
+
overflowFooter.appendChild(manageButton);
|
|
795
|
+
this.overflowMenu.appendChild(overflowFooter);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
render() {
|
|
799
|
+
debug('render() called');
|
|
800
|
+
// Clear toolbar and overflow menu
|
|
801
|
+
this.toolbar.innerHTML = '';
|
|
802
|
+
this.overflowMenu.innerHTML = '';
|
|
803
|
+
const pinnedTools = this.tools.filter((t) => t.isPinned);
|
|
804
|
+
const overflowTools = this.tools.filter((t) => !t.isPinned);
|
|
805
|
+
debug(`Rendering: ${pinnedTools.length} pinned, ${overflowTools.length} unpinned`);
|
|
806
|
+
// Check if we're in mobile view (Bootstrap md breakpoint: < 768px)
|
|
807
|
+
const isMobile = typeof dntShim.dntGlobalThis !== 'undefined' &&
|
|
808
|
+
globalThis.innerWidth < MOBILE_BREAKPOINT;
|
|
809
|
+
const mobileLimit = 4;
|
|
810
|
+
debug(`isMobile: ${isMobile}, window width: ${typeof dntShim.dntGlobalThis !== 'undefined' ? globalThis.innerWidth : 'N/A'}`);
|
|
811
|
+
// In mobile, only show first 4 pinned tools in toolbar
|
|
812
|
+
const visibleTools = isMobile
|
|
813
|
+
? pinnedTools.slice(0, mobileLimit)
|
|
814
|
+
: pinnedTools;
|
|
815
|
+
const mobileOverflowPinned = isMobile ? pinnedTools.slice(mobileLimit) : [];
|
|
816
|
+
debug(`Visible tools in toolbar: ${visibleTools.length}`);
|
|
817
|
+
debug('Visible tool IDs:', visibleTools.map((t) => t.id).join(', '));
|
|
818
|
+
// Render visible pinned tools in toolbar
|
|
819
|
+
visibleTools.forEach((tool, index) => {
|
|
820
|
+
debug(`Rendering tool ${index + 1}/${visibleTools.length}: "${tool.label}" (${tool.id})`);
|
|
821
|
+
const wrapper = document.createElement('span');
|
|
822
|
+
wrapper.classList.add(CSS_PREFIX + '__item');
|
|
823
|
+
wrapper.setAttribute('data-tool-id', tool.id);
|
|
824
|
+
// Check if this is a dropdown with sub-items
|
|
825
|
+
const isDropdown = tool.element.content !== undefined;
|
|
826
|
+
if (isDropdown) {
|
|
827
|
+
// For dropdowns, we'll render it but hide the default dropdown menu
|
|
828
|
+
const { dom, update } = tool.element.render(this.editorView);
|
|
829
|
+
// Find and hide the default dropdown menu if it exists
|
|
830
|
+
const dropdownMenu = dom.querySelector('.kb-dropdown__content');
|
|
831
|
+
if (dropdownMenu) {
|
|
832
|
+
dropdownMenu.style.display = 'none';
|
|
833
|
+
}
|
|
834
|
+
// Intercept all clicks on the button/label
|
|
835
|
+
const button = dom.querySelector('button');
|
|
836
|
+
const label = dom.querySelector('.kb-dropdown__label');
|
|
837
|
+
const clickHandler = (e) => {
|
|
838
|
+
e.preventDefault();
|
|
839
|
+
e.stopPropagation();
|
|
840
|
+
e.stopImmediatePropagation();
|
|
841
|
+
this.showPinnedDropdown(tool, wrapper);
|
|
842
|
+
};
|
|
843
|
+
if (button) {
|
|
844
|
+
button.addEventListener('click', clickHandler, { capture: true });
|
|
845
|
+
button.addEventListener('mousedown', (e) => {
|
|
846
|
+
e.preventDefault();
|
|
847
|
+
e.stopPropagation();
|
|
848
|
+
}, { capture: true });
|
|
849
|
+
}
|
|
850
|
+
if (label) {
|
|
851
|
+
label.addEventListener('click', clickHandler, { capture: true });
|
|
852
|
+
label.addEventListener('mousedown', (e) => {
|
|
853
|
+
e.preventDefault();
|
|
854
|
+
e.stopPropagation();
|
|
855
|
+
}, { capture: true });
|
|
856
|
+
}
|
|
857
|
+
wrapper.appendChild(dom);
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
// Regular items render normally
|
|
861
|
+
const { dom, update } = tool.element.render(this.editorView);
|
|
862
|
+
wrapper.appendChild(dom);
|
|
863
|
+
}
|
|
864
|
+
this.toolbar.appendChild(wrapper);
|
|
865
|
+
});
|
|
866
|
+
// Add separator before overflow button
|
|
867
|
+
if (overflowTools.length > 0 || mobileOverflowPinned.length > 0) {
|
|
868
|
+
const separator = document.createElement('div');
|
|
869
|
+
separator.classList.add(CSS_PREFIX + '__separator');
|
|
870
|
+
this.toolbar.appendChild(separator);
|
|
871
|
+
}
|
|
872
|
+
// Add overflow toggle button
|
|
873
|
+
if (overflowTools.length > 0 || mobileOverflowPinned.length > 0) {
|
|
874
|
+
const overflowToggle = document.createElement('button');
|
|
875
|
+
overflowToggle.type = 'button';
|
|
876
|
+
overflowToggle.className = CSS_PREFIX + '__overflow-toggle';
|
|
877
|
+
overflowToggle.setAttribute('aria-haspopup', 'true');
|
|
878
|
+
overflowToggle.setAttribute('aria-expanded', 'false');
|
|
879
|
+
overflowToggle.title = 'More';
|
|
880
|
+
overflowToggle.innerHTML = `
|
|
881
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
882
|
+
<circle cx="5" cy="12" r="2"/>
|
|
883
|
+
<circle cx="12" cy="12" r="2"/>
|
|
884
|
+
<circle cx="19" cy="12" r="2"/>
|
|
885
|
+
</svg>
|
|
886
|
+
`;
|
|
887
|
+
overflowToggle.addEventListener('click', (e) => {
|
|
888
|
+
e.preventDefault();
|
|
889
|
+
e.stopPropagation();
|
|
890
|
+
const isOpen = this.overflowMenu.style.display !== 'none';
|
|
891
|
+
this.overflowMenu.style.display = isOpen ? 'none' : 'block';
|
|
892
|
+
overflowToggle.setAttribute('aria-expanded', String(!isOpen));
|
|
893
|
+
// Add/remove close handler based on open state
|
|
894
|
+
const doc = this.editorView.dom.ownerDocument || document;
|
|
895
|
+
if (!isOpen) {
|
|
896
|
+
// Opening - reset submenu stack to show main menu
|
|
897
|
+
this.submenuStack = [];
|
|
898
|
+
this.renderOverflowMenu();
|
|
899
|
+
// Opening - add close handler after a short delay
|
|
900
|
+
setTimeout(() => {
|
|
901
|
+
if (this.closeOverflowHandler) {
|
|
902
|
+
doc.removeEventListener('click', this.closeOverflowHandler);
|
|
903
|
+
}
|
|
904
|
+
this.closeOverflowHandler = (e) => {
|
|
905
|
+
// Don't interfere with editor clicks
|
|
906
|
+
const target = e.target;
|
|
907
|
+
const editorDom = this.editorView.dom;
|
|
908
|
+
// Check if click is inside editor
|
|
909
|
+
if (editorDom.contains(target)) {
|
|
910
|
+
return; // Let editor handle it
|
|
911
|
+
}
|
|
912
|
+
if (!this.overflowMenu.contains(target) &&
|
|
913
|
+
!this.toolbar.contains(target)) {
|
|
914
|
+
this.overflowMenu.style.display = 'none';
|
|
915
|
+
overflowToggle.setAttribute('aria-expanded', 'false');
|
|
916
|
+
// Clear submenu stack when closing
|
|
917
|
+
this.submenuStack = [];
|
|
918
|
+
if (this.closeOverflowHandler) {
|
|
919
|
+
doc.removeEventListener('click', this.closeOverflowHandler);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
doc.addEventListener('click', this.closeOverflowHandler);
|
|
924
|
+
}, 0);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
// Closing - remove close handler and clear submenu stack
|
|
928
|
+
if (this.closeOverflowHandler) {
|
|
929
|
+
doc.removeEventListener('click', this.closeOverflowHandler);
|
|
930
|
+
}
|
|
931
|
+
this.submenuStack = [];
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
this.toolbar.appendChild(overflowToggle);
|
|
935
|
+
}
|
|
936
|
+
// Render overflow menu content
|
|
937
|
+
this.renderOverflowMenu();
|
|
938
|
+
console.log('[kb-custom-menu] render() complete. Toolbar children:', this.toolbar.children.length);
|
|
939
|
+
console.log('[kb-custom-menu] Toolbar HTML:', this.toolbar.innerHTML.substring(0, 200));
|
|
940
|
+
}
|
|
941
|
+
openManageModal() {
|
|
942
|
+
// Close overflow menu
|
|
943
|
+
this.overflowMenu.style.display = 'none';
|
|
944
|
+
// Create modal backdrop
|
|
945
|
+
const backdrop = document.createElement('div');
|
|
946
|
+
backdrop.classList.add(CSS_PREFIX + '__modal-backdrop');
|
|
947
|
+
// Create modal
|
|
948
|
+
this.modal = document.createElement('div');
|
|
949
|
+
this.modal.classList.add(CSS_PREFIX + '__modal');
|
|
950
|
+
// Modal header
|
|
951
|
+
const header = document.createElement('div');
|
|
952
|
+
header.classList.add(CSS_PREFIX + '__modal-header');
|
|
953
|
+
header.innerHTML = `
|
|
954
|
+
<h2>Manage Pinned Tools</h2>
|
|
955
|
+
<button type="button" class="${CSS_PREFIX}__modal-close" aria-label="Close">
|
|
956
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
957
|
+
<path d="M6 6l12 12M6 18L18 6"/>
|
|
958
|
+
</svg>
|
|
959
|
+
</button>
|
|
960
|
+
`;
|
|
961
|
+
// Modal message
|
|
962
|
+
const message = document.createElement('div');
|
|
963
|
+
message.classList.add(CSS_PREFIX + '__modal-message');
|
|
964
|
+
message.textContent = `Maximum pinned: ${MAX_PINNED_ITEMS}`;
|
|
965
|
+
// Modal content (tool list)
|
|
966
|
+
const content = document.createElement('div');
|
|
967
|
+
content.classList.add(CSS_PREFIX + '__modal-content');
|
|
968
|
+
const toolList = document.createElement('div');
|
|
969
|
+
toolList.classList.add(CSS_PREFIX + '__tool-list');
|
|
970
|
+
this.tools.forEach((tool) => {
|
|
971
|
+
const toolItem = document.createElement('label');
|
|
972
|
+
toolItem.classList.add(CSS_PREFIX + '__tool-item');
|
|
973
|
+
const checkbox = document.createElement('input');
|
|
974
|
+
checkbox.type = 'checkbox';
|
|
975
|
+
checkbox.checked = tool.isPinned;
|
|
976
|
+
checkbox.disabled = false;
|
|
977
|
+
const pinnedCount = this.tools.filter((t) => t.isPinned).length;
|
|
978
|
+
// Disable unchecked items if we've reached the limit
|
|
979
|
+
if (!tool.isPinned && pinnedCount >= MAX_PINNED_ITEMS) {
|
|
980
|
+
checkbox.disabled = true;
|
|
981
|
+
toolItem.classList.add(CSS_PREFIX + '__tool-item--disabled');
|
|
982
|
+
}
|
|
983
|
+
checkbox.addEventListener('change', () => {
|
|
984
|
+
const currentPinnedCount = this.tools.filter((t) => t.isPinned).length;
|
|
985
|
+
if (checkbox.checked) {
|
|
986
|
+
if (currentPinnedCount >= MAX_PINNED_ITEMS) {
|
|
987
|
+
checkbox.checked = false;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
tool.isPinned = true;
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
tool.isPinned = false;
|
|
994
|
+
}
|
|
995
|
+
this.savePinnedState();
|
|
996
|
+
this.updateModalState(toolList);
|
|
997
|
+
});
|
|
998
|
+
const label = document.createElement('span');
|
|
999
|
+
label.textContent = tool.label;
|
|
1000
|
+
toolItem.appendChild(checkbox);
|
|
1001
|
+
toolItem.appendChild(label);
|
|
1002
|
+
toolList.appendChild(toolItem);
|
|
1003
|
+
});
|
|
1004
|
+
content.appendChild(toolList);
|
|
1005
|
+
// Modal footer
|
|
1006
|
+
const footer = document.createElement('div');
|
|
1007
|
+
footer.classList.add(CSS_PREFIX + '__modal-footer');
|
|
1008
|
+
footer.innerHTML = `
|
|
1009
|
+
<button type="button" class="${CSS_PREFIX}__modal-button ${CSS_PREFIX}__modal-button--primary">
|
|
1010
|
+
Done
|
|
1011
|
+
</button>
|
|
1012
|
+
`;
|
|
1013
|
+
// Assemble modal
|
|
1014
|
+
this.modal.appendChild(header);
|
|
1015
|
+
this.modal.appendChild(message);
|
|
1016
|
+
this.modal.appendChild(content);
|
|
1017
|
+
this.modal.appendChild(footer);
|
|
1018
|
+
backdrop.appendChild(this.modal);
|
|
1019
|
+
// Add to DOM
|
|
1020
|
+
(this.editorView.dom.ownerDocument || document).body.appendChild(backdrop);
|
|
1021
|
+
// Close handlers
|
|
1022
|
+
const closeModal = () => {
|
|
1023
|
+
backdrop.remove();
|
|
1024
|
+
this.modal = null;
|
|
1025
|
+
this.render(); // Re-render toolbar with new pinned state
|
|
1026
|
+
};
|
|
1027
|
+
header.querySelector('.' + CSS_PREFIX + '__modal-close')?.addEventListener('click', closeModal);
|
|
1028
|
+
footer.querySelector('.' + CSS_PREFIX + '__modal-button')?.addEventListener('click', closeModal);
|
|
1029
|
+
backdrop.addEventListener('click', (e) => {
|
|
1030
|
+
if (e.target === backdrop) {
|
|
1031
|
+
closeModal();
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
updateModalState(toolList) {
|
|
1036
|
+
const pinnedCount = this.tools.filter((t) => t.isPinned).length;
|
|
1037
|
+
const items = toolList.querySelectorAll('.' + CSS_PREFIX + '__tool-item');
|
|
1038
|
+
items.forEach((item, index) => {
|
|
1039
|
+
const checkbox = item.querySelector('input[type="checkbox"]');
|
|
1040
|
+
const tool = this.tools[index];
|
|
1041
|
+
if (!tool.isPinned && pinnedCount >= MAX_PINNED_ITEMS) {
|
|
1042
|
+
checkbox.disabled = true;
|
|
1043
|
+
item.classList.add(CSS_PREFIX + '__tool-item--disabled');
|
|
1044
|
+
}
|
|
1045
|
+
else if (!tool.isPinned) {
|
|
1046
|
+
checkbox.disabled = false;
|
|
1047
|
+
item.classList.remove(CSS_PREFIX + '__tool-item--disabled');
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
setupResize() {
|
|
1052
|
+
let isResizing = false;
|
|
1053
|
+
let startY = 0;
|
|
1054
|
+
let startHeight = 0;
|
|
1055
|
+
const onMouseDown = (e) => {
|
|
1056
|
+
isResizing = true;
|
|
1057
|
+
startY = e.clientY;
|
|
1058
|
+
startHeight = this.editorContainer.offsetHeight;
|
|
1059
|
+
// Add resizing class for visual feedback
|
|
1060
|
+
this.wrapper.classList.add(CSS_PREFIX + '__wrapper--resizing');
|
|
1061
|
+
// Prevent text selection while dragging
|
|
1062
|
+
e.preventDefault();
|
|
1063
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
1064
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
1065
|
+
};
|
|
1066
|
+
const onMouseMove = (e) => {
|
|
1067
|
+
if (!isResizing)
|
|
1068
|
+
return;
|
|
1069
|
+
const deltaY = e.clientY - startY;
|
|
1070
|
+
const newHeight = startHeight + deltaY;
|
|
1071
|
+
// Set minimum and maximum heights
|
|
1072
|
+
const minHeight = 200;
|
|
1073
|
+
const maxHeight = globalThis.innerHeight - 100;
|
|
1074
|
+
if (newHeight >= minHeight && newHeight <= maxHeight) {
|
|
1075
|
+
this.editorContainer.style.height = newHeight + 'px';
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
const onMouseUp = () => {
|
|
1079
|
+
if (!isResizing)
|
|
1080
|
+
return;
|
|
1081
|
+
isResizing = false;
|
|
1082
|
+
this.wrapper.classList.remove(CSS_PREFIX + '__wrapper--resizing');
|
|
1083
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
1084
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
1085
|
+
};
|
|
1086
|
+
this.resizeHandle.addEventListener('mousedown', onMouseDown);
|
|
1087
|
+
}
|
|
1088
|
+
update(view, prevState) {
|
|
1089
|
+
// Re-render tools to update their state (original approach)
|
|
1090
|
+
// Note: This is less efficient but more reliable than storing update functions
|
|
1091
|
+
this.tools.forEach((tool) => {
|
|
1092
|
+
tool.element.render(this.editorView);
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
destroy() {
|
|
1096
|
+
// Clean up event listeners
|
|
1097
|
+
const doc = this.editorView.dom.ownerDocument || document;
|
|
1098
|
+
if (this.closeOverflowHandler) {
|
|
1099
|
+
doc.removeEventListener('click', this.closeOverflowHandler);
|
|
1100
|
+
this.closeOverflowHandler = null;
|
|
1101
|
+
}
|
|
1102
|
+
if (this.closePinnedDropdownHandler) {
|
|
1103
|
+
doc.removeEventListener('click', this.closePinnedDropdownHandler);
|
|
1104
|
+
this.closePinnedDropdownHandler = null;
|
|
1105
|
+
}
|
|
1106
|
+
// Clean up pinned dropdown
|
|
1107
|
+
if (this.pinnedDropdownMenu) {
|
|
1108
|
+
this.pinnedDropdownMenu.remove();
|
|
1109
|
+
this.pinnedDropdownMenu = null;
|
|
1110
|
+
}
|
|
1111
|
+
// Clean up modal
|
|
1112
|
+
if (this.modal) {
|
|
1113
|
+
this.modal.remove();
|
|
1114
|
+
this.modal = null;
|
|
1115
|
+
}
|
|
1116
|
+
// Clean up DOM
|
|
1117
|
+
if (this.wrapper.parentNode) {
|
|
1118
|
+
this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
export class CustomMenuPlugin extends Plugin {
|
|
1123
|
+
constructor(editor, options) {
|
|
1124
|
+
super({
|
|
1125
|
+
view(editorView) {
|
|
1126
|
+
return new CustomMenuView(editorView, editor, options.content);
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
}
|