Add graph editor filter, anchor-based positioning, and context docs
- Add name/kind/subtype filter bar with keyboard shortcut (F key) - Filtered-out nodes get dimmed styling, nearly invisible on minimap - Add anchor-based positioning for minimap and toolbar (remembers which corner element is closest to, maintains offset on resize) - Fix minimap not movable after reload (_applyMinimapAnchor undefined) - Fix ResizeObserver to use anchor system for both minimap and toolbar - Add graph-editor.md context file and update frontend.md with graph sync notes - Add filter i18n keys for en/ru/zh locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ let _searchVisible = false;
|
||||
let _searchIndex = -1;
|
||||
let _searchItems = [];
|
||||
let _loading = false;
|
||||
let _filterVisible = false;
|
||||
let _filterQuery = ''; // current active filter text
|
||||
|
||||
// Node drag state
|
||||
let _dragState = null; // { nodeId, el, startClient, startNode, dragging }
|
||||
@@ -53,7 +55,7 @@ let _edgeContextMenu = null;
|
||||
// Selected edge for Delete key detach
|
||||
let _selectedEdge = null; // { from, to, field, targetKind }
|
||||
|
||||
// Minimap position/size persisted in localStorage
|
||||
// Minimap position/size persisted in localStorage (with anchor corner)
|
||||
const _MM_KEY = 'graph_minimap';
|
||||
function _loadMinimapRect() {
|
||||
try { return JSON.parse(localStorage.getItem(_MM_KEY)); } catch { return null; }
|
||||
@@ -61,6 +63,46 @@ function _loadMinimapRect() {
|
||||
function _saveMinimapRect(r) {
|
||||
localStorage.setItem(_MM_KEY, JSON.stringify(r));
|
||||
}
|
||||
/**
|
||||
* Anchor-based positioning: detect closest corner, store offset from that corner,
|
||||
* and reposition from that corner on resize. Works for minimap, toolbar, etc.
|
||||
*/
|
||||
function _anchorCorner(el, container) {
|
||||
const cr = container.getBoundingClientRect();
|
||||
const cx = el.offsetLeft + el.offsetWidth / 2;
|
||||
const cy = el.offsetTop + el.offsetHeight / 2;
|
||||
return (cy > cr.height / 2 ? 'b' : 't') + (cx > cr.width / 2 ? 'r' : 'l');
|
||||
}
|
||||
function _saveAnchored(el, container, saveFn) {
|
||||
const cr = container.getBoundingClientRect();
|
||||
const anchor = _anchorCorner(el, container);
|
||||
const data = {
|
||||
width: el.offsetWidth,
|
||||
height: el.offsetHeight,
|
||||
anchor,
|
||||
offsetX: anchor.includes('r') ? cr.width - el.offsetLeft - el.offsetWidth : el.offsetLeft,
|
||||
offsetY: anchor.includes('b') ? cr.height - el.offsetTop - el.offsetHeight : el.offsetTop,
|
||||
};
|
||||
saveFn(data);
|
||||
return data;
|
||||
}
|
||||
function _applyAnchor(el, container, saved) {
|
||||
if (!saved?.anchor) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const w = saved.width || el.offsetWidth;
|
||||
const h = saved.height || el.offsetHeight;
|
||||
const ox = Math.max(0, saved.offsetX || 0);
|
||||
const oy = Math.max(0, saved.offsetY || 0);
|
||||
let l = saved.anchor.includes('r') ? cr.width - w - ox : ox;
|
||||
let t = saved.anchor.includes('b') ? cr.height - h - oy : oy;
|
||||
l = Math.max(0, Math.min(cr.width - el.offsetWidth, l));
|
||||
t = Math.max(0, Math.min(cr.height - el.offsetHeight, t));
|
||||
el.style.left = l + 'px';
|
||||
el.style.top = t + 'px';
|
||||
}
|
||||
/** Shorthand wrappers for minimap / toolbar. */
|
||||
function _saveMinimapAnchored(el, container) { return _saveAnchored(el, container, _saveMinimapRect); }
|
||||
function _saveToolbarAnchored(el, container) { return _saveAnchored(el, container, _saveToolbarPos); }
|
||||
|
||||
// Toolbar position persisted in localStorage
|
||||
const _TB_KEY = 'graph_toolbar';
|
||||
@@ -142,6 +184,65 @@ export function toggleGraphMinimap() {
|
||||
if (mm) mm.classList.toggle('visible', _minimapVisible);
|
||||
}
|
||||
|
||||
export function toggleGraphFilter() {
|
||||
_filterVisible = !_filterVisible;
|
||||
const bar = document.querySelector('.graph-filter');
|
||||
if (!bar) return;
|
||||
bar.classList.toggle('visible', _filterVisible);
|
||||
if (_filterVisible) {
|
||||
const input = bar.querySelector('.graph-filter-input');
|
||||
if (input) { input.value = _filterQuery; input.focus(); }
|
||||
} else {
|
||||
_applyFilter('');
|
||||
}
|
||||
}
|
||||
|
||||
function _applyFilter(query) {
|
||||
_filterQuery = query;
|
||||
const q = query.toLowerCase().trim();
|
||||
const nodeGroup = document.querySelector('.graph-nodes');
|
||||
const edgeGroup = document.querySelector('.graph-edges');
|
||||
const mm = document.querySelector('.graph-minimap');
|
||||
|
||||
if (!_nodeMap) return;
|
||||
|
||||
// Build set of matching node IDs
|
||||
const matchIds = new Set();
|
||||
for (const node of _nodeMap.values()) {
|
||||
if (!q || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q)) {
|
||||
matchIds.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filtered-out class to nodes
|
||||
if (nodeGroup) {
|
||||
nodeGroup.querySelectorAll('.graph-node').forEach(el => {
|
||||
el.classList.toggle('graph-filtered-out', !!q && !matchIds.has(el.getAttribute('data-id')));
|
||||
});
|
||||
}
|
||||
|
||||
// Dim edges where either endpoint is filtered out
|
||||
if (edgeGroup) {
|
||||
edgeGroup.querySelectorAll('.graph-edge').forEach(el => {
|
||||
const from = el.getAttribute('data-from');
|
||||
const to = el.getAttribute('data-to');
|
||||
el.classList.toggle('graph-filtered-out', !!q && (!matchIds.has(from) || !matchIds.has(to)));
|
||||
});
|
||||
}
|
||||
|
||||
// Dim minimap nodes
|
||||
if (mm) {
|
||||
mm.querySelectorAll('.graph-minimap-node').forEach(el => {
|
||||
const id = el.getAttribute('data-id');
|
||||
el.setAttribute('opacity', (!q || matchIds.has(id)) ? '0.7' : '0.07');
|
||||
});
|
||||
}
|
||||
|
||||
// Update filter button active state
|
||||
const btn = document.querySelector('.graph-filter-btn');
|
||||
if (btn) btn.classList.toggle('active', !!q);
|
||||
}
|
||||
|
||||
export function graphFitAll() {
|
||||
if (_canvas && _bounds) _canvas.fitAll(_bounds);
|
||||
}
|
||||
@@ -400,6 +501,7 @@ function _renderGraph(container) {
|
||||
_renderLegend(container.querySelector('.graph-legend'));
|
||||
_initMinimap(container.querySelector('.graph-minimap'));
|
||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||
_initResizeClamp(container);
|
||||
_initNodeDrag(nodeGroup, edgeGroup);
|
||||
_initPortDrag(svgEl, nodeGroup, edgeGroup);
|
||||
_initRubberBand(svgEl);
|
||||
@@ -427,6 +529,28 @@ function _renderGraph(container) {
|
||||
searchInput.addEventListener('keydown', _onSearchKeydown);
|
||||
}
|
||||
|
||||
const filterInput = container.querySelector('.graph-filter-input');
|
||||
if (filterInput) {
|
||||
filterInput.addEventListener('input', (e) => _applyFilter(e.target.value));
|
||||
filterInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') toggleGraphFilter();
|
||||
});
|
||||
}
|
||||
const filterClear = container.querySelector('.graph-filter-clear');
|
||||
if (filterClear) {
|
||||
filterClear.addEventListener('click', () => {
|
||||
if (filterInput) filterInput.value = '';
|
||||
_applyFilter('');
|
||||
});
|
||||
}
|
||||
|
||||
// Restore active filter if re-rendering
|
||||
if (_filterQuery && _filterVisible) {
|
||||
const bar = container.querySelector('.graph-filter');
|
||||
if (bar) bar.classList.add('visible');
|
||||
_applyFilter(_filterQuery);
|
||||
}
|
||||
|
||||
// Deselect on click on empty space (not after a pan gesture)
|
||||
svgEl.addEventListener('click', (e) => {
|
||||
_dismissEdgeContextMenu();
|
||||
@@ -467,10 +591,11 @@ function _deselect(nodeGroup, edgeGroup) {
|
||||
|
||||
function _graphHTML() {
|
||||
const mmRect = _loadMinimapRect();
|
||||
// Default: bottom-right corner with 12px margin (computed after render via _initMinimap)
|
||||
const mmStyle = mmRect ? `left:${mmRect.left}px;top:${mmRect.top}px;width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
||||
// Only set size from saved state; position is applied in _initMinimap via anchor logic
|
||||
const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
||||
// Toolbar position is applied in _initToolbarDrag via anchor logic
|
||||
const tbPos = _loadToolbarPos();
|
||||
const tbStyle = tbPos ? `left:${tbPos.left}px;top:${tbPos.top}px;` : '';
|
||||
const tbStyle = tbPos && !tbPos.anchor ? `left:${tbPos.left}px;top:${tbPos.top}px;` : '';
|
||||
|
||||
return `
|
||||
<div class="graph-container">
|
||||
@@ -490,6 +615,9 @@ function _graphHTML() {
|
||||
<button class="btn-icon" onclick="openGraphSearch()" title="${t('graph.search')} (/)">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)">
|
||||
<svg class="icon" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
|
||||
</button>
|
||||
@@ -525,6 +653,12 @@ function _graphHTML() {
|
||||
<div class="graph-search-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="graph-filter">
|
||||
<svg class="graph-filter-icon" viewBox="0 0 24 24" width="16" height="16"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
|
||||
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">×</button>
|
||||
</div>
|
||||
|
||||
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
|
||||
@@ -580,13 +714,18 @@ function _initMinimap(mmEl) {
|
||||
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
|
||||
svg.innerHTML = html;
|
||||
|
||||
// Set default position (bottom-right corner) if no saved position
|
||||
if (!mmEl.style.left) {
|
||||
// Apply saved anchored position or default to bottom-right
|
||||
const saved = _loadMinimapRect();
|
||||
if (saved?.anchor) {
|
||||
if (saved.width) mmEl.style.width = saved.width + 'px';
|
||||
if (saved.height) mmEl.style.height = saved.height + 'px';
|
||||
_applyAnchor(mmEl, container, saved);
|
||||
} else if (!mmEl.style.left || mmEl.style.left === '0px') {
|
||||
const cr = container.getBoundingClientRect();
|
||||
mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px';
|
||||
mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px';
|
||||
mmEl.style.width = '200px';
|
||||
mmEl.style.height = '130px';
|
||||
mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px';
|
||||
mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px';
|
||||
}
|
||||
|
||||
// Initial viewport update
|
||||
@@ -596,14 +735,7 @@ function _initMinimap(mmEl) {
|
||||
|
||||
// Helper to clamp minimap within container
|
||||
function _clampMinimap() {
|
||||
const cr = container.getBoundingClientRect();
|
||||
const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight;
|
||||
let l = parseFloat(mmEl.style.left) || 0;
|
||||
let t = parseFloat(mmEl.style.top) || 0;
|
||||
l = Math.max(0, Math.min(cr.width - mw, l));
|
||||
t = Math.max(0, Math.min(cr.height - mh, t));
|
||||
mmEl.style.left = l + 'px';
|
||||
mmEl.style.top = t + 'px';
|
||||
_clampElementInContainer(mmEl, container);
|
||||
}
|
||||
|
||||
// ── Click on minimap SVG → pan main canvas to that point ──
|
||||
@@ -645,7 +777,7 @@ function _initMinimap(mmEl) {
|
||||
mmEl.style.top = t + 'px';
|
||||
});
|
||||
header.addEventListener('pointerup', () => {
|
||||
if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapRect(_mmRect(mmEl)); }
|
||||
if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapAnchored(mmEl, container); }
|
||||
});
|
||||
|
||||
// ── Resize handles ──
|
||||
@@ -683,7 +815,7 @@ function _initMinimap(mmEl) {
|
||||
}
|
||||
_clampMinimap();
|
||||
});
|
||||
rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapRect(_mmRect(mmEl)); } });
|
||||
rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapAnchored(mmEl, container); } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,31 +844,65 @@ function _mmRect(mmEl) {
|
||||
return { left: mmEl.offsetLeft, top: mmEl.offsetTop, width: mmEl.offsetWidth, height: mmEl.offsetHeight };
|
||||
}
|
||||
|
||||
/* ── Toolbar drag ── */
|
||||
/* ── Shared element clamping ── */
|
||||
|
||||
function _clampToolbar(tbEl) {
|
||||
if (!tbEl) return;
|
||||
const container = tbEl.closest('.graph-container');
|
||||
if (!container) return;
|
||||
/** Clamp an absolutely-positioned element within its container. */
|
||||
function _clampElementInContainer(el, container) {
|
||||
if (!el || !container) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const tw = tbEl.offsetWidth, th = tbEl.offsetHeight;
|
||||
if (!tw || !th) return; // not rendered yet
|
||||
let l = tbEl.offsetLeft, top = tbEl.offsetTop;
|
||||
const clamped = {
|
||||
left: Math.max(0, Math.min(cr.width - tw, l)),
|
||||
top: Math.max(0, Math.min(cr.height - th, top)),
|
||||
};
|
||||
if (clamped.left !== l || clamped.top !== top) {
|
||||
tbEl.style.left = clamped.left + 'px';
|
||||
tbEl.style.top = clamped.top + 'px';
|
||||
_saveToolbarPos(clamped);
|
||||
const ew = el.offsetWidth, eh = el.offsetHeight;
|
||||
if (!ew || !eh) return;
|
||||
let l = el.offsetLeft, t = el.offsetTop;
|
||||
const cl = Math.max(0, Math.min(cr.width - ew, l));
|
||||
const ct = Math.max(0, Math.min(cr.height - eh, t));
|
||||
if (cl !== l || ct !== t) {
|
||||
el.style.left = cl + 'px';
|
||||
el.style.top = ct + 'px';
|
||||
}
|
||||
return { left: cl, top: ct };
|
||||
}
|
||||
|
||||
let _resizeObserver = null;
|
||||
|
||||
function _initResizeClamp(container) {
|
||||
if (_resizeObserver) _resizeObserver.disconnect();
|
||||
_resizeObserver = new ResizeObserver(() => {
|
||||
const mm = container.querySelector('.graph-minimap');
|
||||
const tb = container.querySelector('.graph-toolbar');
|
||||
if (mm) {
|
||||
const saved = _loadMinimapRect();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(mm, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(mm, container);
|
||||
}
|
||||
}
|
||||
if (tb) {
|
||||
const saved = _loadToolbarPos();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(tb, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(tb, container);
|
||||
}
|
||||
}
|
||||
});
|
||||
_resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
/* ── Toolbar drag ── */
|
||||
|
||||
function _initToolbarDrag(tbEl) {
|
||||
if (!tbEl) return;
|
||||
_clampToolbar(tbEl); // ensure saved position is still valid
|
||||
const container = tbEl.closest('.graph-container');
|
||||
|
||||
// Apply saved anchor position or clamp
|
||||
const saved = _loadToolbarPos();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(tbEl, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(tbEl, container);
|
||||
}
|
||||
|
||||
const handle = tbEl.querySelector('.graph-toolbar-drag');
|
||||
if (!handle) return;
|
||||
|
||||
@@ -762,7 +928,7 @@ function _initToolbarDrag(tbEl) {
|
||||
handle.addEventListener('pointerup', () => {
|
||||
if (dragStart) {
|
||||
dragStart = null;
|
||||
_saveToolbarPos({ left: tbEl.offsetLeft, top: tbEl.offsetTop });
|
||||
_saveToolbarAnchored(tbEl, container);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -972,8 +1138,10 @@ function _onKeydown(e) {
|
||||
const inInput = e.target.matches('input, textarea, select');
|
||||
|
||||
if (e.key === '/' && !_searchVisible && !inInput) { e.preventDefault(); openGraphSearch(); }
|
||||
if ((e.key === 'f' || e.key === 'F') && !inInput && !e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleGraphFilter(); }
|
||||
if (e.key === 'Escape') {
|
||||
if (_searchVisible) { closeGraphSearch(); }
|
||||
if (_filterVisible) { toggleGraphFilter(); }
|
||||
else if (_searchVisible) { closeGraphSearch(); }
|
||||
else {
|
||||
const ng = document.querySelector('.graph-nodes');
|
||||
const eg = document.querySelector('.graph-edges');
|
||||
|
||||
Reference in New Issue
Block a user