Graph editor: unified card colors, keyboard focus, color picker button
- Unified graph node colors with card color system (shared localStorage) - Added color picker palette button to node overlay toolbar - Auto-focus graph container for keyboard shortcuts to work immediately - Trap Tab key to prevent focus escaping to footer - Added mandatory bundle rebuild note to CLAUDE.md files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -96,7 +96,13 @@ ast-index update # Incremental update after file change
|
||||
|
||||
**Whenever server-side Python code is modified** (any file under `/server/src/` **excluding** `/server/src/wled_controller/static/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
|
||||
|
||||
**No restart needed for frontend-only changes.** Files under `/server/src/wled_controller/static/` (HTML, JS, CSS, JSON locale files) are served directly by FastAPI's static file handler — changes take effect on the next browser page refresh without restarting the server.
|
||||
**No restart needed for frontend-only changes** — but you **MUST rebuild the bundle**. The browser loads the esbuild bundle (`static/dist/app.bundle.js`, `static/dist/app.bundle.css`), NOT the source files. After ANY change to frontend files (JS, CSS under `/server/src/wled_controller/static/`), run:
|
||||
|
||||
```bash
|
||||
cd server && npm run build
|
||||
```
|
||||
|
||||
Without this step, changes will NOT take effect. No server restart is needed — just rebuild and refresh the browser.
|
||||
|
||||
### Restart procedure
|
||||
|
||||
@@ -136,6 +142,10 @@ Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the
|
||||
- **When a task is fully done**: mark it `- [x]` and leave it for the user to clean up
|
||||
- **When the user requests a new feature/fix**: add it to the appropriate section with a priority tag
|
||||
|
||||
## Documentation Lookup
|
||||
|
||||
**Use context7 MCP tools for library/framework documentation lookups.** When you need to check API signatures, usage patterns, or current behavior of external libraries (e.g., FastAPI, OpenCV, Pydantic, yt-dlp), use `mcp__plugin_context7_context7__resolve-library-id` to find the library, then `mcp__plugin_context7_context7__query-docs` to fetch up-to-date docs. This avoids relying on potentially outdated training data.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Always test changes before marking as complete
|
||||
|
||||
@@ -54,7 +54,7 @@ netstat -an | grep 8080
|
||||
- **Permission errors**: Ensure file permissions allow Python to execute
|
||||
|
||||
#### Files that DON'T require restart:
|
||||
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
|
||||
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - but you **MUST rebuild the bundle** after changes: `cd server && npm run build`
|
||||
- Locale files (`static/locales/*.json`) - loaded by frontend
|
||||
- Documentation files (`*.md`)
|
||||
- Configuration files in `config/` if server supports hot-reload (check implementation)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js';
|
||||
import { EDGE_COLORS } from './graph-edges.js';
|
||||
import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.js';
|
||||
import { getCardColor, setCardColor } from './card-colors.js';
|
||||
import * as P from './icon-paths.js';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
@@ -90,26 +91,10 @@ export function renderNodes(group, nodeMap, callbacks = {}) {
|
||||
/**
|
||||
* Render a single node.
|
||||
*/
|
||||
// Per-node color overrides (persisted in localStorage)
|
||||
const _NC_KEY = 'graph_node_colors';
|
||||
let _nodeColorOverrides = null;
|
||||
|
||||
function _loadNodeColors() {
|
||||
if (_nodeColorOverrides) return _nodeColorOverrides;
|
||||
try { _nodeColorOverrides = JSON.parse(localStorage.getItem(_NC_KEY)) || {}; } catch { _nodeColorOverrides = {}; }
|
||||
return _nodeColorOverrides;
|
||||
}
|
||||
|
||||
function _saveNodeColor(nodeId, color) {
|
||||
const map = _loadNodeColors();
|
||||
map[nodeId] = color;
|
||||
localStorage.setItem(_NC_KEY, JSON.stringify(map));
|
||||
}
|
||||
|
||||
/** Return custom color for a node, or null if none set. */
|
||||
export function getNodeColor(nodeId) {
|
||||
const map = _loadNodeColors();
|
||||
return map[nodeId] || null;
|
||||
return getCardColor(nodeId) || null;
|
||||
}
|
||||
|
||||
/** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */
|
||||
@@ -117,6 +102,61 @@ export function getNodeDisplayColor(nodeId, kind) {
|
||||
return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666';
|
||||
}
|
||||
|
||||
/** Open a color picker for a graph node, positioned near the click point. */
|
||||
function _openNodeColorPicker(node, e) {
|
||||
const nodeEl = e.target.closest('.graph-node') || document.querySelector(`.graph-node[data-id="${node.id}"]`);
|
||||
if (!nodeEl) return;
|
||||
const svg = nodeEl.ownerSVGElement;
|
||||
const container = svg?.closest('.graph-container');
|
||||
if (!svg || !container) return;
|
||||
|
||||
// Remove any previous graph color picker overlay
|
||||
container.querySelector('.graph-cp-overlay')?.remove();
|
||||
closeAllColorPickers();
|
||||
|
||||
// Compute position relative to container
|
||||
const cr = container.getBoundingClientRect();
|
||||
const px = e.clientX - cr.left;
|
||||
const py = e.clientY - cr.top;
|
||||
|
||||
// Create an HTML overlay with the custom color picker
|
||||
const pickerId = `graph-node-${node.id}`;
|
||||
const cpOverlay = document.createElement('div');
|
||||
cpOverlay.className = 'graph-cp-overlay';
|
||||
cpOverlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`;
|
||||
const curColor = getNodeColor(node.id);
|
||||
cpOverlay.innerHTML = createColorPicker({
|
||||
id: pickerId,
|
||||
currentColor: curColor || ENTITY_COLORS[node.kind] || '#666',
|
||||
anchor: 'left',
|
||||
showReset: true,
|
||||
resetColor: '#808080',
|
||||
});
|
||||
container.appendChild(cpOverlay);
|
||||
|
||||
// Register callback to update the bar color
|
||||
registerColorPicker(pickerId, (hex) => {
|
||||
const bar = nodeEl.querySelector('.graph-node-color-bar');
|
||||
const barCover = bar?.nextElementSibling;
|
||||
if (bar) {
|
||||
if (hex) {
|
||||
bar.setAttribute('fill', hex);
|
||||
if (barCover) barCover.setAttribute('fill', hex);
|
||||
bar.style.display = '';
|
||||
if (barCover) barCover.style.display = '';
|
||||
} else {
|
||||
bar.style.display = 'none';
|
||||
if (barCover) barCover.style.display = 'none';
|
||||
}
|
||||
}
|
||||
setCardColor(node.id, hex);
|
||||
cpOverlay.remove();
|
||||
});
|
||||
|
||||
// Open the popover immediately
|
||||
window._cpToggle(pickerId);
|
||||
}
|
||||
|
||||
function renderNode(node, callbacks) {
|
||||
const { id, kind, name, subtype, x, y, width, height, running } = node;
|
||||
let color = getNodeColor(id);
|
||||
@@ -167,45 +207,7 @@ function renderNode(node, callbacks) {
|
||||
barHit.style.cursor = 'pointer';
|
||||
barHit.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const svg = barHit.ownerSVGElement;
|
||||
const container = svg?.closest('.graph-container');
|
||||
if (!svg || !container) return;
|
||||
|
||||
// Remove any previous graph color picker overlay
|
||||
container.querySelector('.graph-cp-overlay')?.remove();
|
||||
closeAllColorPickers();
|
||||
|
||||
// Compute position relative to container
|
||||
const ctm = barHit.getScreenCTM();
|
||||
const cr = container.getBoundingClientRect();
|
||||
const px = (ctm ? ctm.e : e.clientX) - cr.left;
|
||||
const py = (ctm ? ctm.f : e.clientY) - cr.top;
|
||||
|
||||
// Create an HTML overlay with the custom color picker
|
||||
const pickerId = `graph-node-${id}`;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'graph-cp-overlay';
|
||||
overlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`;
|
||||
overlay.innerHTML = createColorPicker({
|
||||
id: pickerId,
|
||||
currentColor: color || ENTITY_COLORS[kind] || '#666',
|
||||
anchor: 'left',
|
||||
});
|
||||
container.appendChild(overlay);
|
||||
|
||||
// Register callback to update the bar color
|
||||
registerColorPicker(pickerId, (hex) => {
|
||||
color = hex;
|
||||
bar.setAttribute('fill', hex);
|
||||
barCover.setAttribute('fill', hex);
|
||||
bar.style.display = '';
|
||||
barCover.style.display = '';
|
||||
_saveNodeColor(id, hex);
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
// Open the popover immediately
|
||||
window._cpToggle(pickerId);
|
||||
_openNodeColorPicker(node, e);
|
||||
});
|
||||
g.appendChild(barHit);
|
||||
|
||||
@@ -371,6 +373,9 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
// Clone (smaller scale to fit the compact button)
|
||||
btns.push({ svgPath: P.copy, action: 'clone', cls: '', scale: 0.6 });
|
||||
|
||||
// Color picker
|
||||
btns.push({ svgPath: P.palette, action: 'color', cls: '', scale: 0.6 });
|
||||
|
||||
// Always: edit and delete
|
||||
btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎
|
||||
btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖
|
||||
@@ -400,6 +405,7 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
test: 'Test / Preview',
|
||||
notify: 'Test notification',
|
||||
clone: 'Clone',
|
||||
color: 'Color',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
};
|
||||
@@ -440,6 +446,7 @@ function _createOverlay(node, nodeWidth, callbacks) {
|
||||
if (btn.action === 'notify' && callbacks.onNotificationTest) callbacks.onNotificationTest(node);
|
||||
if (btn.action === 'clone' && callbacks.onCloneNode) callbacks.onCloneNode(node);
|
||||
if (btn.action === 'activate' && callbacks.onActivatePreset) callbacks.onActivatePreset(node);
|
||||
if (btn.action === 'color') _openNodeColorPicker(node, e);
|
||||
});
|
||||
overlay.appendChild(bg);
|
||||
});
|
||||
|
||||
@@ -261,6 +261,8 @@ export async function loadGraphEditor() {
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
// Ensure keyboard focus whenever the graph is (re-)loaded
|
||||
container.focus();
|
||||
}
|
||||
|
||||
export function toggleGraphLegend() {
|
||||
@@ -816,6 +818,11 @@ function _renderGraph(container) {
|
||||
|
||||
container.addEventListener('keydown', _onKeydown);
|
||||
container.setAttribute('tabindex', '0');
|
||||
container.style.outline = 'none';
|
||||
// Focus the container so keyboard shortcuts work immediately
|
||||
container.focus();
|
||||
// Re-focus when clicking inside the graph
|
||||
svgEl.addEventListener('pointerdown', () => container.focus());
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@@ -1457,6 +1464,9 @@ function _onNotificationTest(node) {
|
||||
/* ── Keyboard ── */
|
||||
|
||||
function _onKeydown(e) {
|
||||
// Trap Tab inside the graph to prevent focus escaping to footer
|
||||
if (e.key === 'Tab') { e.preventDefault(); return; }
|
||||
|
||||
// Skip when typing in search input (except Escape/F11)
|
||||
const inInput = e.target.matches('input, textarea, select');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user