fix grouping functionality
This commit is contained in:
13
README.md
13
README.md
@@ -85,6 +85,7 @@ http://127.0.0.1:5173
|
||||
Notes:
|
||||
|
||||
- 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 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
|
||||
|
||||
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:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then launch the desktop app from source:
|
||||
Launch the desktop app from source:
|
||||
|
||||
```powershell
|
||||
npm run desktop
|
||||
@@ -110,8 +106,7 @@ npm run desktop
|
||||
|
||||
Notes:
|
||||
|
||||
- `npm run desktop` uses the built frontend from `frontend/dist`.
|
||||
- If you change frontend code, run `npm run build` again before starting the desktop version.
|
||||
- `npm run build` clears stale frontend output, Vite cache, and Python bytecode before producing `frontend/dist`.
|
||||
|
||||
## Building the Windows `.exe`
|
||||
|
||||
|
||||
@@ -180,6 +180,20 @@ def _render_annotation_text(text: str, size_px: int, color: tuple[int, int, int]
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -508,7 +522,7 @@ def list_channels(filepath: str) -> list[dict]:
|
||||
|
||||
if ext == ".ibw":
|
||||
try:
|
||||
from igor.binarywave import load as load_ibw
|
||||
load_ibw = _import_ibw_loader()
|
||||
wave = load_ibw(str(path))
|
||||
raw = wave["wave"]["wData"]
|
||||
labels = wave["wave"].get("labels", None)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
from backend.node_registry import register_node
|
||||
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")
|
||||
@@ -149,11 +149,7 @@ class Image:
|
||||
|
||||
@staticmethod
|
||||
def _load_ibw_all(path: Path) -> list[DataField]:
|
||||
try:
|
||||
from igor.binarywave import load as load_ibw
|
||||
except ImportError:
|
||||
raise ImportError("Install 'igor' package to load .ibw files: pip install igor")
|
||||
|
||||
load_ibw = _import_ibw_loader()
|
||||
wave = load_ibw(str(path))
|
||||
wdata = wave["wave"]
|
||||
header = wdata["wave_header"]
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"npm": ">=9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "vite --force",
|
||||
"build": "vite build --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test tests/**/*.test.mjs"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
||||
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
||||
import { hydrateWorkflowState } from './workflowHydration';
|
||||
import { serializeWorkflowState } from './workflowSerialization';
|
||||
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
||||
import {
|
||||
buildNodeClipboardPayload,
|
||||
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) {
|
||||
return point.x >= rect.left
|
||||
&& point.x <= rect.right
|
||||
@@ -189,13 +202,60 @@ function rectContainsPoint(rect, point) {
|
||||
&& 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 anchorNode = nodeMap.get(String(anchorNodeId));
|
||||
if (!anchorNode) return null;
|
||||
|
||||
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 || [])
|
||||
.filter((node) => (
|
||||
@@ -712,6 +772,7 @@ function Flow() {
|
||||
const lastPastedClipboardTextRef = useRef('');
|
||||
const pasteRepeatCountRef = useRef(0);
|
||||
const duplicateDragRef = useRef(null);
|
||||
const dragStateRef = useRef(null);
|
||||
const activeDragNodeIdRef = useRef(null);
|
||||
const reactFlow = useReactFlow();
|
||||
|
||||
@@ -999,8 +1060,9 @@ function Flow() {
|
||||
groupNode,
|
||||
];
|
||||
|
||||
setNodes(nextNodes);
|
||||
setTimeout(() => refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()), 0);
|
||||
const orderedNodes = sortNodesForParentOrder(nextNodes);
|
||||
setNodes(orderedNodes);
|
||||
setTimeout(() => refreshGroupNode(groupId, orderedNodes, reactFlow.getEdges()), 0);
|
||||
}, [reactFlow, refreshGroupNode, setNodes]);
|
||||
|
||||
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
|
||||
@@ -1639,10 +1701,10 @@ function Flow() {
|
||||
|
||||
nextIdRef.current = pasted.nextNodeId;
|
||||
|
||||
setNodes((existing) => [
|
||||
setNodes((existing) => sortNodesForParentOrder([
|
||||
...existing.map((node) => ({ ...node, selected: false })),
|
||||
...pasted.nodes,
|
||||
]);
|
||||
]));
|
||||
setEdges((existing) => [
|
||||
...existing.map((edge) => ({ ...edge, selected: false })),
|
||||
...pasted.edges,
|
||||
@@ -1682,7 +1744,7 @@ function Flow() {
|
||||
|
||||
const applyWorkflowData = useCallback((data) => {
|
||||
const hydrated = hydrateWorkflowState(data, nodeDefsRef.current);
|
||||
setNodes(hydrated.nodes);
|
||||
setNodes(sortNodesForParentOrder(hydrated.nodes));
|
||||
setEdges(hydrated.edges);
|
||||
nextIdRef.current = hydrated.nextNodeId;
|
||||
initializeDynamicNodes(hydrated.nodes);
|
||||
@@ -1912,11 +1974,40 @@ function Flow() {
|
||||
|
||||
const onNodeDragStart = useCallback((event, node) => {
|
||||
activeDragNodeIdRef.current = String(node.id);
|
||||
dragStateRef.current = null;
|
||||
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
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') {
|
||||
const descendantIds = collectGroupDescendantIds(reactFlow.getNodes(), node.id);
|
||||
const descendantIds = collectGroupDescendantIds(currentNodes, node.id);
|
||||
if (descendantIds.size > 0) {
|
||||
setNodes((existing) => existing.map((candidate) => (
|
||||
descendantIds.has(String(candidate.id))
|
||||
@@ -1974,10 +2065,10 @@ function Flow() {
|
||||
duplicateSourceById,
|
||||
};
|
||||
|
||||
setNodes((existing) => [
|
||||
setNodes((existing) => sortNodesForParentOrder([
|
||||
...existing.map((candidate) => ({ ...candidate, selected: false })),
|
||||
...duplicated.nodes,
|
||||
]);
|
||||
]));
|
||||
setEdges((existing) => [
|
||||
...existing.map((edge) => ({ ...edge, selected: false })),
|
||||
...duplicated.edges,
|
||||
@@ -2036,6 +2127,8 @@ function Flow() {
|
||||
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
||||
activeDragNodeIdRef.current = null;
|
||||
|
||||
const dragState = dragStateRef.current;
|
||||
dragStateRef.current = null;
|
||||
const duplicateState = duplicateDragRef.current;
|
||||
duplicateDragRef.current = null;
|
||||
if (duplicateState) {
|
||||
@@ -2089,6 +2182,7 @@ function Flow() {
|
||||
}
|
||||
|
||||
const currentNodes = reactFlow.getNodes();
|
||||
const dragIntent = getDragIntent(event, reactFlow, dragState);
|
||||
const touchedGroupIds = new Set();
|
||||
let nextNodes = currentNodes;
|
||||
let changed = false;
|
||||
@@ -2103,9 +2197,23 @@ function Flow() {
|
||||
|
||||
if (draggedNodes.length > 0) {
|
||||
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) {
|
||||
const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate]));
|
||||
const targetRect = getGroupWorkspaceBounds(targetGroup, nodeMap);
|
||||
const targetAbs = getNodeAbsolutePosition(targetGroup, nodeMap);
|
||||
let joinedCount = 0;
|
||||
@@ -2113,10 +2221,15 @@ function Flow() {
|
||||
nextNodes = nextNodes.map((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;
|
||||
|
||||
const absolute = getNodeAbsolutePosition(candidate, nodeMap);
|
||||
const absolute = intendedAbsolute || getNodeAbsolutePosition(candidate, nodeMap);
|
||||
const nextPosition = {
|
||||
x: absolute.x - targetAbs.x,
|
||||
y: absolute.y - targetAbs.y,
|
||||
@@ -2147,12 +2260,47 @@ function Flow() {
|
||||
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;
|
||||
|
||||
setNodes(nextNodes);
|
||||
setNodes(sortNodesForParentOrder(nextNodes));
|
||||
setTimeout(() => {
|
||||
touchedGroupIds.forEach((groupId) => {
|
||||
if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
||||
|
||||
export const NODE_CLIPBOARD_KIND = 'argonode/node-selection';
|
||||
export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection';
|
||||
|
||||
@@ -151,9 +153,12 @@ export function instantiateNodeClipboardPayload(
|
||||
const idMap = new Map();
|
||||
let currentId = Number(nextNodeId) || 1;
|
||||
|
||||
const nodes = payload.nodes.map((node) => {
|
||||
const newId = String(currentId++);
|
||||
idMap.set(String(node.id), newId);
|
||||
payload.nodes.forEach((node) => {
|
||||
idMap.set(String(node.id), String(currentId++));
|
||||
});
|
||||
|
||||
const nodes = sortNodesForParentOrder(payload.nodes.map((node) => {
|
||||
const newId = idMap.get(String(node.id));
|
||||
const className = node.data?.className || '';
|
||||
const definition = className ? defs[className] || null : null;
|
||||
|
||||
@@ -187,7 +192,7 @@ export function instantiateNodeClipboardPayload(
|
||||
warning: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
const edges = payload.edges
|
||||
.filter((edge) => (
|
||||
|
||||
28
frontend/src/nodeHierarchy.js
Normal file
28
frontend/src/nodeHierarchy.js
Normal 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;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
||||
|
||||
function mergeDefinition(nodeData, defs) {
|
||||
const savedData = nodeData || {};
|
||||
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 loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
|
||||
|
||||
const nodes = loadedNodes.map((node) => {
|
||||
const nodes = sortNodesForParentOrder(loadedNodes.map((node) => {
|
||||
const definition = mergeDefinition(node.data, defs);
|
||||
|
||||
return {
|
||||
@@ -62,7 +64,7 @@ export function hydrateWorkflowState(data, defs = {}) {
|
||||
warning: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
const edges = loadedEdges.map((edge) => ({ ...edge }));
|
||||
|
||||
|
||||
96
frontend/tests/nodeHierarchy.test.mjs
Normal file
96
frontend/tests/nodeHierarchy.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -7,12 +7,15 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "npm --prefix frontend install",
|
||||
"dev": "npm --prefix frontend run dev",
|
||||
"build": "npm --prefix frontend run build",
|
||||
"clean:dev": "node scripts/clean-build-artifacts.mjs",
|
||||
"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",
|
||||
"test:frontend": "npm --prefix frontend test",
|
||||
"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:mac": "bash scripts/build-mac.sh",
|
||||
"build:linux": "bash scripts/build-linux.sh"
|
||||
|
||||
@@ -38,6 +38,10 @@ $pythonExe = if (Test-Path ".\.venv\Scripts\python.exe") {
|
||||
$frontendDist = Join-Path $repoRoot "frontend\dist"
|
||||
$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..."
|
||||
npm run build
|
||||
Assert-LastExitCode "Frontend build"
|
||||
|
||||
70
scripts/clean-build-artifacts.mjs
Normal file
70
scripts/clean-build-artifacts.mjs
Normal 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}).`);
|
||||
}
|
||||
Reference in New Issue
Block a user