Skip to content

Commit d936283

Browse files
committed
feat(tree-view): implement context menu for the tree-view (Copy Path/Value, Extract, Remove)
1 parent db23cf1 commit d936283

3 files changed

Lines changed: 216 additions & 2 deletions

File tree

src/css/styles.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,50 @@ body {
548548
background: var(--color-accent-light);
549549
}
550550

551+
/* Context Menu */
552+
.context-menu {
553+
position: absolute;
554+
background: var(--color-bg-primary);
555+
border: 1px solid var(--color-border);
556+
border-radius: var(--radius-md);
557+
box-shadow: var(--shadow-lg);
558+
padding: var(--space-xs) 0;
559+
min-width: 160px;
560+
z-index: 2000;
561+
font-family: var(--font-primary);
562+
font-size: var(--font-size-sm);
563+
display: flex;
564+
flex-direction: column;
565+
}
566+
567+
.context-menu-item {
568+
padding: var(--space-sm) var(--space-md);
569+
color: var(--color-text-primary);
570+
cursor: pointer;
571+
display: flex;
572+
align-items: center;
573+
gap: var(--space-sm);
574+
transition: background 0.15s ease;
575+
}
576+
577+
.context-menu-item:hover {
578+
background: var(--color-bg-hover);
579+
}
580+
581+
.context-menu-item.danger {
582+
color: var(--color-error);
583+
}
584+
585+
.context-menu-item.danger:hover {
586+
background: rgba(239, 68, 68, 0.1);
587+
}
588+
589+
.context-menu-divider {
590+
height: 1px;
591+
background: var(--color-border);
592+
margin: var(--space-xs) 0;
593+
}
594+
551595
/* Table View */
552596
.table-container {
553597
flex: 1;

src/js/editor/jsonEditor.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,8 @@ class JsonEditor {
437437
}
438438

439439
onContentChange() {
440-
this.updateStatusBar();
441440
this.saveToHistory();
441+
this.updateStatusBar();
442442

443443
// Persist content to IndexedDB (Async)
444444
if (window.StorageUtils) {

src/js/editor/treeView.js

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ class TreeView {
1010
this.init();
1111
}
1212

13-
init() {}
13+
init() {
14+
this.contextMenu = null;
15+
document.addEventListener('click', (e) => {
16+
this.closeContextMenu();
17+
});
18+
}
1419

1520
/**
1621
* Render JSON data as tree
@@ -97,6 +102,28 @@ class TreeView {
97102
e.stopPropagation();
98103
});
99104

105+
// Right-click context menu
106+
header.addEventListener('contextmenu', (e) => {
107+
e.preventDefault();
108+
e.stopPropagation();
109+
110+
const jsPath = this.getJsPath(currentPathArray);
111+
const jsonPointer = this.getJsonPointer(currentPathArray);
112+
113+
this.showContextMenu(e.clientX, e.clientY, {
114+
jsPath,
115+
jsonPointer,
116+
value,
117+
key,
118+
pathArray: currentPathArray
119+
});
120+
121+
this.container
122+
.querySelectorAll('.tree-node-header.selected')
123+
.forEach((el) => el.classList.remove('selected'));
124+
header.classList.add('selected');
125+
});
126+
100127
// Toggle arrow for expandable nodes
101128
const toggle = document.createElement('span');
102129
toggle.className = 'tree-toggle' + (isExpanded ? ' expanded' : '');
@@ -252,6 +279,149 @@ class TreeView {
252279
this.render();
253280
}
254281

282+
showContextMenu(x, y, data) {
283+
this.closeContextMenu();
284+
285+
const menu = document.createElement('div');
286+
menu.className = 'context-menu';
287+
menu.style.left = `${x}px`;
288+
menu.style.top = `${y}px`;
289+
290+
const createItem = (label, icon, onClick, isDanger = false) => {
291+
const item = document.createElement('div');
292+
item.className = `context-menu-item ${isDanger ? 'danger' : ''}`;
293+
item.innerHTML = `<i data-lucide="${icon}" style="width: 14px; height: 14px;"></i> <span>${label}</span>`;
294+
item.addEventListener('click', (e) => {
295+
e.stopPropagation();
296+
this.closeContextMenu();
297+
onClick();
298+
});
299+
return item;
300+
};
301+
302+
const createDivider = () => {
303+
const divider = document.createElement('div');
304+
divider.className = 'context-menu-divider';
305+
return divider;
306+
};
307+
308+
const copyToClipboard = async (text, successMsg) => {
309+
try {
310+
await navigator.clipboard.writeText(text);
311+
if (window.App) App.showToast(successMsg, 'success');
312+
} catch (err) {
313+
if (window.App) App.showToast('Copy failed', 'error');
314+
}
315+
};
316+
317+
// --- Menu Items ---
318+
319+
// 1. Copy Key (if not root)
320+
if (data.key !== null && data.key !== undefined && data.key !== '') {
321+
menu.appendChild(createItem('Copy Key', 'copy', () => copyToClipboard(String(data.key), 'Key copied')));
322+
}
323+
324+
// 2. Copy Value / Content
325+
const valueString = typeof data.value === 'object' ? JSON.stringify(data.value, null, 2) : String(data.value);
326+
const valueLabel = typeof data.value === 'object' ? 'Copy Content (JSON)' : 'Copy Value';
327+
menu.appendChild(createItem(valueLabel, 'copy', () => copyToClipboard(valueString, 'Content copied')));
328+
329+
menu.appendChild(createDivider());
330+
331+
// 3. Copy Paths
332+
menu.appendChild(createItem('Copy JS Path', 'code', () => copyToClipboard(data.jsPath, 'JS Path copied')));
333+
menu.appendChild(createItem('Copy JSON Pointer', 'link', () => copyToClipboard(data.jsonPointer, 'JSON Pointer copied')));
334+
335+
menu.appendChild(createDivider());
336+
337+
// 4. Extract (Replace content with extracted JSON)
338+
if (typeof data.value === 'object') {
339+
menu.appendChild(createItem('Extract (Replace content)', 'external-link', () => {
340+
if (window.App && this.container) {
341+
const editorPanel = this.container.closest('.editor-instance');
342+
if (editorPanel) {
343+
const instance = App.editors.find(e => e.wrapper === editorPanel);
344+
if (instance) {
345+
instance.setValue(valueString);
346+
App.showToast('Extracted JSON into editor', 'success');
347+
}
348+
}
349+
} else {
350+
copyToClipboard(valueString, 'Extracted content copied');
351+
}
352+
}));
353+
}
354+
355+
// 5. Remove Node
356+
// Removing requires updating the actual JSON text and causing a re-render.
357+
// It's safest to mutate the parsed object and text editor if in sync, but here we can just delete from this.data and re-render.
358+
// However, to sync with Monaco text editor, we need to pass the updated JSON up.
359+
if (data.pathArray.length > 0) { // Don't remove root
360+
menu.appendChild(createItem('Remove Element', 'trash-2', () => {
361+
this.removeNodeAtPath(data.pathArray);
362+
}, true));
363+
}
364+
365+
document.body.appendChild(menu);
366+
this.contextMenu = menu;
367+
368+
if (window.lucide) {
369+
lucide.createIcons({ root: menu });
370+
}
371+
372+
// Adjust position if it goes off screen
373+
const rect = menu.getBoundingClientRect();
374+
if (rect.right > window.innerWidth) {
375+
menu.style.left = `${window.innerWidth - rect.width - 10}px`;
376+
}
377+
if (rect.bottom > window.innerHeight) {
378+
menu.style.top = `${window.innerHeight - rect.height - 10}px`;
379+
}
380+
}
381+
382+
closeContextMenu() {
383+
if (this.contextMenu && this.contextMenu.parentNode) {
384+
this.contextMenu.parentNode.removeChild(this.contextMenu);
385+
}
386+
this.contextMenu = null;
387+
}
388+
389+
removeNodeAtPath(pathArray) {
390+
if (!pathArray || pathArray.length === 0) return;
391+
392+
// Deep clone data to avoid direct mutation issues, or mutate directly
393+
// Let's mutate directly for simplicity
394+
let current = this.data;
395+
for (let i = 0; i < pathArray.length - 1; i++) {
396+
current = current[pathArray[i]];
397+
}
398+
399+
const lastKey = pathArray[pathArray.length - 1];
400+
401+
if (Array.isArray(current)) {
402+
current.splice(Number(lastKey), 1);
403+
} else {
404+
delete current[lastKey];
405+
}
406+
407+
// Re-render tree
408+
this.render();
409+
410+
// Sync with TextEditor if possible
411+
// We dispatch a custom event or check for global App
412+
if (window.App && this.container) {
413+
// Hacky way to find parent JsonEditor instance to update its text value
414+
const editorPanel = this.container.closest('.editor-instance');
415+
if (editorPanel) {
416+
// App.editors array holds instances
417+
const instance = App.editors.find(e => e.wrapper === editorPanel);
418+
if (instance) {
419+
instance.setValue(JSON.stringify(this.data, null, 2));
420+
}
421+
}
422+
}
423+
}
424+
255425
clear() {
256426
this.data = null;
257427
this.expandedPaths.clear();

0 commit comments

Comments
 (0)