Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-22.04, windows-latest]
include:
# Apple Silicon (M1/M2/M3)
- platform: macos-latest
args: ''
# Intel Mac
- platform: macos-13
args: ''
- platform: ubuntu-22.04
args: ''
- platform: windows-latest
args: ''

runs-on: ${{ matrix.platform }}

Expand Down Expand Up @@ -44,9 +54,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
key: ${{ runner.os }}-${{ matrix.platform }}-release-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-
${{ runner.os }}-${{ matrix.platform }}-release-cargo-

- name: Build and upload to release
uses: tauri-apps/tauri-action@v0
Expand All @@ -58,3 +68,4 @@ jobs:
releaseBody: 'Download the installer for your platform below.'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ Pre-built installers for Windows, macOS, and Linux are available on the [Release
- **macOS**: download the `.dmg` disk image.
- **Linux**: download the `.AppImage` or `.deb` package.

**macOS note:** if you see a message saying the app is damaged, macOS is blocking it because it is not signed with an Apple certificate. To open it, run this command in Terminal:

```
xattr -cr "/Applications/JSON Tree Editor.app"
```

If you did not move the app to Applications, replace the path with wherever the `.app` file is located.

---

## How it works
Expand Down
19 changes: 18 additions & 1 deletion src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,28 @@
}
.node:hover { border-color: #353a55; }

.node.dragging { opacity: 0.35; }
.node.drag-over-top { border-top: 2px solid #4f6ef7; }
.node.drag-over-bottom { border-bottom: 2px solid #4f6ef7; }
.node.drag-over-child { border: 1px solid #4f6ef7; box-shadow: 0 0 0 2px rgba(79,110,247,0.2); }

/* Ghost label that follows the cursor while dragging */
.drag-ghost {
position: fixed;
pointer-events: none;
background: #252a3d;
border: 1px solid #4f6ef7;
border-radius: 6px;
color: #a0b8ff;
font-size: 0.82rem;
padding: 4px 10px;
z-index: 300;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}

/* Block text selection while a drag is in progress */
body.drag-active { user-select: none; }

.indent-bar { width: 3px; height: 28px; border-radius: 2px; flex-shrink: 0; }
.depth-0 .indent-bar { background: transparent; }
.depth-1 .indent-bar { background: #4f6ef7; opacity: 0.7; }
Expand Down
196 changes: 127 additions & 69 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ function renderNode(node) {
// The visible row.
const div = document.createElement('div');
div.className = `node depth-${Math.min(depth, 4)}`;
div.draggable = true;
div.dataset.id = node.id;

// Colour bar on the left -- its colour changes with depth.
Expand Down Expand Up @@ -274,91 +273,150 @@ function renderNode(node) {

// ── Drag and drop ─────────────────────────────────────────────────────────
//
// Dropping in the top third of a row places the dragged node before that row
// (same parent). Dropping in the bottom third of a container row makes it a
// child of that container. Dropping in the middle places it after the row.

let dragId = null;
// Uses pointer events (mousedown/mousemove/mouseup) instead of the HTML5
// Drag and Drop API. WebView2 on Windows does not fire dragstart reliably
// when draggable is set via JavaScript on divs that contain input elements,
// so the HTML5 approach silently drops all events there.
//
// Only the drag handle triggers a drag. During the move a small ghost label
// follows the cursor. On release, the position within the target row decides
// where the node lands:
// top third -> place before the target row (same parent)
// bottom third -> make a child of the target (containers only)
// middle -> place after the target row (same parent)

let dragId = null;
let dragGhost = null;
let dragOverEl = null; // the .node element currently highlighted
let dragZone = null; // 'top' | 'middle' | 'child'

function setupDrag(el, node) {
el.addEventListener('dragstart', e => {
dragId = node.id;
el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
const handle = el.querySelector('.drag-handle');
if (!handle) return;

handle.addEventListener('mousedown', e => {
if (e.button !== 0) return; // primary button only
e.preventDefault();
e.stopPropagation();
});

el.addEventListener('dragend', () => {
el.classList.remove('dragging');
document.querySelectorAll('.node').forEach(n =>
n.classList.remove('drag-over-top', 'drag-over-bottom', 'drag-over-child')
);
dragId = null;
dragId = node.id;
document.body.classList.add('drag-active');

// Floating ghost label that follows the cursor.
dragGhost = document.createElement('div');
dragGhost.className = 'drag-ghost';
dragGhost.textContent = node.key || '(unnamed)';
dragGhost.style.left = e.clientX + 14 + 'px';
dragGhost.style.top = e.clientY + 4 + 'px';
document.body.appendChild(dragGhost);

document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
});
}

el.addEventListener('dragover', e => {
e.preventDefault();
e.stopPropagation();
if (dragId === null || dragId === node.id) return;
function onDragMove(e) {
if (dragId === null) return;

// Do not allow dropping into one of the node's own descendants.
const descIds = getDescendants(dragId).map(d => d.id);
if (descIds.includes(node.id)) return;
// Keep ghost next to cursor.
if (dragGhost) {
dragGhost.style.left = e.clientX + 14 + 'px';
dragGhost.style.top = e.clientY + 4 + 'px';
}

document.querySelectorAll('.node').forEach(n =>
n.classList.remove('drag-over-top', 'drag-over-bottom', 'drag-over-child')
);
// Temporarily hide the ghost so elementFromPoint sees what is behind it.
if (dragGhost) dragGhost.style.visibility = 'hidden';
const hit = document.elementFromPoint(e.clientX, e.clientY);
if (dragGhost) dragGhost.style.visibility = '';

const { top, height } = el.getBoundingClientRect();
const relY = e.clientY - top;
const third = height / 3;
const isContainer = node.type === 'object' || node.type === 'array';
const nodeEl = hit && hit.closest('.node[data-id]');

if (relY < third) {
el.classList.add('drag-over-top');
} else if (relY > height - third && isContainer) {
el.classList.add('drag-over-child');
} else {
el.classList.add('drag-over-bottom');
}
});
// Clear highlight on the previously hovered row.
if (dragOverEl && dragOverEl !== nodeEl) {
dragOverEl.classList.remove('drag-over-top', 'drag-over-bottom', 'drag-over-child');
}

el.addEventListener('dragleave', () => {
el.classList.remove('drag-over-top', 'drag-over-bottom', 'drag-over-child');
});
if (!nodeEl) {
dragOverEl = null;
dragZone = null;
return;
}

el.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
if (dragId === null || dragId === node.id) return;
const targetId = parseInt(nodeEl.dataset.id, 10);
if (targetId === dragId || getDescendants(dragId).some(d => d.id === targetId)) {
dragOverEl = null;
dragZone = null;
return;
}

const descIds = getDescendants(dragId).map(d => d.id);
if (descIds.includes(node.id)) return;
const targetNode = nodes.find(n => n.id === targetId);
const isContainer = targetNode && (targetNode.type === 'object' || targetNode.type === 'array');

const dragNode = nodes.find(n => n.id === dragId);
const { top, height } = el.getBoundingClientRect();
const relY = e.clientY - top;
const third = height / 3;
const isContainer = node.type === 'object' || node.type === 'array';
const { top, height } = nodeEl.getBoundingClientRect();
const relY = e.clientY - top;
const third = height / 3;

nodes = nodes.filter(n => n.id !== dragId);
const targetIdx = nodes.findIndex(n => n.id === node.id);
nodeEl.classList.remove('drag-over-top', 'drag-over-bottom', 'drag-over-child');

if (relY < third) {
dragNode.parentId = node.parentId;
nodes.splice(targetIdx, 0, dragNode);
} else if (relY > height - third && isContainer) {
dragNode.parentId = node.id;
node.collapsed = false;
nodes.splice(targetIdx + 1, 0, dragNode);
} else {
dragNode.parentId = node.parentId;
nodes.splice(targetIdx + 1, 0, dragNode);
}
if (relY < third) {
nodeEl.classList.add('drag-over-top');
dragZone = 'top';
} else if (relY > height - third && isContainer) {
nodeEl.classList.add('drag-over-child');
dragZone = 'child';
} else {
nodeEl.classList.add('drag-over-bottom');
dragZone = 'middle';
}

el.classList.remove('drag-over-top', 'drag-over-bottom', 'drag-over-child');
render();
});
dragOverEl = nodeEl;
}

function onDragEnd() {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
document.body.classList.remove('drag-active');

if (dragGhost) { dragGhost.remove(); dragGhost = null; }

if (dragOverEl) {
dragOverEl.classList.remove('drag-over-top', 'drag-over-bottom', 'drag-over-child');
}

const targetEl = dragOverEl;
const zone = dragZone;
dragOverEl = null;
dragZone = null;

if (dragId === null || targetEl === null || zone === null) {
dragId = null;
return;
}

const targetId = parseInt(targetEl.dataset.id, 10);
const dragNode = nodes.find(n => n.id === dragId);
const targetNode = nodes.find(n => n.id === targetId);
dragId = null;

if (!dragNode || !targetNode) return;
if (getDescendants(dragNode.id).some(d => d.id === targetId)) return;

nodes = nodes.filter(n => n.id !== dragNode.id);
const targetIdx = nodes.findIndex(n => n.id === targetId);

if (zone === 'top') {
dragNode.parentId = targetNode.parentId;
nodes.splice(targetIdx, 0, dragNode);
} else if (zone === 'child') {
dragNode.parentId = targetId;
targetNode.collapsed = false;
nodes.splice(targetIdx + 1, 0, dragNode);
} else {
dragNode.parentId = targetNode.parentId;
nodes.splice(targetIdx + 1, 0, dragNode);
}

render();
}

// ── Preview panel ─────────────────────────────────────────────────────────
Expand Down
Loading