fix grouping functionality

This commit is contained in:
matei jordache
2026-03-27 13:27:39 -07:00
parent 46e6457c34
commit 98d36eb327
12 changed files with 403 additions and 42 deletions

View File

@@ -85,6 +85,7 @@ http://127.0.0.1:5173
Notes: Notes:
- The frontend dev server proxies API and WebSocket requests to the backend. - The frontend dev server proxies API and WebSocket requests to the backend.
- `npm run dev` now clears Vite's local cache and stale Python bytecode first, then starts Vite with `--force`.
- If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`. - If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`.
- If you want the frontend accessible from other devices on your LAN, run: - If you want the frontend accessible from other devices on your LAN, run:
@@ -95,14 +96,9 @@ npm run dev -- --host 0.0.0.0
## Running the Local Desktop Version ## Running the Local Desktop Version
The desktop launcher starts the Python server internally and opens a native window with `pywebview`. The desktop launcher starts the Python server internally and opens a native window with `pywebview`.
`npm run desktop` now rebuilds the frontend first so the native app always uses a fresh `frontend/dist`.
Build the frontend first: Launch the desktop app from source:
```powershell
npm run build
```
Then launch the desktop app from source:
```powershell ```powershell
npm run desktop npm run desktop
@@ -110,8 +106,7 @@ npm run desktop
Notes: Notes:
- `npm run desktop` uses the built frontend from `frontend/dist`. - `npm run build` clears stale frontend output, Vite cache, and Python bytecode before producing `frontend/dist`.
- If you change frontend code, run `npm run build` again before starting the desktop version.
## Building the Windows `.exe` ## Building the Windows `.exe`

View File

@@ -180,6 +180,20 @@ def _render_annotation_text(text: str, size_px: int, color: tuple[int, int, int]
return text_image return text_image
def _import_ibw_loader():
"""Import igor's binary wave loader with NumPy 2 compatibility."""
if not hasattr(np, "complex"):
# igor 0.3 still references np.complex at import time.
setattr(np, "complex", complex)
try:
from igor.binarywave import load as load_ibw
except ImportError:
raise ImportError("Install 'igor' package to load .ibw files: pip install igor")
return load_ibw
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Markup helpers (from display.py — used by Markup) # Markup helpers (from display.py — used by Markup)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -508,7 +522,7 @@ def list_channels(filepath: str) -> list[dict]:
if ext == ".ibw": if ext == ".ibw":
try: try:
from igor.binarywave import load as load_ibw load_ibw = _import_ibw_loader()
wave = load_ibw(str(path)) wave = load_ibw(str(path))
raw = wave["wave"]["wData"] raw = wave["wave"]["wData"]
labels = wave["wave"].get("labels", None) labels = wave["wave"].get("labels", None)

View File

@@ -5,7 +5,7 @@ from pathlib import Path
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.data_types import COLORMAPS, DataField, resolve_colormap_input from backend.data_types import COLORMAPS, DataField, resolve_colormap_input
from backend.nodes.helpers import _resolve_path, _SPM_EXTENSIONS from backend.nodes.helpers import _resolve_path, _SPM_EXTENSIONS, _import_ibw_loader
@register_node(display_name="Image") @register_node(display_name="Image")
@@ -149,11 +149,7 @@ class Image:
@staticmethod @staticmethod
def _load_ibw_all(path: Path) -> list[DataField]: def _load_ibw_all(path: Path) -> list[DataField]:
try: load_ibw = _import_ibw_loader()
from igor.binarywave import load as load_ibw
except ImportError:
raise ImportError("Install 'igor' package to load .ibw files: pip install igor")
wave = load_ibw(str(path)) wave = load_ibw(str(path))
wdata = wave["wave"] wdata = wave["wave"]
header = wdata["wave_header"] header = wdata["wave_header"]

View File

@@ -7,8 +7,8 @@
"npm": ">=9.0.0" "npm": ">=9.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --force",
"build": "vite build", "build": "vite build --emptyOutDir",
"preview": "vite preview", "preview": "vite preview",
"test": "node --test tests/**/*.test.mjs" "test": "node --test tests/**/*.test.mjs"
}, },

