From 9cbdfaa5891091aeecdfb4d918a8d5924c6441ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:16:26 +0000 Subject: [PATCH 1/9] Add find function with toolbar button, Ctrl+F shortcut, and match navigation Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/ea48bd7a-a461-430f-a02e-00aa15cd326f Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 192 +++++++++++++++++++++++ packages/core/tests/boxes-editor.test.js | 116 ++++++++++++++ 2 files changed, 308 insertions(+) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 2715ebf..cbb75eb 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -331,6 +331,8 @@ export class BoxesEditor { this._selectedElement = null; this._ctxTarget = null; this._ctxPosition = null; + this._findMatches = []; + this._findCurrentIdx = -1; this.context = { ...(options.context ?? tmpl.context ?? {}) }; this._init(); @@ -439,6 +441,17 @@ export class BoxesEditor { .bxe-ctx-sep { height:1px; background:#dee2e6; } .bxe-ctx-editor { min-height:300px; margin:-4px; } .bxe-label-editor { position:absolute; z-index:20; background:rgba(255,255,255,.95); border:2px solid #4d90fe; border-radius:4px; padding:2px 6px; font-size:13px; font-family:inherit; outline:none; box-sizing:border-box; text-align:center; box-shadow:0 2px 8px rgba(0,0,0,.2); line-height:1.4; } +.bxe-find-bar { position:absolute; top:8px; left:50%; transform:translateX(-50%); z-index:25; display:flex; align-items:center; gap:4px; background:#fff; border:1px solid #ccc; border-radius:5px; padding:4px 8px; box-shadow:0 2px 8px rgba(0,0,0,.2); white-space:nowrap; } +.bxe-find-bar.bxe-hidden { display:none; } +.bxe-find-input { border:1px solid #ccc; border-radius:3px; padding:2px 6px; font-size:13px; width:180px; outline:none; font-family:inherit; } +.bxe-find-input:focus { border-color:#4d90fe; box-shadow:0 0 0 2px rgba(77,144,254,.2); } +.bxe-find-count { font-size:12px; color:#666; min-width:44px; text-align:center; } +.bxe-find-count.no-match { color:#dc3545; } +.bxe-find-nav-btn { padding:2px 7px; font-size:13px; cursor:pointer; background:#fff; border:1px solid #ccc; border-radius:3px; line-height:1.4; } +.bxe-find-nav-btn:hover:not(:disabled) { background:#f0f0f0; } +.bxe-find-nav-btn:disabled { opacity:.4; cursor:default; } +.bxe-find-close-btn { padding:2px 5px; font-size:13px; cursor:pointer; background:none; border:none; color:#888; border-radius:3px; line-height:1.4; } +.bxe-find-close-btn:hover { background:#f0f0f0; color:#333; } `; document.head.appendChild(style); } @@ -470,6 +483,50 @@ export class BoxesEditor { this._panelToggleBtn.addEventListener('click', () => this._toggleSidebar()); this._canvasWrap.appendChild(this._panelToggleBtn); + // Find bar (floating overlay on canvas, hidden by default) + this._findBar = document.createElement('div'); + this._findBar.className = 'bxe-find-bar bxe-hidden'; + this._findInput = document.createElement('input'); + this._findInput.className = 'bxe-find-input'; + this._findInput.type = 'text'; + this._findInput.placeholder = 'Find\u2026'; + this._findInput.setAttribute('aria-label', 'Find nodes'); + this._findCount = document.createElement('span'); + this._findCount.className = 'bxe-find-count'; + this._findPrevBtn = document.createElement('button'); + this._findPrevBtn.className = 'bxe-find-nav-btn'; + this._findPrevBtn.title = 'Previous match (Shift+Enter)'; + this._findPrevBtn.textContent = '\u2039'; + this._findPrevBtn.disabled = true; + this._findNextBtn = document.createElement('button'); + this._findNextBtn.className = 'bxe-find-nav-btn'; + this._findNextBtn.title = 'Next match (Enter)'; + this._findNextBtn.textContent = '\u203A'; + this._findNextBtn.disabled = true; + this._findCloseBtn = document.createElement('button'); + this._findCloseBtn.className = 'bxe-find-close-btn'; + this._findCloseBtn.title = 'Close (Escape)'; + this._findCloseBtn.textContent = '\u2715'; + this._findInput.addEventListener('input', () => this._executeFind(this._findInput.value)); + this._findInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.shiftKey ? this._findPrev() : this._findNext(); + } else if (e.key === 'Escape') { + e.preventDefault(); + this._closeFind(); + } + }); + this._findPrevBtn.addEventListener('click', () => this._findPrev()); + this._findNextBtn.addEventListener('click', () => this._findNext()); + this._findCloseBtn.addEventListener('click', () => this._closeFind()); + this._findBar.appendChild(this._findInput); + this._findBar.appendChild(this._findCount); + this._findBar.appendChild(this._findPrevBtn); + this._findBar.appendChild(this._findNextBtn); + this._findBar.appendChild(this._findCloseBtn); + this._canvasWrap.appendChild(this._findBar); + // Resize handle sits between canvas-wrap and sidebar this._resizeHandle = document.createElement('div'); this._resizeHandle.className = 'bxe-resize-handle'; @@ -493,6 +550,11 @@ export class BoxesEditor { this._redoBtn.addEventListener('click', () => this.redo()); toolbar.appendChild(this._undoBtn); toolbar.appendChild(this._redoBtn); + this._findToolbarBtn = document.createElement('button'); + this._findToolbarBtn.title = 'Find (Ctrl+F)'; + this._findToolbarBtn.textContent = '\uD83D\uDD0D'; + this._findToolbarBtn.addEventListener('click', () => this._toggleFind()); + toolbar.appendChild(this._findToolbarBtn); this._sidebarEl.appendChild(toolbar); // Tab nav @@ -687,6 +749,12 @@ export class BoxesEditor { if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') { e.preventDefault(); this.paste(); } + } else if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); this._toggleFind(); + } else if (e.key === 'Escape') { + if (this._findBar && !this._findBar.classList.contains('bxe-hidden')) { + this._closeFind(); + } } else if (e.key === 'Delete' || e.key === 'Backspace') { const tag = document.activeElement?.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') { @@ -1525,6 +1593,14 @@ export class BoxesEditor { { selector: '.eh-reconnect-target', style: { 'border-width': 3, 'border-color': '#3498db', 'border-opacity': 1 } + }, + { + selector: '.bxe-match', + style: { 'border-width': 3, 'border-color': '#ff9900', 'overlay-color': '#ff9900', 'overlay-padding': 5, 'overlay-opacity': 0.2 } + }, + { + selector: '.bxe-match-current', + style: { 'border-width': 4, 'border-color': '#ff6600', 'overlay-color': '#ff6600', 'overlay-padding': 7, 'overlay-opacity': 0.4 } } ]; @@ -2217,6 +2293,120 @@ export class BoxesEditor { canUndo() { return this._undoStack.length > 0; } canRedo() { return this._redoStack.length > 0; } + /** Toggle the find bar open/closed. */ + _toggleFind() { + if (this._findBar.classList.contains('bxe-hidden')) { + this._openFind(); + } else { + this._closeFind(); + } + } + + /** Open the find bar and focus the input. */ + _openFind() { + this._findBar.classList.remove('bxe-hidden'); + this._findInput.focus(); + this._findInput.select(); + if (this._findInput.value) { + this._executeFind(this._findInput.value); + } + } + + /** Close the find bar and clear all highlights. */ + _closeFind() { + this._findBar.classList.add('bxe-hidden'); + this._clearFindHighlights(); + this._findMatches = []; + this._findCurrentIdx = -1; + this._findInput.value = ''; + this._updateFindUI(); + } + + /** + * Search all nodes for a query string, matching against id, label, and all + * non-internal data properties. Highlights all matches and navigates to the + * first one. + */ + _executeFind(query) { + this._clearFindHighlights(); + this._findMatches = []; + this._findCurrentIdx = -1; + + if (query && query.trim()) { + const q = query.toLowerCase(); + this.cy.nodes().forEach(node => { + const d = node.data(); + const matched = Object.entries(d).some(([k, v]) => { + if (k.startsWith('_')) return false; + return String(v ?? '').toLowerCase().includes(q) || k.toLowerCase().includes(q); + }); + if (matched) { + this._findMatches.push(node.id()); + } + }); + if (this._findMatches.length > 0) { + this._findCurrentIdx = 0; + this._applyFindHighlights(); + } + } + + this._updateFindUI(); + } + + /** Navigate to the next find match. */ + _findNext() { + if (!this._findMatches.length) return; + this._findCurrentIdx = (this._findCurrentIdx + 1) % this._findMatches.length; + this._applyFindHighlights(); + this._updateFindUI(); + } + + /** Navigate to the previous find match. */ + _findPrev() { + if (!this._findMatches.length) return; + this._findCurrentIdx = (this._findCurrentIdx - 1 + this._findMatches.length) % this._findMatches.length; + this._applyFindHighlights(); + this._updateFindUI(); + } + + /** Apply bxe-match / bxe-match-current classes and select + centre the current match. */ + _applyFindHighlights() { + this.cy.nodes().removeClass('bxe-match bxe-match-current'); + this._findMatches.forEach((id, i) => { + const node = this.cy.getElementById(id); + if (i === this._findCurrentIdx) { + node.addClass('bxe-match-current'); + this.cy.elements().unselect(); + node.select(); + this.cy.animate({ center: { eles: node } }, { duration: 200 }); + } else { + node.addClass('bxe-match'); + } + }); + } + + /** Remove all find highlight classes from nodes. */ + _clearFindHighlights() { + if (this.cy) { + this.cy.nodes().removeClass('bxe-match bxe-match-current'); + } + } + + /** Refresh the find bar count label and button states. */ + _updateFindUI() { + const total = this._findMatches.length; + const hasQuery = this._findInput.value.trim().length > 0; + if (total === 0) { + this._findCount.textContent = hasQuery ? '0 matches' : ''; + this._findCount.classList.toggle('no-match', hasQuery); + } else { + this._findCount.textContent = `${this._findCurrentIdx + 1} / ${total}`; + this._findCount.classList.remove('no-match'); + } + this._findPrevBtn.disabled = total === 0; + this._findNextBtn.disabled = total === 0; + } + /** Programmatically switch the active sidebar tab by id (palette|properties|stylesheet|layout|context). */ setActiveTab(tabId) { if (this._tabBtns[tabId]) this._switchPane(tabId); @@ -2317,6 +2507,8 @@ export class BoxesEditor { this._contextEditor.destroy(); this._contextEditor = null; } + this._findMatches = []; + this._findCurrentIdx = -1; if (this.cy) { this.cy.destroy(); this.cy = null; diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index 6f24cfe..a03aaa9 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -368,6 +368,122 @@ describe('BoxesEditor', () => { }); }); + describe('find / search', () => { + beforeEach(() => { + editor = new BoxesEditor(container); + editor.addNode({ id: 'n1', label: 'Apple' }); + editor.addNode({ id: 'n2', label: 'Banana', color: 'yellow' }); + editor.addNode({ id: 'n3', label: 'Apricot' }); + }); + + it('should find nodes by label', () => { + editor._executeFind('apple'); + expect(editor._findMatches).toHaveLength(1); + expect(editor._findMatches[0]).toBe('n1'); + }); + + it('should find multiple matches', () => { + editor._executeFind('ap'); + expect(editor._findMatches).toHaveLength(2); + expect(editor._findMatches).toContain('n1'); + expect(editor._findMatches).toContain('n3'); + }); + + it('should find nodes by property value', () => { + editor._executeFind('yellow'); + expect(editor._findMatches).toHaveLength(1); + expect(editor._findMatches[0]).toBe('n2'); + }); + + it('should find nodes by property key', () => { + editor._executeFind('color'); + expect(editor._findMatches).toHaveLength(1); + expect(editor._findMatches[0]).toBe('n2'); + }); + + it('should be case-insensitive', () => { + editor._executeFind('APPLE'); + expect(editor._findMatches).toHaveLength(1); + expect(editor._findMatches[0]).toBe('n1'); + }); + + it('should return no matches for unrecognised query', () => { + editor._executeFind('zzznomatch'); + expect(editor._findMatches).toHaveLength(0); + }); + + it('should clear matches when query is empty', () => { + editor._executeFind('apple'); + editor._executeFind(''); + expect(editor._findMatches).toHaveLength(0); + }); + + it('should set current index to 0 after initial find', () => { + editor._executeFind('ap'); + expect(editor._findCurrentIdx).toBe(0); + }); + + it('should advance to next match with _findNext', () => { + editor._executeFind('ap'); + editor._findNext(); + expect(editor._findCurrentIdx).toBe(1); + }); + + it('should wrap around to first match after last', () => { + editor._executeFind('ap'); + editor._findNext(); + editor._findNext(); + expect(editor._findCurrentIdx).toBe(0); + }); + + it('should go to previous match with _findPrev', () => { + editor._executeFind('ap'); + editor._findPrev(); + expect(editor._findCurrentIdx).toBe(1); + }); + + it('should apply bxe-match-current class to current match', () => { + editor._executeFind('apple'); + const node = editor.cy.getElementById('n1'); + expect(node.hasClass('bxe-match-current')).toBe(true); + }); + + it('should apply bxe-match class to non-current matches', () => { + editor._executeFind('ap'); + const n3 = editor.cy.getElementById('n3'); + expect(n3.hasClass('bxe-match')).toBe(true); + }); + + it('should clear highlights when find is closed', () => { + editor._executeFind('apple'); + editor._closeFind(); + const node = editor.cy.getElementById('n1'); + expect(node.hasClass('bxe-match')).toBe(false); + expect(node.hasClass('bxe-match-current')).toBe(false); + }); + + it('should open and close the find bar', () => { + expect(editor._findBar.classList.contains('bxe-hidden')).toBe(true); + editor._openFind(); + expect(editor._findBar.classList.contains('bxe-hidden')).toBe(false); + editor._closeFind(); + expect(editor._findBar.classList.contains('bxe-hidden')).toBe(true); + }); + + it('should toggle the find bar', () => { + editor._toggleFind(); + expect(editor._findBar.classList.contains('bxe-hidden')).toBe(false); + editor._toggleFind(); + expect(editor._findBar.classList.contains('bxe-hidden')).toBe(true); + }); + + it('should not include internal _style fields in search', () => { + editor.addNode({ id: 'n4', label: 'Test', _style: { 'background-color': 'searchme' } }); + editor._executeFind('searchme'); + expect(editor._findMatches).toHaveLength(0); + }); + }); + describe('cleanup', () => { it('should destroy the editor properly', () => { editor = new BoxesEditor(container); From a0342bd0592398f80b6e0edc74963a8e0c421431 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:24:10 +0000 Subject: [PATCH 2/9] Add cut, copy, and paste buttons to the toolbar Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/5a37a0a2-5085-49f2-8e8a-a6e4118bb1c0 Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 15 ++++++++ packages/core/tests/boxes-editor.test.js | 46 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index cbb75eb..eb44679 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -550,6 +550,21 @@ export class BoxesEditor { this._redoBtn.addEventListener('click', () => this.redo()); toolbar.appendChild(this._undoBtn); toolbar.appendChild(this._redoBtn); + this._cutBtn = document.createElement('button'); + this._cutBtn.title = 'Cut (Ctrl+X)'; + this._cutBtn.textContent = '\u2702'; + this._cutBtn.addEventListener('click', () => this.cut()); + this._copyBtn = document.createElement('button'); + this._copyBtn.title = 'Copy (Ctrl+C)'; + this._copyBtn.textContent = '\u2398'; + this._copyBtn.addEventListener('click', () => this.copy()); + this._pasteBtn = document.createElement('button'); + this._pasteBtn.title = 'Paste (Ctrl+V)'; + this._pasteBtn.textContent = '\uD83D\uDCCB'; + this._pasteBtn.addEventListener('click', () => this.paste()); + toolbar.appendChild(this._cutBtn); + toolbar.appendChild(this._copyBtn); + toolbar.appendChild(this._pasteBtn); this._findToolbarBtn = document.createElement('button'); this._findToolbarBtn.title = 'Find (Ctrl+F)'; this._findToolbarBtn.textContent = '\uD83D\uDD0D'; diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index a03aaa9..62aab3f 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -484,6 +484,52 @@ describe('BoxesEditor', () => { }); }); + describe('toolbar buttons', () => { + beforeEach(() => { + editor = new BoxesEditor(container); + }); + + it('should have a cut button', () => { + expect(editor._cutBtn).toBeDefined(); + expect(editor._cutBtn.tagName).toBe('BUTTON'); + }); + + it('should have a copy button', () => { + expect(editor._copyBtn).toBeDefined(); + expect(editor._copyBtn.tagName).toBe('BUTTON'); + }); + + it('should have a paste button', () => { + expect(editor._pasteBtn).toBeDefined(); + expect(editor._pasteBtn.tagName).toBe('BUTTON'); + }); + + it('copy button invokes copy()', () => { + editor.addNode({ id: 'n1', label: 'A' }); + editor.selectElements(['n1']); + editor._copyBtn.click(); + expect(editor._clipboard).not.toBeNull(); + expect(editor._clipboard.nodes.some(n => n.data.id === 'n1')).toBe(true); + }); + + it('cut button invokes cut() and removes selected node', () => { + editor.addNode({ id: 'n1', label: 'A' }); + editor.selectElements(['n1']); + editor._cutBtn.click(); + expect(editor._clipboard).not.toBeNull(); + expect(editor.cy.getElementById('n1').length).toBe(0); + }); + + it('paste button invokes paste() and adds clipboard contents', () => { + editor.addNode({ id: 'n1', label: 'A' }); + editor.selectElements(['n1']); + editor.copy(); + const before = editor.getElements().nodes.length; + editor._pasteBtn.click(); + expect(editor.getElements().nodes.length).toBeGreaterThan(before); + }); + }); + describe('cleanup', () => { it('should destroy the editor properly', () => { editor = new BoxesEditor(container); From 609842e46d091c5ceefc7cabec399e15b9bdf5b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:39:11 +0000 Subject: [PATCH 3/9] Enable/disable cut/copy/paste buttons; integrate system clipboard with JSON serialization Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/7c7ced33-6dbd-4d98-93a2-a806868c02c9 Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 78 ++++++++++++++++++++++-- packages/core/tests/boxes-editor.test.js | 74 ++++++++++++++++++++-- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index eb44679..29313ce 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -553,14 +553,17 @@ export class BoxesEditor { this._cutBtn = document.createElement('button'); this._cutBtn.title = 'Cut (Ctrl+X)'; this._cutBtn.textContent = '\u2702'; + this._cutBtn.disabled = true; this._cutBtn.addEventListener('click', () => this.cut()); this._copyBtn = document.createElement('button'); this._copyBtn.title = 'Copy (Ctrl+C)'; this._copyBtn.textContent = '\u2398'; + this._copyBtn.disabled = true; this._copyBtn.addEventListener('click', () => this.copy()); this._pasteBtn = document.createElement('button'); this._pasteBtn.title = 'Paste (Ctrl+V)'; this._pasteBtn.textContent = '\uD83D\uDCCB'; + this._pasteBtn.disabled = true; this._pasteBtn.addEventListener('click', () => this.paste()); toolbar.appendChild(this._cutBtn); toolbar.appendChild(this._copyBtn); @@ -779,6 +782,12 @@ export class BoxesEditor { }; document.addEventListener('keydown', this._keydownHandler); + // When the window regains focus, check the system clipboard so the paste + // button can be enabled if another window (or a text editor) put valid + // graph JSON there. + this._clipboardFocusHandler = () => this._checkSystemClipboard(); + window.addEventListener('focus', this._clipboardFocusHandler); + this._switchPane('palette'); this._renderContextPane(); } @@ -1518,6 +1527,28 @@ export class BoxesEditor { if (this._redoBtn) this._redoBtn.disabled = !this.canRedo(); } + _updateClipboardButtons() { + const hasSel = !!(this.cy && this.cy.$(':selected').length > 0); + if (this._cutBtn) this._cutBtn.disabled = !hasSel; + if (this._copyBtn) this._copyBtn.disabled = !hasSel; + if (this._pasteBtn) this._pasteBtn.disabled = !this._clipboard; + } + + /** Silently probe the system clipboard and enable paste if valid graph JSON is found. */ + _checkSystemClipboard() { + if (!navigator.clipboard?.readText) return; + navigator.clipboard.readText().then(text => { + try { + const parsed = JSON.parse(text); + if (parsed && Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) { + this._clipboard = parsed; + this._pasteOffset = 0; + this._updateClipboardButtons(); + } + } catch (_) { /* not valid JSON */ } + }).catch(() => { /* permission denied or unavailable */ }); + } + _init() { this._injectCSS(); this._createUI(); @@ -1667,12 +1698,14 @@ export class BoxesEditor { this._selectedElement = evt.target; this._refreshProperties(evt.target); this._switchPane('properties'); + this._updateClipboardButtons(); }); this.cy.on('unselect', (evt) => { this._emit('unselect', { target: evt.target }); this._emit('selectionChange', { type: 'unselect', target: evt.target, selected: this.cy.$(':selected').jsons() }); if (!this.cy.$(':selected').length) this._clearProperties(); + this._updateClipboardButtons(); }); this.cy.on('grabon', 'node', (evt) => { @@ -2155,6 +2188,14 @@ export class BoxesEditor { edges: edges.map(e => e.json()) }; this._pasteOffset = 0; + + // Write to the system clipboard so the data can be pasted into other + // browser windows or into a .boxes file in a text editor. + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(JSON.stringify(this._clipboard)).catch(() => {}); + } + + this._updateClipboardButtons(); this._emit('clipboardChange', { hasClipboard: true }); return true; } @@ -2171,10 +2212,33 @@ export class BoxesEditor { /** * Paste the clipboard contents into the graph. - * Each paste cascades by 20px. Returns the newly added elements or false. + * Reads from the system clipboard first (JSON format); falls back to the + * in-memory clipboard. Each paste cascades by 20px. Returns the newly + * added elements or false. */ - paste() { - if (!this._clipboard) return false; + async paste() { + // Try to read fresh data from the system clipboard. + let clipData = this._clipboard; + try { + if (navigator.clipboard?.readText) { + const text = await navigator.clipboard.readText(); + const parsed = JSON.parse(text); + if (parsed && Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) { + // If the system clipboard contains different content than our local + // cache, treat it as a fresh copy and reset the paste cascade. + if (JSON.stringify(parsed) !== JSON.stringify(this._clipboard)) { + this._pasteOffset = 0; + } + this._clipboard = parsed; + clipData = this._clipboard; + this._updateClipboardButtons(); + } + } + } catch (_) { + // Permission denied, clipboard unavailable, or invalid JSON — use local clipboard. + } + + if (!clipData) return false; this._pushUndo(); this._pasteOffset += 20; @@ -2185,7 +2249,7 @@ export class BoxesEditor { const newNodes = []; const newEdges = []; - this._clipboard.nodes.forEach(nodeJson => { + clipData.nodes.forEach(nodeJson => { const newId = 'node-' + Math.random().toString(36).slice(2, 9); idMap[nodeJson.data.id] = newId; @@ -2199,7 +2263,7 @@ export class BoxesEditor { newNodes.push(entry); }); - this._clipboard.edges.forEach(edgeJson => { + clipData.edges.forEach(edgeJson => { const newSrc = idMap[edgeJson.data.source]; const newTgt = idMap[edgeJson.data.target]; if (!newSrc || !newTgt) return; // skip if endpoints weren't in clipboard @@ -2537,6 +2601,10 @@ export class BoxesEditor { document.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; } + if (this._clipboardFocusHandler) { + window.removeEventListener('focus', this._clipboardFocusHandler); + this._clipboardFocusHandler = null; + } if (this.container) { this.container.innerHTML = ''; // Only clear the specific properties _createUI set, not all inline styles. diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index 62aab3f..9ee6110 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -1,6 +1,15 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { BoxesEditor } from '../src/boxes-editor.js'; +// Provide a minimal navigator.clipboard stub so tests can verify system-clipboard +// integration without a real browser. +const clipboardStub = { + _value: '', + writeText: vi.fn(async (text) => { clipboardStub._value = text; }), + readText: vi.fn(async () => clipboardStub._value), +}; +Object.defineProperty(navigator, 'clipboard', { value: clipboardStub, configurable: true }); + describe('BoxesEditor', () => { let container; let editor; @@ -10,6 +19,10 @@ describe('BoxesEditor', () => { container.style.width = '800px'; container.style.height = '600px'; document.body.appendChild(container); + // Reset the clipboard stub before every test to prevent cross-test pollution. + clipboardStub._value = ''; + clipboardStub.writeText.mockClear(); + clipboardStub.readText.mockClear(); }); afterEach(() => { @@ -504,12 +517,47 @@ describe('BoxesEditor', () => { expect(editor._pasteBtn.tagName).toBe('BUTTON'); }); - it('copy button invokes copy()', () => { + it('cut and copy buttons start disabled', () => { + expect(editor._cutBtn.disabled).toBe(true); + expect(editor._copyBtn.disabled).toBe(true); + }); + + it('paste button starts disabled', () => { + expect(editor._pasteBtn.disabled).toBe(true); + }); + + it('cut and copy buttons enable when a node is selected', () => { + editor.addNode({ id: 'n1', label: 'A' }); + editor.selectElements(['n1']); + expect(editor._cutBtn.disabled).toBe(false); + expect(editor._copyBtn.disabled).toBe(false); + }); + + it('cut and copy buttons disable again when selection is cleared', () => { + editor.addNode({ id: 'n1', label: 'A' }); + editor.selectElements(['n1']); + editor.cy.$(':selected').unselect(); + // give cytoscape a tick to fire the unselect event + expect(editor._cutBtn.disabled).toBe(true); + expect(editor._copyBtn.disabled).toBe(true); + }); + + it('copy button invokes copy() and writes JSON to system clipboard', async () => { editor.addNode({ id: 'n1', label: 'A' }); editor.selectElements(['n1']); editor._copyBtn.click(); expect(editor._clipboard).not.toBeNull(); expect(editor._clipboard.nodes.some(n => n.data.id === 'n1')).toBe(true); + // wait for the async writeText call + await Promise.resolve(); + expect(clipboardStub.writeText).toHaveBeenCalledWith(JSON.stringify(editor._clipboard)); + }); + + it('paste button enables after copy', () => { + editor.addNode({ id: 'n1', label: 'A' }); + editor.selectElements(['n1']); + editor.copy(); + expect(editor._pasteBtn.disabled).toBe(false); }); it('cut button invokes cut() and removes selected node', () => { @@ -520,14 +568,32 @@ describe('BoxesEditor', () => { expect(editor.cy.getElementById('n1').length).toBe(0); }); - it('paste button invokes paste() and adds clipboard contents', () => { + it('paste button invokes paste() and adds clipboard contents', async () => { editor.addNode({ id: 'n1', label: 'A' }); editor.selectElements(['n1']); editor.copy(); const before = editor.getElements().nodes.length; - editor._pasteBtn.click(); + await editor.paste(); expect(editor.getElements().nodes.length).toBeGreaterThan(before); }); + + it('paste() reads from system clipboard and uses it when valid graph JSON', async () => { + // Seed the system clipboard with a foreign graph (simulating a copy from another window) + const foreignClip = { nodes: [{ data: { id: 'foreign-1', label: 'Foreign' } }], edges: [] }; + clipboardStub._value = JSON.stringify(foreignClip); + + // local clipboard is null — no copy() was called in this editor + expect(editor._clipboard).toBeNull(); + await editor.paste(); + + // The foreign node should now be in the graph + const ids = editor.getElements().nodes.map(n => n.data.id); + // The pasted node gets a new ID, but the graph should have one node + expect(ids.length).toBeGreaterThan(0); + // And the internal clipboard cache should be updated + expect(editor._clipboard).toEqual(foreignClip); + expect(editor._pasteBtn.disabled).toBe(false); + }); }); describe('cleanup', () => { From bd83894e10ed59329b4a0b682a3c87fe187bf792 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:16:13 +0000 Subject: [PATCH 4/9] Paste: center content on viewport, offset repeated pastes by content width Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/78de61f3-a344-4570-ac2e-b417fb16b200 Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 35 +++++++++++++-- packages/core/tests/boxes-editor.test.js | 54 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 29313ce..5576c6a 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -326,7 +326,7 @@ export class BoxesEditor { this._restoringState = false; this._preGrabSnapshot = null; this._clipboard = null; // { nodes: [...json], edges: [...json] } - this._pasteOffset = 0; // increments each paste so repeated pastes cascade + this._pasteOffset = 0; // count of pastes since last copy; used for cascade offset this._currentNodeTypeId = this._nodeTypes[0]?.id || null; this._selectedElement = null; this._ctxTarget = null; @@ -2241,8 +2241,35 @@ export class BoxesEditor { if (!clipData) return false; this._pushUndo(); - this._pasteOffset += 20; - const offset = this._pasteOffset; + // Compute the bounding box of the clipboard content so we can center it + // on the viewport. Fall back to a 200-unit default width when nodes have + // no recorded positions (e.g. clipboard text from a text editor). + const positions = clipData.nodes.map(n => n.position).filter(Boolean); + let contentCenterX = 0, contentCenterY = 0, contentWidth = 200; + if (positions.length > 0) { + const xs = positions.map(p => p.x); + const ys = positions.map(p => p.y); + const minX = Math.min(...xs), maxX = Math.max(...xs); + const minY = Math.min(...ys), maxY = Math.max(...ys); + contentCenterX = (minX + maxX) / 2; + contentCenterY = (minY + maxY) / 2; + // Use the actual width but enforce a minimum so stacked single-node + // pastes still have visible separation. + contentWidth = Math.max(maxX - minX, 200); + } + + // Viewport center in graph coordinates. + const ext = this.cy.extent(); + const viewCenterX = (ext.x1 + ext.x2) / 2; + const viewCenterY = (ext.y1 + ext.y2) / 2; + + // First paste (pasteIndex=0) centres the content on the viewport. + // Each subsequent paste shifts right by one content-width so copies + // cascade without overlapping. + const pasteIndex = this._pasteOffset; + const shiftX = viewCenterX - contentCenterX + pasteIndex * contentWidth; + const shiftY = viewCenterY - contentCenterY; + this._pasteOffset += 1; // Map old node IDs → new node IDs const idMap = {}; @@ -2255,7 +2282,7 @@ export class BoxesEditor { const newData = { ...nodeJson.data, id: newId }; const newPos = nodeJson.position - ? { x: nodeJson.position.x + offset, y: nodeJson.position.y + offset } + ? { x: nodeJson.position.x + shiftX, y: nodeJson.position.y + shiftY } : undefined; const entry = { group: 'nodes', data: newData }; diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index 9ee6110..9b0cabb 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -594,6 +594,60 @@ describe('BoxesEditor', () => { expect(editor._clipboard).toEqual(foreignClip); expect(editor._pasteBtn.disabled).toBe(false); }); + + it('paste() centers content on the viewport on first paste', async () => { + // Clipboard has two nodes at known positions so the content centre is (150, 100). + const clip = { + nodes: [ + { data: { id: 'a', label: 'A' }, position: { x: 100, y: 100 } }, + { data: { id: 'b', label: 'B' }, position: { x: 200, y: 100 } }, + ], + edges: [], + }; + clipboardStub._value = JSON.stringify(clip); + await editor.paste(); + + const ext = editor.cy.extent(); + const viewCenterX = (ext.x1 + ext.x2) / 2; + const viewCenterY = (ext.y1 + ext.y2) / 2; + + // Both pasted nodes should be shifted so the content centre (150,100) maps + // to the viewport centre. + const nodes = editor.getElements().nodes; + expect(nodes).toHaveLength(2); + const posA = nodes.find(n => n.data.label === 'A').position; + const posB = nodes.find(n => n.data.label === 'B').position; + const pastedCenterX = (posA.x + posB.x) / 2; + const pastedCenterY = (posA.y + posB.y) / 2; + expect(pastedCenterX).toBeCloseTo(viewCenterX, 1); + expect(pastedCenterY).toBeCloseTo(viewCenterY, 1); + }); + + it('paste() offsets subsequent pastes by content width', async () => { + // Content width is 200−100 = 100, but clamped to min 200. + const clip = { + nodes: [ + { data: { id: 'a', label: 'A' }, position: { x: 100, y: 100 } }, + { data: { id: 'b', label: 'B' }, position: { x: 200, y: 100 } }, + ], + edges: [], + }; + clipboardStub._value = JSON.stringify(clip); + + await editor.paste(); // pasteIndex=0 + await editor.paste(); // pasteIndex=1 + + const nodes = editor.getElements().nodes; + expect(nodes).toHaveLength(4); + + // First group: pasteIndex=0, shift = viewCenter - contentCenter + 0*w + // Second group: pasteIndex=1, shift = viewCenter - contentCenter + 1*w + // So the second group should be contentWidth (200) further right than the first. + const labelsA = nodes.filter(n => n.data.label === 'A'); + expect(labelsA).toHaveLength(2); + const xDiff = Math.abs(labelsA[1].position.x - labelsA[0].position.x); + expect(xDiff).toBeCloseTo(200, 1); // clamped content width + }); }); describe('cleanup', () => { From 47382753c76fd8c375d3f52d665d490b9eaff2c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:28:47 +0000 Subject: [PATCH 5/9] Paste: use cy.boundingBox() for accurate content width, fix overlap on repeat paste Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/b65f65b1-aea1-41c0-b14d-2d2b72f8354e Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 72 +++++++++++------------- packages/core/tests/boxes-editor.test.js | 31 +++++----- 2 files changed, 49 insertions(+), 54 deletions(-) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 5576c6a..3889ec0 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -2241,37 +2241,10 @@ export class BoxesEditor { if (!clipData) return false; this._pushUndo(); - // Compute the bounding box of the clipboard content so we can center it - // on the viewport. Fall back to a 200-unit default width when nodes have - // no recorded positions (e.g. clipboard text from a text editor). - const positions = clipData.nodes.map(n => n.position).filter(Boolean); - let contentCenterX = 0, contentCenterY = 0, contentWidth = 200; - if (positions.length > 0) { - const xs = positions.map(p => p.x); - const ys = positions.map(p => p.y); - const minX = Math.min(...xs), maxX = Math.max(...xs); - const minY = Math.min(...ys), maxY = Math.max(...ys); - contentCenterX = (minX + maxX) / 2; - contentCenterY = (minY + maxY) / 2; - // Use the actual width but enforce a minimum so stacked single-node - // pastes still have visible separation. - contentWidth = Math.max(maxX - minX, 200); - } - - // Viewport center in graph coordinates. - const ext = this.cy.extent(); - const viewCenterX = (ext.x1 + ext.x2) / 2; - const viewCenterY = (ext.y1 + ext.y2) / 2; - - // First paste (pasteIndex=0) centres the content on the viewport. - // Each subsequent paste shifts right by one content-width so copies - // cascade without overlapping. - const pasteIndex = this._pasteOffset; - const shiftX = viewCenterX - contentCenterX + pasteIndex * contentWidth; - const shiftY = viewCenterY - contentCenterY; - this._pasteOffset += 1; - - // Map old node IDs → new node IDs + // Map old node IDs → new node IDs, preserving original positions for now. + // We add nodes at their clipboard positions first so Cytoscape can compute + // the real rendered bounding box (which accounts for node width, label + // padding, etc.). We shift them to their final positions afterwards. const idMap = {}; const newNodes = []; const newEdges = []; @@ -2280,13 +2253,8 @@ export class BoxesEditor { const newId = 'node-' + Math.random().toString(36).slice(2, 9); idMap[nodeJson.data.id] = newId; - const newData = { ...nodeJson.data, id: newId }; - const newPos = nodeJson.position - ? { x: nodeJson.position.x + shiftX, y: nodeJson.position.y + shiftY } - : undefined; - - const entry = { group: 'nodes', data: newData }; - if (newPos) entry.position = newPos; + const entry = { group: 'nodes', data: { ...nodeJson.data, id: newId } }; + if (nodeJson.position) entry.position = { ...nodeJson.position }; newNodes.push(entry); }); @@ -2306,6 +2274,34 @@ export class BoxesEditor { const added = this.cy.add([...newNodes, ...newEdges]); added.select(); + // Now that Cytoscape has rendered the nodes, measure their actual bounding + // box. This correctly accounts for node width, label extents, etc. — all + // things that can't be known from center positions alone. + const addedNodes = added.nodes(); + if (addedNodes.length > 0 && newNodes.some(n => n.position)) { + const bb = addedNodes.boundingBox(); + const bbCenterX = (bb.x1 + bb.x2) / 2; + const bbCenterY = (bb.y1 + bb.y2) / 2; + + // Viewport center in graph coordinates. + const ext = this.cy.extent(); + const viewCenterX = (ext.x1 + ext.x2) / 2; + const viewCenterY = (ext.y1 + ext.y2) / 2; + + // First paste (pasteIndex=0) centres content on the viewport. + // Each subsequent paste shifts right by the actual rendered width so + // copies never overlap, even for wide label text. + const pasteIndex = this._pasteOffset; + const shiftX = viewCenterX - bbCenterX + pasteIndex * bb.w; + const shiftY = viewCenterY - bbCenterY; + + addedNodes.forEach(node => { + const p = node.position(); + node.position({ x: p.x + shiftX, y: p.y + shiftY }); + }); + } + this._pasteOffset += 1; + this._updateStylesheet(); this._emit('paste', { nodes: newNodes, edges: newEdges }); return added; diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index 9b0cabb..0f8d901 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -596,7 +596,7 @@ describe('BoxesEditor', () => { }); it('paste() centers content on the viewport on first paste', async () => { - // Clipboard has two nodes at known positions so the content centre is (150, 100). + // Clipboard has two nodes at known positions. const clip = { nodes: [ { data: { id: 'a', label: 'A' }, position: { x: 100, y: 100 } }, @@ -611,20 +611,16 @@ describe('BoxesEditor', () => { const viewCenterX = (ext.x1 + ext.x2) / 2; const viewCenterY = (ext.y1 + ext.y2) / 2; - // Both pasted nodes should be shifted so the content centre (150,100) maps - // to the viewport centre. - const nodes = editor.getElements().nodes; - expect(nodes).toHaveLength(2); - const posA = nodes.find(n => n.data.label === 'A').position; - const posB = nodes.find(n => n.data.label === 'B').position; - const pastedCenterX = (posA.x + posB.x) / 2; - const pastedCenterY = (posA.y + posB.y) / 2; - expect(pastedCenterX).toBeCloseTo(viewCenterX, 1); - expect(pastedCenterY).toBeCloseTo(viewCenterY, 1); + // The rendered bounding box of the pasted group should be centred on the + // viewport (the bbox center matches viewCenter). + const bb = editor.cy.nodes().boundingBox(); + const pastedBBCenterX = (bb.x1 + bb.x2) / 2; + const pastedBBCenterY = (bb.y1 + bb.y2) / 2; + expect(pastedBBCenterX).toBeCloseTo(viewCenterX, 1); + expect(pastedBBCenterY).toBeCloseTo(viewCenterY, 1); }); it('paste() offsets subsequent pastes by content width', async () => { - // Content width is 200−100 = 100, but clamped to min 200. const clip = { nodes: [ { data: { id: 'a', label: 'A' }, position: { x: 100, y: 100 } }, @@ -635,18 +631,21 @@ describe('BoxesEditor', () => { clipboardStub._value = JSON.stringify(clip); await editor.paste(); // pasteIndex=0 + // Measure the actual rendered bounding box width after the first paste. + const firstBB = editor.cy.nodes().boundingBox(); + const actualBBWidth = firstBB.w; + await editor.paste(); // pasteIndex=1 const nodes = editor.getElements().nodes; expect(nodes).toHaveLength(4); - // First group: pasteIndex=0, shift = viewCenter - contentCenter + 0*w - // Second group: pasteIndex=1, shift = viewCenter - contentCenter + 1*w - // So the second group should be contentWidth (200) further right than the first. + // The second paste's A node should be exactly bb.w further right than + // the first paste's A node. const labelsA = nodes.filter(n => n.data.label === 'A'); expect(labelsA).toHaveLength(2); const xDiff = Math.abs(labelsA[1].position.x - labelsA[0].position.x); - expect(xDiff).toBeCloseTo(200, 1); // clamped content width + expect(xDiff).toBeCloseTo(actualBBWidth, 1); }); }); From e2814efe019d32f48695a96c488b54a1c16678fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:51:24 +0000 Subject: [PATCH 6/9] Paste: reset cascade offset when viewport center changes between pastes Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/926c62b6-bf16-4dc5-9d2f-8a07efc344e9 Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 21 +++++++++++++-- packages/core/tests/boxes-editor.test.js | 34 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 3889ec0..1b4aa91 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -325,8 +325,9 @@ export class BoxesEditor { this._redoStack = []; this._restoringState = false; this._preGrabSnapshot = null; - this._clipboard = null; // { nodes: [...json], edges: [...json] } - this._pasteOffset = 0; // count of pastes since last copy; used for cascade offset + this._clipboard = null; // { nodes: [...json], edges: [...json] } + this._pasteOffset = 0; // count of pastes since last copy; used for cascade offset + this._pasteViewCenter = null; // viewport center {x,y} when cascade began; reset resets offset this._currentNodeTypeId = this._nodeTypes[0]?.id || null; this._selectedElement = null; this._ctxTarget = null; @@ -1543,6 +1544,7 @@ export class BoxesEditor { if (parsed && Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) { this._clipboard = parsed; this._pasteOffset = 0; + this._pasteViewCenter = null; this._updateClipboardButtons(); } } catch (_) { /* not valid JSON */ } @@ -2188,6 +2190,7 @@ export class BoxesEditor { edges: edges.map(e => e.json()) }; this._pasteOffset = 0; + this._pasteViewCenter = null; // Write to the system clipboard so the data can be pasted into other // browser windows or into a .boxes file in a text editor. @@ -2228,6 +2231,7 @@ export class BoxesEditor { // cache, treat it as a fresh copy and reset the paste cascade. if (JSON.stringify(parsed) !== JSON.stringify(this._clipboard)) { this._pasteOffset = 0; + this._pasteViewCenter = null; } this._clipboard = parsed; clipData = this._clipboard; @@ -2288,6 +2292,19 @@ export class BoxesEditor { const viewCenterX = (ext.x1 + ext.x2) / 2; const viewCenterY = (ext.y1 + ext.y2) / 2; + // If the user has panned or zoomed since the cascade began, the previous + // cascade offsets no longer make sense — restart from 0 at the new + // viewport center so the first copy lands in view and subsequent copies + // cascade correctly from there. + if (this._pasteViewCenter && + (Math.abs(this._pasteViewCenter.x - viewCenterX) > 1 || + Math.abs(this._pasteViewCenter.y - viewCenterY) > 1)) { + this._pasteOffset = 0; + } + + // Record the viewport center for the next paste to compare against. + this._pasteViewCenter = { x: viewCenterX, y: viewCenterY }; + // First paste (pasteIndex=0) centres content on the viewport. // Each subsequent paste shifts right by the actual rendered width so // copies never overlap, even for wide label text. diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index 0f8d901..72128ab 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -647,6 +647,40 @@ describe('BoxesEditor', () => { const xDiff = Math.abs(labelsA[1].position.x - labelsA[0].position.x); expect(xDiff).toBeCloseTo(actualBBWidth, 1); }); + + it('paste() resets cascade offset when the viewport center changes between pastes', async () => { + const clip = { + nodes: [ + { data: { id: 'a', label: 'A' }, position: { x: 100, y: 100 } }, + ], + edges: [], + }; + clipboardStub._value = JSON.stringify(clip); + + // First paste — establishes a cascade at pasteOffset=0, records viewCenter. + await editor.paste(); + expect(editor._pasteOffset).toBe(1); + + // Simulate pan: shift the viewport so the center changes significantly. + editor.cy.pan({ x: editor.cy.pan().x + 500, y: editor.cy.pan().y }); + + // Second paste — viewport center has changed; offset should reset to 0 + // so the copy is centered on the NEW viewport center rather than + // continuing the old cascade. + await editor.paste(); + + // After the second paste the offset should be 1 again (reset to 0 then incremented). + expect(editor._pasteOffset).toBe(1); + + // Both pasted nodes should land at the viewport center, not offset by bb.w. + const ext = editor.cy.extent(); + const viewCenterX = (ext.x1 + ext.x2) / 2; + const allNodes = editor.getElements().nodes; + expect(allNodes).toHaveLength(2); + // The second pasted node should be centred on the current viewport center. + const secondNode = allNodes[allNodes.length - 1]; + expect(secondNode.position.x).toBeCloseTo(viewCenterX, 1); + }); }); describe('cleanup', () => { From 9b56b22b3d6e33624d037cf81b2bde46512c8313 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:03:51 +0000 Subject: [PATCH 7/9] Paste: move viewport check to start of paste(), before node addition Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/a4c37908-09d4-452c-9af5-5d57fdadc9ef Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 38 +++++++++++++----------- packages/core/tests/boxes-editor.test.js | 38 ++++++++++++++++-------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 1b4aa91..1a727f1 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -2243,6 +2243,26 @@ export class BoxesEditor { } if (!clipData) return false; + + // Snapshot the viewport center NOW — before adding any nodes — so the + // comparison is unaffected by anything Cytoscape does when elements are + // added. This also runs unconditionally, regardless of whether the + // clipboard contains positioned nodes, keeping _pasteViewCenter in sync. + const ext = this.cy.extent(); + const viewCenterX = (ext.x1 + ext.x2) / 2; + const viewCenterY = (ext.y1 + ext.y2) / 2; + + // If the viewport center has moved since the last paste, the accumulated + // cascade offset no longer points at anything in view. Reset it to 0 so + // the next content lands centred on the NEW viewport. + if (this._pasteViewCenter && + (Math.abs(this._pasteViewCenter.x - viewCenterX) > 1 || + Math.abs(this._pasteViewCenter.y - viewCenterY) > 1)) { + this._pasteOffset = 0; + } + // Always record the current viewport center as the baseline for the next paste. + this._pasteViewCenter = { x: viewCenterX, y: viewCenterY }; + this._pushUndo(); // Map old node IDs → new node IDs, preserving original positions for now. @@ -2287,24 +2307,6 @@ export class BoxesEditor { const bbCenterX = (bb.x1 + bb.x2) / 2; const bbCenterY = (bb.y1 + bb.y2) / 2; - // Viewport center in graph coordinates. - const ext = this.cy.extent(); - const viewCenterX = (ext.x1 + ext.x2) / 2; - const viewCenterY = (ext.y1 + ext.y2) / 2; - - // If the user has panned or zoomed since the cascade began, the previous - // cascade offsets no longer make sense — restart from 0 at the new - // viewport center so the first copy lands in view and subsequent copies - // cascade correctly from there. - if (this._pasteViewCenter && - (Math.abs(this._pasteViewCenter.x - viewCenterX) > 1 || - Math.abs(this._pasteViewCenter.y - viewCenterY) > 1)) { - this._pasteOffset = 0; - } - - // Record the viewport center for the next paste to compare against. - this._pasteViewCenter = { x: viewCenterX, y: viewCenterY }; - // First paste (pasteIndex=0) centres content on the viewport. // Each subsequent paste shifts right by the actual rendered width so // copies never overlap, even for wide label text. diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index 72128ab..7ee88d8 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -657,29 +657,41 @@ describe('BoxesEditor', () => { }; clipboardStub._value = JSON.stringify(clip); - // First paste — establishes a cascade at pasteOffset=0, records viewCenter. - await editor.paste(); - expect(editor._pasteOffset).toBe(1); + // Build up a non-zero cascade offset (paste several times without panning). + await editor.paste(); // index 0, pasteOffset → 1 + await editor.paste(); // index 1, pasteOffset → 2 + await editor.paste(); // index 2, pasteOffset → 3 + expect(editor._pasteOffset).toBe(3); - // Simulate pan: shift the viewport so the center changes significantly. + // Simulate a significant pan so the viewport center moves. editor.cy.pan({ x: editor.cy.pan().x + 500, y: editor.cy.pan().y }); - // Second paste — viewport center has changed; offset should reset to 0 - // so the copy is centered on the NEW viewport center rather than - // continuing the old cascade. + // Paste after panning — the viewport-change check must fire and reset + // _pasteOffset to 0 BEFORE the positioning logic uses it. If the reset + // didn't fire, this paste would use index 3 (offset by 3*bb.w from center). await editor.paste(); - // After the second paste the offset should be 1 again (reset to 0 then incremented). + // The paste that triggered the reset used pasteIndex=0 (landed at center). + // _pasteOffset is then incremented to 1, ready for the next cascade entry. expect(editor._pasteOffset).toBe(1); - // Both pasted nodes should land at the viewport center, not offset by bb.w. + // Confirm the paste-after-pan node is centred on the NEW viewport center. const ext = editor.cy.extent(); const viewCenterX = (ext.x1 + ext.x2) / 2; const allNodes = editor.getElements().nodes; - expect(allNodes).toHaveLength(2); - // The second pasted node should be centred on the current viewport center. - const secondNode = allNodes[allNodes.length - 1]; - expect(secondNode.position.x).toBeCloseTo(viewCenterX, 1); + expect(allNodes).toHaveLength(4); + const lastNode = allNodes[allNodes.length - 1]; + expect(lastNode.position.x).toBeCloseTo(viewCenterX, 1); + + // A further paste without panning should be at index 1 (center+bb.w), + // NOT at index 0 again (confirming the cascade continues correctly). + await editor.paste(); + expect(editor._pasteOffset).toBe(2); + const nodesAfter = editor.getElements().nodes; + expect(nodesAfter).toHaveLength(5); + const cascadeNode = nodesAfter[nodesAfter.length - 1]; + // It should be further right than the previous paste, not at the same position. + expect(cascadeNode.position.x).toBeGreaterThan(lastNode.position.x + 1); }); }); From 6b893ce84268d49d693d3996206aef3c28716458 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:16:05 +0000 Subject: [PATCH 8/9] importGraph: nudge all nodes 1px right then left on open (no undo) Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/73b371dd-9c0c-41ef-9b89-0d588c512cc0 Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/boxes-editor.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 1a727f1..a42f418 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -2080,6 +2080,16 @@ export class BoxesEditor { this.cy.elements().boundingBox({ useCache: false }); this.cy.fit(undefined, 30); this.cy.style().update(); + + // Nudge every node 1px right then 1px left. This is a zero-net-movement + // force-render trick that causes Cytoscape to fully recalculate edge + // control-point geometry after a graph is opened. These moves are + // intentionally NOT recorded in the undo history. + const nodes = this.cy.nodes(); + if (nodes.length) { + nodes.forEach(n => { const p = n.position(); n.position({ x: p.x + 1, y: p.y }); }); + nodes.forEach(n => { const p = n.position(); n.position({ x: p.x - 1, y: p.y }); }); + } } /** Return true if loaded nodes have no real position data */ From a6ae7db0f7f1add75a83220434afb481864041d8 Mon Sep 17 00:00:00 2001 From: Jamie McCusker Date: Mon, 6 Apr 2026 22:49:23 +0000 Subject: [PATCH 9/9] Move the nudge to have a real effect in cytoscape without affecting the actual positions. If there's no net effect, the re-render isn't actually triggered. --- packages/core/src/boxes-editor.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index a42f418..1c8580a 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -2045,6 +2045,14 @@ export class BoxesEditor { this._renderPalette(); } if (graphData.elements) { + graphData.elements.nodes.forEach(n => { + if (n.position) { + // Nudge every node 1px left. We will nudge it back to the right by 1 px after loading, + // as a force-render trick to ensure edge control points are properly calculated and rendered on load. + // See the end of this method for details. + n.position.x = n.position.x - 1; + } + }); this.loadElements(graphData.elements); } const incoming = graphData.userStylesheet || graphData.stylesheet; @@ -2081,14 +2089,13 @@ export class BoxesEditor { this.cy.fit(undefined, 30); this.cy.style().update(); - // Nudge every node 1px right then 1px left. This is a zero-net-movement + // Nudge every node 1px right. This is a negligible movement // force-render trick that causes Cytoscape to fully recalculate edge // control-point geometry after a graph is opened. These moves are // intentionally NOT recorded in the undo history. const nodes = this.cy.nodes(); if (nodes.length) { nodes.forEach(n => { const p = n.position(); n.position({ x: p.x + 1, y: p.y }); }); - nodes.forEach(n => { const p = n.position(); n.position({ x: p.x - 1, y: p.y }); }); } }