html-overlay-node 0.1.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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * ContextMenu - Extensible context menu for nodes and groups
3
+ * Provides right-click functionality with customizable menu items
4
+ */
5
+ export class ContextMenu {
6
+ constructor({ graph, hooks, renderer, commandStack }) {
7
+ this.graph = graph;
8
+ this.hooks = hooks;
9
+ this.renderer = renderer;
10
+ this.commandStack = commandStack;
11
+
12
+ this.items = [];
13
+ this.visible = false;
14
+ this.target = null;
15
+ this.position = { x: 0, y: 0 };
16
+
17
+ this.menuElement = this._createMenuElement();
18
+
19
+ // Close menu on any click outside
20
+ this._onDocumentClick = (e) => {
21
+ if (!this.menuElement.contains(e.target)) {
22
+ this.hide();
23
+ }
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Add a menu item
29
+ * @param {string} id - Unique identifier for the menu item
30
+ * @param {string} label - Display label
31
+ * @param {Object} options - Options
32
+ * @param {Function} options.action - Action to execute (receives target)
33
+ * @param {Array} options.submenu - Submenu items
34
+ * @param {Function} options.condition - Optional condition to show item (receives target)
35
+ * @param {number} options.order - Optional sort order (default: 100)
36
+ */
37
+ addItem(id, label, options = {}) {
38
+ const { action, submenu, condition, order = 100 } = options;
39
+
40
+ // Either action or submenu must be provided
41
+ if (!action && !submenu) {
42
+ console.error("ContextMenu.addItem: either action or submenu is required");
43
+ return;
44
+ }
45
+
46
+ // Remove existing item with same id
47
+ this.removeItem(id);
48
+
49
+ this.items.push({
50
+ id,
51
+ label,
52
+ action,
53
+ submenu,
54
+ condition,
55
+ order,
56
+ });
57
+
58
+ // Sort by order
59
+ this.items.sort((a, b) => a.order - b.order);
60
+ }
61
+
62
+ /**
63
+ * Remove a menu item by id
64
+ * @param {string} id - Item id to remove
65
+ */
66
+ removeItem(id) {
67
+ this.items = this.items.filter((item) => item.id !== id);
68
+ }
69
+
70
+ /**
71
+ * Show the context menu
72
+ * @param {Object} target - Target node/group
73
+ * @param {number} x - Screen x position
74
+ * @param {number} y - Screen y position
75
+ * @param {Object} worldPos - Optional world position {x, y}
76
+ */
77
+ show(target, x, y, worldPos = null) {
78
+ this.target = target;
79
+ this.position = { x, y };
80
+ this.worldPosition = worldPos; // Store world position for node creation
81
+ this.visible = true;
82
+
83
+ this._renderItems();
84
+
85
+ // Position menu
86
+ this.menuElement.style.left = `${x}px`;
87
+ this.menuElement.style.top = `${y}px`;
88
+ this.menuElement.style.display = "block";
89
+
90
+ // Adjust position if menu goes off-screen
91
+ requestAnimationFrame(() => {
92
+ const rect = this.menuElement.getBoundingClientRect();
93
+ const vw = window.innerWidth;
94
+ const vh = window.innerHeight;
95
+
96
+ let adjustedX = x;
97
+ let adjustedY = y;
98
+
99
+ if (rect.right > vw) {
100
+ adjustedX = vw - rect.width - 5;
101
+ }
102
+ if (rect.bottom > vh) {
103
+ adjustedY = vh - rect.height - 5;
104
+ }
105
+
106
+ this.menuElement.style.left = `${adjustedX}px`;
107
+ this.menuElement.style.top = `${adjustedY}px`;
108
+ });
109
+
110
+ // Listen for clicks to close menu
111
+ document.addEventListener("click", this._onDocumentClick);
112
+ }
113
+
114
+ /**
115
+ * Hide the context menu
116
+ */
117
+ hide() {
118
+ this.visible = false;
119
+ this.target = null;
120
+
121
+ // Clean up any open submenus
122
+ const allSubmenus = document.querySelectorAll(".context-submenu");
123
+ allSubmenus.forEach(submenu => submenu.remove());
124
+
125
+ this.menuElement.style.display = "none";
126
+ document.removeEventListener("click", this._onDocumentClick);
127
+ }
128
+
129
+ /**
130
+ * Cleanup
131
+ */
132
+ destroy() {
133
+ this.hide();
134
+ if (this.menuElement && this.menuElement.parentNode) {
135
+ this.menuElement.parentNode.removeChild(this.menuElement);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Create the menu DOM element
141
+ * @private
142
+ */
143
+ _createMenuElement() {
144
+ const menu = document.createElement("div");
145
+ menu.className = "html-overlay-node-context-menu";
146
+
147
+ // Styling
148
+ Object.assign(menu.style, {
149
+ position: "fixed",
150
+ display: "none",
151
+ minWidth: "180px",
152
+ backgroundColor: "#2a2a2e",
153
+ border: "1px solid #444",
154
+ borderRadius: "6px",
155
+ boxShadow: "0 4px 16px rgba(0, 0, 0, 0.4)",
156
+ zIndex: "10000",
157
+ padding: "4px 0",
158
+ fontFamily: "system-ui, -apple-system, sans-serif",
159
+ fontSize: "13px",
160
+ color: "#e9e9ef",
161
+ });
162
+
163
+ document.body.appendChild(menu);
164
+ return menu;
165
+ }
166
+
167
+ /**
168
+ * Render menu items based on current target
169
+ * @private
170
+ */
171
+ _renderItems() {
172
+ this.menuElement.innerHTML = "";
173
+
174
+ const visibleItems = this.items.filter((item) => {
175
+ if (item.condition) {
176
+ return item.condition(this.target);
177
+ }
178
+ return true;
179
+ });
180
+
181
+ if (visibleItems.length === 0) {
182
+ this.hide();
183
+ return;
184
+ }
185
+
186
+ visibleItems.forEach((item) => {
187
+ const itemEl = document.createElement("div");
188
+ itemEl.className = "context-menu-item";
189
+
190
+ // Create item content wrapper
191
+ const contentWrapper = document.createElement("div");
192
+ Object.assign(contentWrapper.style, {
193
+ display: "flex",
194
+ alignItems: "center",
195
+ justifyContent: "space-between",
196
+ width: "100%",
197
+ });
198
+
199
+ const labelEl = document.createElement("span");
200
+ labelEl.textContent = item.label;
201
+ contentWrapper.appendChild(labelEl);
202
+
203
+ // Add arrow indicator if item has submenu
204
+ if (item.submenu) {
205
+ const arrow = document.createElement("span");
206
+ arrow.textContent = "▶";
207
+ arrow.style.marginLeft = "12px";
208
+ arrow.style.fontSize = "10px";
209
+ arrow.style.opacity = "0.7";
210
+ contentWrapper.appendChild(arrow);
211
+ }
212
+
213
+ itemEl.appendChild(contentWrapper);
214
+
215
+ Object.assign(itemEl.style, {
216
+ padding: "4px 8px",
217
+ cursor: "pointer",
218
+ transition: "background-color 0.15s ease",
219
+ userSelect: "none",
220
+ position: "relative",
221
+ });
222
+
223
+ // Hover effect
224
+ itemEl.addEventListener("mouseenter", () => {
225
+ itemEl.style.backgroundColor = "#3a3a3e";
226
+
227
+ // Clear any pending hide timeout
228
+ if (itemEl._hideTimeout) {
229
+ clearTimeout(itemEl._hideTimeout);
230
+ itemEl._hideTimeout = null;
231
+ }
232
+
233
+ // Show submenu if exists
234
+ if (item.submenu) {
235
+ this._showSubmenu(item.submenu, itemEl);
236
+ }
237
+ });
238
+
239
+ itemEl.addEventListener("mouseleave", (e) => {
240
+ itemEl.style.backgroundColor = "transparent";
241
+
242
+ // Hide submenu with delay if moving to submenu
243
+ if (item.submenu) {
244
+ const submenuEl = itemEl._submenuElement;
245
+ if (submenuEl) {
246
+ // Add delay before hiding to allow mouse to reach submenu
247
+ itemEl._hideTimeout = setTimeout(() => {
248
+ if (!submenuEl.contains(document.elementFromPoint(e.clientX, e.clientY))) {
249
+ this._hideSubmenu(itemEl);
250
+ }
251
+ }, 150); // 150ms delay
252
+ }
253
+ }
254
+ });
255
+
256
+ // Click handler
257
+ if (!item.submenu) {
258
+ itemEl.addEventListener("click", (e) => {
259
+ e.stopPropagation();
260
+ item.action(this.target);
261
+ this.hide();
262
+ });
263
+ }
264
+
265
+ this.menuElement.appendChild(itemEl);
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Show submenu for an item
271
+ * @private
272
+ */
273
+ _showSubmenu(submenuItems, parentItemEl) {
274
+ // Remove any existing submenu
275
+ this._hideSubmenu(parentItemEl);
276
+
277
+ const submenuEl = document.createElement("div");
278
+ submenuEl.className = "context-submenu";
279
+
280
+ Object.assign(submenuEl.style, {
281
+ position: "fixed",
282
+ minWidth: "140px",
283
+ backgroundColor: "#2a2a2e",
284
+ border: "1px solid #444",
285
+ borderRadius: "6px",
286
+ boxShadow: "0 4px 16px rgba(0, 0, 0, 0.4)",
287
+ zIndex: "10001",
288
+ padding: "4px 0",
289
+ fontFamily: "system-ui, -apple-system, sans-serif",
290
+ fontSize: "13px",
291
+ color: "#e9e9ef",
292
+ });
293
+
294
+ submenuItems.forEach((subItem) => {
295
+ const subItemEl = document.createElement("div");
296
+ subItemEl.className = "context-submenu-item";
297
+
298
+ // Create content with color swatch if available
299
+ const contentWrapper = document.createElement("div");
300
+ Object.assign(contentWrapper.style, {
301
+ display: "flex",
302
+ alignItems: "center",
303
+ gap: "8px",
304
+ });
305
+
306
+ // Color swatch
307
+ if (subItem.color) {
308
+ const swatch = document.createElement("div");
309
+ Object.assign(swatch.style, {
310
+ width: "16px",
311
+ height: "16px",
312
+ borderRadius: "3px",
313
+ backgroundColor: subItem.color,
314
+ border: "1px solid #555",
315
+ flexShrink: "0",
316
+ });
317
+ contentWrapper.appendChild(swatch);
318
+ }
319
+
320
+ const labelEl = document.createElement("span");
321
+ labelEl.textContent = subItem.label;
322
+ contentWrapper.appendChild(labelEl);
323
+
324
+ subItemEl.appendChild(contentWrapper);
325
+
326
+ Object.assign(subItemEl.style, {
327
+ padding: "4px 8px",
328
+ cursor: "pointer",
329
+ transition: "background-color 0.15s ease",
330
+ userSelect: "none",
331
+ });
332
+
333
+ subItemEl.addEventListener("mouseenter", () => {
334
+ subItemEl.style.backgroundColor = "#3a3a3e";
335
+ });
336
+
337
+ subItemEl.addEventListener("mouseleave", () => {
338
+ subItemEl.style.backgroundColor = "transparent";
339
+ });
340
+
341
+ subItemEl.addEventListener("click", (e) => {
342
+ e.stopPropagation();
343
+ subItem.action(this.target);
344
+ this.hide();
345
+ });
346
+
347
+ submenuEl.appendChild(subItemEl);
348
+ });
349
+
350
+ // Keep submenu open when hovering over it
351
+ submenuEl.addEventListener("mouseenter", () => {
352
+ // Clear parent's hide timeout
353
+ if (parentItemEl._hideTimeout) {
354
+ clearTimeout(parentItemEl._hideTimeout);
355
+ parentItemEl._hideTimeout = null;
356
+ }
357
+ });
358
+
359
+ submenuEl.addEventListener("mouseleave", (e) => {
360
+ if (!parentItemEl.contains(e.relatedTarget)) {
361
+ this._hideSubmenu(parentItemEl);
362
+ }
363
+ });
364
+
365
+ document.body.appendChild(submenuEl);
366
+ parentItemEl._submenuElement = submenuEl;
367
+
368
+ // Position submenu next to parent item
369
+ requestAnimationFrame(() => {
370
+ const parentRect = parentItemEl.getBoundingClientRect();
371
+ const submenuRect = submenuEl.getBoundingClientRect();
372
+
373
+ let left = parentRect.right + 2;
374
+ let top = parentRect.top;
375
+
376
+ // Adjust if submenu goes off-screen
377
+ if (left + submenuRect.width > window.innerWidth) {
378
+ left = parentRect.left - submenuRect.width - 2;
379
+ }
380
+
381
+ if (top + submenuRect.height > window.innerHeight) {
382
+ top = window.innerHeight - submenuRect.height - 5;
383
+ }
384
+
385
+ submenuEl.style.left = `${left}px`;
386
+ submenuEl.style.top = `${top}px`;
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Hide submenu for an item
392
+ * @private
393
+ */
394
+ _hideSubmenu(parentItemEl) {
395
+ if (parentItemEl._submenuElement) {
396
+ parentItemEl._submenuElement.remove();
397
+ parentItemEl._submenuElement = null;
398
+ }
399
+ }
400
+ }