diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 2715ebf..1c8580a 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -325,12 +325,15 @@ export class BoxesEditor { this._redoStack = []; this._restoringState = false; this._preGrabSnapshot = null; - this._clipboard = null; // { nodes: [...json], edges: [...json] } - this._pasteOffset = 0; // increments each paste so repeated pastes cascade + 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; this._ctxPosition = null; + this._findMatches = []; + this._findCurrentIdx = -1; this.context = { ...(options.context ?? tmpl.context ?? {}) }; this._init(); @@ -439,6 +442,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 +484,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 +551,29 @@ 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.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); + toolbar.appendChild(this._pasteBtn); + 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 +768,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') { @@ -696,6 +783,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(); } @@ -1435,6 +1528,29 @@ 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._pasteViewCenter = null; + this._updateClipboardButtons(); + } + } catch (_) { /* not valid JSON */ } + }).catch(() => { /* permission denied or unavailable */ }); + } + _init() { this._injectCSS(); this._createUI(); @@ -1525,6 +1641,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 } } ]; @@ -1576,12 +1700,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) => { @@ -1919,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; @@ -1954,6 +2088,15 @@ export class BoxesEditor { this.cy.elements().boundingBox({ useCache: false }); this.cy.fit(undefined, 30); this.cy.style().update(); + + // 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 }); }); + } } /** Return true if loaded nodes have no real position data */ @@ -2064,6 +2207,15 @@ 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. + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(JSON.stringify(this._clipboard)).catch(() => {}); + } + + this._updateClipboardButtons(); this._emit('clipboardChange', { hasClipboard: true }); return true; } @@ -2080,35 +2232,74 @@ 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; - this._pushUndo(); + 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._pasteViewCenter = null; + } + this._clipboard = parsed; + clipData = this._clipboard; + this._updateClipboardButtons(); + } + } + } catch (_) { + // Permission denied, clipboard unavailable, or invalid JSON — use local clipboard. + } - this._pasteOffset += 20; - const offset = this._pasteOffset; + if (!clipData) return false; - // Map old node IDs → new node IDs + // 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. + // 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 = []; - this._clipboard.nodes.forEach(nodeJson => { + clipData.nodes.forEach(nodeJson => { 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 + offset, y: nodeJson.position.y + offset } - : 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); }); - 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 @@ -2124,6 +2315,29 @@ 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; + + // 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; @@ -2217,6 +2431,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 +2645,8 @@ export class BoxesEditor { this._contextEditor.destroy(); this._contextEditor = null; } + this._findMatches = []; + this._findCurrentIdx = -1; if (this.cy) { this.cy.destroy(); this.cy = null; @@ -2330,6 +2660,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 6f24cfe..7ee88d8 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(() => { @@ -368,6 +381,320 @@ 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('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('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', () => { + 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', async () => { + editor.addNode({ id: 'n1', label: 'A' }); + editor.selectElements(['n1']); + editor.copy(); + const before = editor.getElements().nodes.length; + 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); + }); + + it('paste() centers content on the viewport on first paste', async () => { + // Clipboard has two nodes at known positions. + 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; + + // 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 () => { + 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 + // 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); + + // 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(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); + + // 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 a significant pan so the viewport center moves. + editor.cy.pan({ x: editor.cy.pan().x + 500, y: editor.cy.pan().y }); + + // 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(); + + // 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); + + // 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(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); + }); + }); + describe('cleanup', () => { it('should destroy the editor properly', () => { editor = new BoxesEditor(container);