View File

@@ -16,6 +16,7 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture'; import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
import { hydrateWorkflowState } from './workflowHydration'; import { hydrateWorkflowState } from './workflowHydration';
import { serializeWorkflowState } from './workflowSerialization'; import { serializeWorkflowState } from './workflowSerialization';
import { sortNodesForParentOrder } from './nodeHierarchy.js';
import { import {
buildNodeClipboardPayload, buildNodeClipboardPayload,
buildNodeClipboardPayloadForIds, buildNodeClipboardPayloadForIds,
@@ -182,6 +183,18 @@ function getNodeCenter(node, nodeMap) {
}; };
} }
function getNodeRect(node, nodeMap) {
const pos = getNodeAbsolutePosition(node, nodeMap);
const width = Number(getNodeDimension(node, 'width')) || 200;
const height = Number(getNodeDimension(node, 'height')) || 120;
return {
left: pos.x,
top: pos.y,
right: pos.x + width,
bottom: pos.y + height,
};
}
function rectContainsPoint(rect, point) { function rectContainsPoint(rect, point) {
return point.x >= rect.left return point.x >= rect.left
&& point.x <= rect.right && point.x <= rect.right
@@ -189,13 +202,60 @@ function rectContainsPoint(rect, point) {
&& point.y <= rect.bottom; && point.y <= rect.bottom;
} }
function findExpandedGroupDropTarget(nodes, draggedNodeIds, anchorNodeId) { function getEventClientPosition(event) {
if (!event) return null;
const point = 'changedTouches' in event && event.changedTouches?.[0]
? event.changedTouches[0]
: ('touches' in event && event.touches?.[0] ? event.touches[0] : event);
if (!Number.isFinite(point?.clientX) || !Number.isFinite(point?.clientY)) return null;
return { x: point.clientX, y: point.clientY };
}
function getEventFlowPosition(event, reactFlow) {
const clientPosition = getEventClientPosition(event);
if (!clientPosition || typeof reactFlow?.screenToFlowPosition !== 'function') return null;
return reactFlow.screenToFlowPosition(clientPosition);
}
function getDragIntent(event, reactFlow, dragState) {
if (!dragState?.pointerOffset || !dragState?.anchorStartAbsolute) return null;
const pointerFlowPos = getEventFlowPosition(event, reactFlow);
if (!pointerFlowPos) return null;
const anchorAbsolute = {
x: pointerFlowPos.x - dragState.pointerOffset.x,
y: pointerFlowPos.y - dragState.pointerOffset.y,
};
const delta = {
x: anchorAbsolute.x - (Number(dragState.anchorStartAbsolute.x) || 0),
y: anchorAbsolute.y - (Number(dragState.anchorStartAbsolute.y) || 0),
};
const absolutePositions = new Map(
Object.entries(dragState.absolutePositions || {}).map(([id, pos]) => [
id,
{
x: (Number(pos?.x) || 0) + delta.x,
y: (Number(pos?.y) || 0) + delta.y,
},
]),
);
return {
pointerFlowPos,
anchorAbsolute,
absolutePositions,
};
}
function findExpandedGroupDropTarget(nodes, draggedNodeIds, anchorNodeId, anchorPoint = null) {
const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node])); const nodeMap = new Map((nodes || []).map((node) => [String(node.id), node]));
const anchorNode = nodeMap.get(String(anchorNodeId)); const anchorNode = nodeMap.get(String(anchorNodeId));
if (!anchorNode) return null; if (!anchorNode) return null;
const draggedIdSet = new Set((draggedNodeIds || []).map((id) => String(id))); const draggedIdSet = new Set((draggedNodeIds || []).map((id) => String(id)));
const anchorCenter = getNodeCenter(anchorNode, nodeMap); const anchorCenter = anchorPoint && Number.isFinite(anchorPoint.x) && Number.isFinite(anchorPoint.y)
? anchorPoint
: getNodeCenter(anchorNode, nodeMap);
return (nodes || []) return (nodes || [])
.filter((node) => ( .filter((node) => (
@@ -712,6 +772,7 @@ function Flow() {
const lastPastedClipboardTextRef = useRef(''); const lastPastedClipboardTextRef = useRef('');
const pasteRepeatCountRef = useRef(0); const pasteRepeatCountRef = useRef(0);
const duplicateDragRef = useRef(null); const duplicateDragRef = useRef(null);
const dragStateRef = useRef(null);
const activeDragNodeIdRef = useRef(null); const activeDragNodeIdRef = useRef(null);
const reactFlow = useReactFlow(); const reactFlow = useReactFlow();
@@ -999,8 +1060,9 @@ function Flow() {
groupNode, groupNode,
]; ];
setNodes(nextNodes); const orderedNodes = sortNodesForParentOrder(nextNodes);
setTimeout(() => refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()), 0); setNodes(orderedNodes);
setTimeout(() => refreshGroupNode(groupId, orderedNodes, reactFlow.getEdges()), 0);
}, [reactFlow, refreshGroupNode, setNodes]); }, [reactFlow, refreshGroupNode, setNodes]);
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => { const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
@@ -1639,10 +1701,10 @@ function Flow() {
nextIdRef.current = pasted.nextNodeId; nextIdRef.current = pasted.nextNodeId;
setNodes((existing) => [ setNodes((existing) => sortNodesForParentOrder([
...existing.map((node) => ({ ...node, selected: false })), ...existing.map((node) => ({ ...node, selected: false })),
...pasted.nodes, ...pasted.nodes,
]); ]));
setEdges((existing) => [ setEdges((existing) => [
...existing.map((edge) => ({ ...edge, selected: false })), ...existing.map((edge) => ({ ...edge, selected: false })),
...pasted.edges, ...pasted.edges,
@@ -1682,7 +1744,7 @@ function Flow() {
const applyWorkflowData = useCallback((data) => { const applyWorkflowData = useCallback((data) => {
const hydrated = hydrateWorkflowState(data, nodeDefsRef.current); const hydrated = hydrateWorkflowState(data, nodeDefsRef.current);
setNodes(hydrated.nodes); setNodes(sortNodesForParentOrder(hydrated.nodes));
setEdges(hydrated.edges); setEdges(hydrated.edges);
nextIdRef.current = hydrated.nextNodeId; nextIdRef.current = hydrated.nextNodeId;
initializeDynamicNodes(hydrated.nodes); initializeDynamicNodes(hydrated.nodes);
@@ -1912,11 +1974,40 @@ function Flow() {
const onNodeDragStart = useCallback((event, node) => { const onNodeDragStart = useCallback((event, node) => {
activeDragNodeIdRef.current = String(node.id); activeDragNodeIdRef.current = String(node.id);
dragStateRef.current = null;
if (!(event.ctrlKey || event.metaKey)) { if (!(event.ctrlKey || event.metaKey)) {
duplicateDragRef.current = null; duplicateDragRef.current = null;
const currentNodes = reactFlow.getNodes();
const draggedNodes = node.data?.className === 'Group'
? []
: (
node.selected
? currentNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
: currentNodes.filter((candidate) => candidate.id === node.id)
);
const pointerFlowPos = getEventFlowPosition(event, reactFlow);
if (draggedNodes.length > 0 && pointerFlowPos) {
const nodeMap = new Map(currentNodes.map((candidate) => [String(candidate.id), candidate]));
const absolutePositions = Object.fromEntries(
draggedNodes.map((candidate) => [
String(candidate.id),
getNodeAbsolutePosition(candidate, nodeMap),
]),
);
const anchorAbsolute = absolutePositions[String(node.id)] || getNodeAbsolutePosition(node, nodeMap);
dragStateRef.current = {
anchorId: String(node.id),
anchorStartAbsolute: anchorAbsolute,
absolutePositions,
pointerOffset: {
x: pointerFlowPos.x - anchorAbsolute.x,
y: pointerFlowPos.y - anchorAbsolute.y,
},
};
}
if (node.data?.className === 'Group') { if (node.data?.className === 'Group') {
const descendantIds = collectGroupDescendantIds(reactFlow.getNodes(), node.id); const descendantIds = collectGroupDescendantIds(currentNodes, node.id);
if (descendantIds.size > 0) { if (descendantIds.size > 0) {
setNodes((existing) => existing.map((candidate) => ( setNodes((existing) => existing.map((candidate) => (
descendantIds.has(String(candidate.id)) descendantIds.has(String(candidate.id))
@@ -1974,10 +2065,10 @@ function Flow() {
duplicateSourceById, duplicateSourceById,
}; };
setNodes((existing) => [ setNodes((existing) => sortNodesForParentOrder([
...existing.map((candidate) => ({ ...candidate, selected: false })), ...existing.map((candidate) => ({ ...candidate, selected: false })),
...duplicated.nodes, ...duplicated.nodes,
]); ]));
setEdges((existing) => [ setEdges((existing) => [
...existing.map((edge) => ({ ...edge, selected: false })), ...existing.map((edge) => ({ ...edge, selected: false })),
...duplicated.edges, ...duplicated.edges,
@@ -2036,6 +2127,8 @@ function Flow() {
if (String(node.id) !== activeDragNodeIdRef.current) return; if (String(node.id) !== activeDragNodeIdRef.current) return;
activeDragNodeIdRef.current = null; activeDragNodeIdRef.current = null;
const dragState = dragStateRef.current;
dragStateRef.current = null;
const duplicateState = duplicateDragRef.current; const duplicateState = duplicateDragRef.current;
duplicateDragRef.current = null; duplicateDragRef.current = null;
if (duplicateState) { if (duplicateState) {
@@ -2089,6 +2182,7 @@ function Flow() {
} }
const currentNodes = reactFlow.getNodes(); const currentNodes = reactFlow.getNodes();
const dragIntent = getDragIntent(event, reactFlow, dragState);
const touchedGroupIds = new Set(); const touchedGroupIds = new Set();
let nextNodes = currentNodes; let nextNodes = currentNodes;
let changed = false; let changed = false;
@@ -2103,9 +2197,23 @@ function Flow() {
if (draggedNodes.length > 0) { if (draggedNodes.length > 0) {
const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id))); const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id)));
const targetGroup = findExpandedGroupDropTarget(nextNodes, Array.from(draggedIdSet), node.id); const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate]));
const anchorNode = nodeMap.get(String(dragState?.anchorId || node.id));
const intendedAnchorAbsolute = dragIntent?.absolutePositions.get(String(anchorNode?.id || node.id))
|| (anchorNode ? getNodeAbsolutePosition(anchorNode, nodeMap) : null);
const intendedAnchorCenter = anchorNode && intendedAnchorAbsolute
? {
x: intendedAnchorAbsolute.x + (Number(getNodeDimension(anchorNode, 'width')) || 200) / 2,
y: intendedAnchorAbsolute.y + (Number(getNodeDimension(anchorNode, 'height')) || 120) / 2,
}
: null;
const targetGroup = findExpandedGroupDropTarget(
nextNodes,
Array.from(draggedIdSet),
node.id,
intendedAnchorCenter,
);
if (targetGroup) { if (targetGroup) {
const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate]));
const targetRect = getGroupWorkspaceBounds(targetGroup, nodeMap); const targetRect = getGroupWorkspaceBounds(targetGroup, nodeMap);
const targetAbs = getNodeAbsolutePosition(targetGroup, nodeMap); const targetAbs = getNodeAbsolutePosition(targetGroup, nodeMap);
let joinedCount = 0; let joinedCount = 0;
@@ -2113,10 +2221,15 @@ function Flow() {
nextNodes = nextNodes.map((candidate) => { nextNodes = nextNodes.map((candidate) => {
if (!draggedIdSet.has(String(candidate.id))) return candidate; if (!draggedIdSet.has(String(candidate.id))) return candidate;
const center = getNodeCenter(candidate, nodeMap); const intendedAbsolute = dragIntent?.absolutePositions.get(String(candidate.id));
const width = Number(getNodeDimension(candidate, 'width')) || 200;
const height = Number(getNodeDimension(candidate, 'height')) || 120;
const center = intendedAbsolute
? { x: intendedAbsolute.x + width / 2, y: intendedAbsolute.y + height / 2 }
: getNodeCenter(candidate, nodeMap);
if (!rectContainsPoint(targetRect, center)) return candidate; if (!rectContainsPoint(targetRect, center)) return candidate;
const absolute = getNodeAbsolutePosition(candidate, nodeMap); const absolute = intendedAbsolute || getNodeAbsolutePosition(candidate, nodeMap);
const nextPosition = { const nextPosition = {
x: absolute.x - targetAbs.x, x: absolute.x - targetAbs.x,
y: absolute.y - targetAbs.y, y: absolute.y - targetAbs.y,
@@ -2147,12 +2260,47 @@ function Flow() {
level: 'info', level: 'info',
}); });
} }
} else {
const pointerFlowPos = dragIntent?.pointerFlowPos || getEventFlowPosition(event, reactFlow);
let removedCount = 0;
nextNodes = nextNodes.map((candidate) => {
if (!draggedIdSet.has(String(candidate.id)) || !candidate.parentId) return candidate;
const parentId = String(candidate.parentId);
const parentNode = nodeMap.get(parentId);
if (!parentNode || parentNode.data?.className !== 'Group') return candidate;
if (!pointerFlowPos) return candidate;
if (rectContainsPoint(getNodeRect(parentNode, nodeMap), pointerFlowPos)) {
return candidate;
}
const absolute = dragIntent?.absolutePositions.get(String(candidate.id))
|| getNodeAbsolutePosition(candidate, nodeMap);
touchedGroupIds.add(parentId);
removedCount += 1;
changed = true;
return {
...candidate,
parentId: undefined,
extent: undefined,
hidden: false,
position: absolute,
};
});
if (removedCount > 0) {
setStatus({
text: `Removed ${removedCount} node${removedCount === 1 ? '' : 's'} from group.`,
level: 'info',
});
}
} }
} }
if (!changed) return; if (!changed) return;
setNodes(nextNodes); setNodes(sortNodesForParentOrder(nextNodes));
setTimeout(() => { setTimeout(() => {
touchedGroupIds.forEach((groupId) => { touchedGroupIds.forEach((groupId) => {
if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()); if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());

View File

@@ -1,3 +1,5 @@
import { sortNodesForParentOrder } from './nodeHierarchy.js';
export const NODE_CLIPBOARD_KIND = 'argonode/node-selection'; export const NODE_CLIPBOARD_KIND = 'argonode/node-selection';
export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection'; export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection';
@@ -151,9 +153,12 @@ export function instantiateNodeClipboardPayload(
const idMap = new Map(); const idMap = new Map();
let currentId = Number(nextNodeId) || 1; let currentId = Number(nextNodeId) || 1;
const nodes = payload.nodes.map((node) => { payload.nodes.forEach((node) => {
const newId = String(currentId++); idMap.set(String(node.id), String(currentId++));
idMap.set(String(node.id), newId); });
const nodes = sortNodesForParentOrder(payload.nodes.map((node) => {
const newId = idMap.get(String(node.id));
const className = node.data?.className || ''; const className = node.data?.className || '';
const definition = className ? defs[className] || null : null; const definition = className ? defs[className] || null : null;
@@ -187,7 +192,7 @@ export function instantiateNodeClipboardPayload(
warning: null, warning: null,
}, },
}; };
}); }));
const edges = payload.edges const edges = payload.edges
.filter((edge) => ( .filter((edge) => (

View File

@@ -0,0 +1,28 @@
export function sortNodesForParentOrder(nodes) {
const list = Array.isArray(nodes) ? nodes.filter(Boolean) : [];
const entries = list.map((node) => ({ id: String(node.id), node }));
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const visiting = new Set();
const visited = new Set();
const ordered = [];
function visit(entry) {
if (!entry) return;
const { id, node } = entry;
if (visited.has(id) || visiting.has(id)) return;
visiting.add(id);
const parentId = node?.parentId ? String(node.parentId) : null;
if (parentId) {
visit(byId.get(parentId));
}
visiting.delete(id);
visited.add(id);
ordered.push(node);
}
entries.forEach((entry) => visit(entry));
return ordered;
}

View File

@@ -1,3 +1,5 @@
import { sortNodesForParentOrder } from './nodeHierarchy.js';
function mergeDefinition(nodeData, defs) { function mergeDefinition(nodeData, defs) {
const savedData = nodeData || {}; const savedData = nodeData || {};
const registryDefinition = savedData.className ? defs[savedData.className] : null; const registryDefinition = savedData.className ? defs[savedData.className] : null;
@@ -34,7 +36,7 @@ export function hydrateWorkflowState(data, defs = {}) {
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : []; const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
const loadedEdges = Array.isArray(data?.edges) ? data.edges : []; const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
const nodes = loadedNodes.map((node) => { const nodes = sortNodesForParentOrder(loadedNodes.map((node) => {
const definition = mergeDefinition(node.data, defs); const definition = mergeDefinition(node.data, defs);
return { return {
@@ -62,7 +64,7 @@ export function hydrateWorkflowState(data, defs = {}) {
warning: null, warning: null,
}, },
}; };
}); }));
const edges = loadedEdges.map((edge) => ({ ...edge })); const edges = loadedEdges.map((edge) => ({ ...edge }));

View File

@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sortNodesForParentOrder } from '../src/nodeHierarchy.js';
import { hydrateWorkflowState } from '../src/workflowHydration.js';
import { instantiateNodeClipboardPayload, NODE_CLIPBOARD_KIND } from '../src/nodeClipboard.js';
test('sortNodesForParentOrder places parents before descendants', () => {
const nodes = [
{ id: '2', parentId: '1', position: { x: 80, y: 60 }, data: { className: 'Preview' } },
{ id: '3', position: { x: 300, y: 20 }, data: { className: 'Image' } },
{ id: '1', className: 'group-shell', position: { x: 0, y: 0 }, data: { className: 'Group' } },
{ id: '4', parentId: '2', position: { x: 30, y: 24 }, data: { className: 'Save' } },
];
const ordered = sortNodesForParentOrder(nodes);
assert.deepEqual(ordered.map((node) => node.id), ['1', '2', '3', '4']);
});
test('hydrateWorkflowState reorders group parents ahead of children', () => {
const saved = {
nodes: [
{
id: '11',
type: 'custom',
position: { x: 48, y: 72 },
parentId: '10',
extent: 'parent',
data: {
label: 'preview',
className: 'Preview',
widgetValues: {},
},
},
{
id: '10',
type: 'custom',
className: 'group-shell',
position: { x: 12, y: 24 },
style: { width: 320, height: 220 },
data: {
label: 'group',
className: 'Group',
widgetValues: {},
},
},
],
edges: [],
};
const hydrated = hydrateWorkflowState(saved, {});
assert.deepEqual(hydrated.nodes.map((node) => node.id), ['10', '11']);
assert.equal(hydrated.nodes[1].parentId, '10');
});
test('instantiateNodeClipboardPayload remaps parent ids before sorting grouped nodes', () => {
const payload = {
kind: NODE_CLIPBOARD_KIND,
version: 1,
nodes: [
{
id: 'child',
type: 'custom',
position: { x: 48, y: 72 },
parentId: 'group',
extent: 'parent',
data: {
label: 'preview',
className: 'Preview',
widgetValues: {},
},
},
{
id: 'group',
type: 'custom',
className: 'group-shell',
position: { x: 12, y: 24 },
style: { width: 320, height: 220 },
data: {
label: 'group',
className: 'Group',
widgetValues: {},
},
},
],
edges: [],
};
const instantiated = instantiateNodeClipboardPayload(payload, {}, 20);
assert.deepEqual(instantiated.nodes.map((node) => node.id), ['21', '20']);
assert.equal(instantiated.nodes[1].parentId, '21');
assert.equal(instantiated.nextNodeId, 22);
});

View File

@@ -7,12 +7,15 @@
}, },
"scripts": { "scripts": {
"postinstall": "npm --prefix frontend install", "postinstall": "npm --prefix frontend install",
"dev": "npm --prefix frontend run dev", "clean:dev": "node scripts/clean-build-artifacts.mjs",
"build": "npm --prefix frontend run build", "clean:build": "node scripts/clean-build-artifacts.mjs",
"clean:native": "node scripts/clean-build-artifacts.mjs --mode=native",
"dev": "npm run clean:dev && npm --prefix frontend run dev",
"build": "npm run clean:build && npm --prefix frontend run build",
"preview": "npm --prefix frontend run preview", "preview": "npm --prefix frontend run preview",
"test:frontend": "npm --prefix frontend test", "test:frontend": "npm --prefix frontend test",
"backend": "python -m backend.main", "backend": "python -m backend.main",
"desktop": "python desktop.py", "desktop": "npm run build && python desktop.py",
"build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1", "build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1",
"build:mac": "bash scripts/build-mac.sh", "build:mac": "bash scripts/build-mac.sh",
"build:linux": "bash scripts/build-linux.sh" "build:linux": "bash scripts/build-linux.sh"

View File

@@ -38,6 +38,10 @@ $pythonExe = if (Test-Path ".\.venv\Scripts\python.exe") {
$frontendDist = Join-Path $repoRoot "frontend\dist" $frontendDist = Join-Path $repoRoot "frontend\dist"
$demoDir = Join-Path $repoRoot "demo" $demoDir = Join-Path $repoRoot "demo"
Write-Host "Removing cached frontend and desktop build artifacts..."
node scripts\clean-build-artifacts.mjs --mode=native
Assert-LastExitCode "Artifact cleanup"
Write-Host "Building frontend bundle..." Write-Host "Building frontend bundle..."
npm run build npm run build
Assert-LastExitCode "Frontend build" Assert-LastExitCode "Frontend build"

View File

@@ -0,0 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const args = new Set(process.argv.slice(2));
const mode = args.has('--mode=native') || args.has('--native') ? 'native' : 'frontend';
const removed = [];
function removePath(targetPath) {
if (!fs.existsSync(targetPath)) return;
fs.rmSync(targetPath, { recursive: true, force: true });
removed.push(path.relative(repoRoot, targetPath) || '.');
}
function removePythonCaches(rootPath) {
const stack = [rootPath];
const skipDirs = new Set(['.git', '.venv', 'node_modules']);
while (stack.length > 0) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__pycache__') {
removePath(fullPath);
continue;
}
if (skipDirs.has(entry.name)) continue;
stack.push(fullPath);
continue;
}
if (entry.isFile() && (entry.name.endsWith('.pyc') || entry.name.endsWith('.pyo'))) {
try {
fs.rmSync(fullPath, { force: true });
removed.push(path.relative(repoRoot, fullPath) || '.');
} catch {
// Ignore files held open by another process; the rest of the clean can still continue.
}
}
}
}
}
removePath(path.join(repoRoot, 'frontend', 'dist'));
removePath(path.join(repoRoot, 'frontend', 'node_modules', '.vite'));
removePythonCaches(repoRoot);
if (mode === 'native') {
removePath(path.join(repoRoot, 'desktop-build'));
removePath(path.join(repoRoot, 'desktop-dist'));
}
if (removed.length === 0) {
console.log(`[clean] No cached build artifacts found (${mode}).`);
} else {
console.log(`[clean] Removed ${removed.length} artifact${removed.length === 1 ? '' : 's'} (${mode}).`);
}