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:
|
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`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
|
||||||
if (targetGroup) {
|
|
||||||
const nodeMap = new Map(nextNodes.map((candidate) => [String(candidate.id), candidate]));
|
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 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());
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
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) {
|
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 }));
|
||||||
|
|
||||||
|
|||||||
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": {
|
"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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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