diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ecb15fd..773bad3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 }} @@ -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 @@ -58,3 +68,4 @@ jobs: releaseBody: 'Download the installer for your platform below.' releaseDraft: true prerelease: false + args: ${{ matrix.args }} diff --git a/README.md b/README.md index 8911acb..3bc7935 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/index.html b/src/index.html index db2b9bd..dbe323a 100644 --- a/src/index.html +++ b/src/index.html @@ -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; } diff --git a/src/main.js b/src/main.js index 957382f..eceb284 100644 --- a/src/main.js +++ b/src/main.js @@ -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. @@ -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 ─────────────────────────────────────────────────────────