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.
- package/LICENSE +21 -0
- package/dist/example.json +522 -0
- package/dist/html-overlay-node.es.js +3596 -0
- package/dist/html-overlay-node.es.js.map +1 -0
- package/dist/html-overlay-node.umd.js +2 -0
- package/dist/html-overlay-node.umd.js.map +1 -0
- package/index.css +232 -0
- package/package.json +65 -0
- package/readme.md +437 -0
- package/src/core/CommandStack.js +26 -0
- package/src/core/Edge.js +28 -0
- package/src/core/Edge.test.js +73 -0
- package/src/core/Graph.js +267 -0
- package/src/core/Graph.test.js +256 -0
- package/src/core/Group.js +77 -0
- package/src/core/Hooks.js +12 -0
- package/src/core/Hooks.test.js +108 -0
- package/src/core/Node.js +70 -0
- package/src/core/Node.test.js +113 -0
- package/src/core/Registry.js +71 -0
- package/src/core/Registry.test.js +88 -0
- package/src/core/Runner.js +211 -0
- package/src/core/commands.js +125 -0
- package/src/groups/GroupManager.js +116 -0
- package/src/index.js +1030 -0
- package/src/interact/ContextMenu.js +400 -0
- package/src/interact/Controller.js +856 -0
- package/src/minimap/Minimap.js +146 -0
- package/src/render/CanvasRenderer.js +606 -0
- package/src/render/HtmlOverlay.js +161 -0
- package/src/render/hitTest.js +38 -0
- package/src/ui/PropertyPanel.css +277 -0
- package/src/ui/PropertyPanel.js +269 -0
- package/src/utils/utils.js +75 -0
|
@@ -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
|
+
}
|