diff --git a/.tmp-tests/write-probe.txt b/.tmp-tests/write-probe.txt new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.tmp-tests/write-probe.txt @@ -0,0 +1 @@ +ok diff --git a/backend/nodes/save.py b/backend/nodes/save.py index 95ef18b..c0536b8 100644 --- a/backend/nodes/save.py +++ b/backend/nodes/save.py @@ -34,6 +34,7 @@ class Save: "choices_by_source_type": { "DATA_FIELD": ["TIFF", "PNG", "NPZ"], "IMAGE": ["PNG", "TIFF", "NPZ"], + "ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"], "LINE": ["CSV", "NPZ", "JSON"], "MEASURE_TABLE": ["CSV", "JSON"], "RECORD_TABLE": ["CSV", "JSON"], diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c2d358b..4f81f79 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -89,8 +89,19 @@ function getConnectionHandleType(handleId) { } function getNodeDimension(node, axis) { - if (axis === 'width') return node.measured?.width || node.width || node.style?.width || 200; - return node.measured?.height || node.height || node.style?.height || 120; + if (axis === 'width') return node.measured?.width || node.style?.width || node.width || 200; + return node.measured?.height || node.style?.height || node.height || 120; +} + +function applyNodeSize(node, width, height) { + const nextWidth = Math.round(Number(width) || 0); + const nextHeight = Math.round(Number(height) || 0); + return { + ...node, + width: nextWidth, + height: nextHeight, + style: { ...(node.style || {}), width: nextWidth, height: nextHeight }, + }; } function getNodeAbsolutePosition(node, nodeMap) { @@ -195,6 +206,17 @@ function getNodeRect(node, nodeMap) { }; } +function getAbsoluteRectForNodePosition(node, absolutePosition) { + const width = Number(getNodeDimension(node, 'width')) || 200; + const height = Number(getNodeDimension(node, 'height')) || 120; + return { + left: absolutePosition.x, + top: absolutePosition.y, + right: absolutePosition.x + width, + bottom: absolutePosition.y + height, + }; +} + function rectContainsPoint(rect, point) { return point.x >= rect.left && point.x <= rect.right @@ -202,6 +224,13 @@ function rectContainsPoint(rect, point) { && point.y <= rect.bottom; } +function rectContainsRect(outerRect, innerRect) { + return innerRect.left >= outerRect.left + && innerRect.top >= outerRect.top + && innerRect.right <= outerRect.right + && innerRect.bottom <= outerRect.bottom; +} + function getEventClientPosition(event) { if (!event) return null; const point = 'changedTouches' in event && event.changedTouches?.[0] @@ -829,10 +858,11 @@ function Flow() { }; const collapsedHeight = Math.max(74, 38 + Math.max(proxyData.proxyInputs.length, proxyData.proxyOutputs.length, 1) * 24 + 26); return { - ...node, - style: collapsed - ? { ...(node.style || {}), width: 260, height: collapsedHeight } - : { ...(node.style || {}), width: expandedSize.width, height: expandedSize.height }, + ...applyNodeSize( + node, + collapsed ? 260 : expandedSize.width, + collapsed ? collapsedHeight : expandedSize.height, + ), data: { ...node.data, collapsed, @@ -1014,6 +1044,8 @@ function Flow() { type: 'custom', className: 'group-shell', position: groupPosition, + width: groupWidth, + height: groupHeight, dragHandle: '.drag-handle', style: { width: groupWidth, height: groupHeight }, data: { @@ -1726,14 +1758,39 @@ function Flow() { setNodes, ]); + const resizeGroup = useCallback((groupId, size) => { + const nextWidth = Math.round(Number(size?.width) || 0); + const nextHeight = Math.round(Number(size?.height) || 0); + if (!nextWidth || !nextHeight) return; + + setNodes((existing) => existing.map((node) => { + if (String(node.id) !== String(groupId) || node.data?.className !== 'Group') return node; + + const sameSize = Math.abs((Number(node.style?.width) || 0) - nextWidth) < 0.5 + && Math.abs((Number(node.style?.height) || 0) - nextHeight) < 0.5; + if (sameSize) return node; + + return { + ...applyNodeSize(node, nextWidth, nextHeight), + data: { + ...node.data, + expandedSize: { width: nextWidth, height: nextHeight }, + }, + }; + })); + + setTimeout(() => reactFlow.updateNodeInternals(String(groupId)), 0); + }, [reactFlow, setNodes]); + const contextValue = useMemo(() => ({ onWidgetChange, onRuntimeValuesChange, openFileBrowser, onManualTrigger, onToggleGroupCollapse: toggleGroupCollapse, + onResizeGroup: resizeGroup, onUngroup: ungroupGroup, - }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, toggleGroupCollapse, ungroupGroup]); + }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, resizeGroup, toggleGroupCollapse, ungroupGroup]); const clearGraph = useCallback(() => { setNodes([]); @@ -2000,6 +2057,8 @@ function Flow() { anchorId: String(node.id), anchorStartAbsolute: anchorAbsolute, absolutePositions, + releasedNodeIds: new Set(), + touchedGroupIds: new Set(), pointerOffset: { x: pointerFlowPos.x - anchorAbsolute.x, y: pointerFlowPos.y - anchorAbsolute.y, @@ -2077,7 +2136,7 @@ function Flow() { initializeDynamicNodes(duplicated.nodes); }, [initializeDynamicNodes, reactFlow, setEdges, setNodes]); - const onNodeDrag = useCallback((_event, node) => { + const onNodeDrag = useCallback((event, node) => { if (String(node.id) !== activeDragNodeIdRef.current) return; const duplicateState = duplicateDragRef.current; @@ -2121,7 +2180,103 @@ function Flow() { })); return; } - }, [setNodes]); + + const dragState = dragStateRef.current; + if (!dragState || node.data?.className === 'Group') return; + + const currentNodes = reactFlow.getNodes(); + const draggedNodes = node.selected + ? currentNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group') + : currentNodes.filter((candidate) => candidate.id === node.id); + if (draggedNodes.length === 0) return; + + const dragIntent = getDragIntent(event, reactFlow, dragState); + if (!dragIntent?.pointerFlowPos) return; + + const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id))); + const nodeMap = new Map(currentNodes.map((candidate) => [String(candidate.id), candidate])); + const releasedNodeIds = dragState.releasedNodeIds instanceof Set + ? new Set(dragState.releasedNodeIds) + : new Set(); + const touchedGroupIds = dragState.touchedGroupIds instanceof Set + ? new Set(dragState.touchedGroupIds) + : new Set(); + + let nextNodes = currentNodes; + let changed = false; + let structureChanged = false; + + nextNodes = nextNodes.map((candidate) => { + const candidateId = String(candidate.id); + if (!draggedIdSet.has(candidateId)) return candidate; + + const absolute = dragIntent.absolutePositions.get(candidateId) + || getNodeAbsolutePosition(candidate, nodeMap); + if (!absolute) return candidate; + + if (candidate.parentId) { + const parentId = String(candidate.parentId); + const parentNode = nodeMap.get(parentId); + if (parentNode?.data?.className === 'Group') { + const parentRect = getGroupWorkspaceBounds(parentNode, nodeMap); + const parentAbsolute = getNodeAbsolutePosition(parentNode, nodeMap); + const nextPosition = { + x: absolute.x - parentAbsolute.x, + y: absolute.y - parentAbsolute.y, + }; + const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute); + const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5 + && Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5; + + if (!releasedNodeIds.has(candidateId) && !rectContainsRect(parentRect, candidateRect)) { + releasedNodeIds.add(candidateId); + changed = true; + return { + ...candidate, + extent: undefined, + hidden: false, + position: nextPosition, + }; + } + + if (releasedNodeIds.has(candidateId)) { + if (!candidate.parentId && !candidate.extent && candidate.hidden !== true && samePosition) { + return candidate; + } + + changed = true; + return { + ...candidate, + extent: undefined, + hidden: false, + position: nextPosition, + }; + } + } + } + + if (!releasedNodeIds.has(candidateId)) return candidate; + return candidate; + }); + + if (!changed) return; + + dragStateRef.current = { + ...dragState, + releasedNodeIds, + touchedGroupIds, + }; + + setNodes(structureChanged ? sortNodesForParentOrder(nextNodes) : nextNodes); + + if (structureChanged) { + setTimeout(() => { + touchedGroupIds.forEach((groupId) => { + if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges()); + }); + }, 0); + } + }, [reactFlow, refreshGroupNode, setNodes]); const onNodeDragStop = useCallback((event, node) => { if (String(node.id) !== activeDragNodeIdRef.current) return; @@ -2183,7 +2338,9 @@ function Flow() { const currentNodes = reactFlow.getNodes(); const dragIntent = getDragIntent(event, reactFlow, dragState); - const touchedGroupIds = new Set(); + const touchedGroupIds = dragState?.touchedGroupIds instanceof Set + ? new Set(dragState.touchedGroupIds) + : new Set(); let nextNodes = currentNodes; let changed = false; @@ -2237,7 +2394,7 @@ function Flow() { const alreadyInTarget = String(candidate.parentId || '') === String(targetGroup.id); const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5 && Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5; - if (alreadyInTarget && samePosition) return candidate; + if (alreadyInTarget && candidate.extent === 'parent' && samePosition) return candidate; if (candidate.parentId) { touchedGroupIds.add(String(candidate.parentId)); @@ -2261,7 +2418,6 @@ function Flow() { }); } } else { - const pointerFlowPos = dragIntent?.pointerFlowPos || getEventFlowPosition(event, reactFlow); let removedCount = 0; nextNodes = nextNodes.map((candidate) => { @@ -2270,13 +2426,20 @@ function Flow() { 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); + const parentWorkspaceRect = getGroupWorkspaceBounds(parentNode, nodeMap); + const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute); + if (rectContainsRect(parentWorkspaceRect, candidateRect)) { + if (candidate.extent === 'parent') return candidate; + changed = true; + return { + ...candidate, + extent: 'parent', + hidden: false, + }; + } + touchedGroupIds.add(parentId); removedCount += 1; changed = true; @@ -2298,7 +2461,16 @@ function Flow() { } } - if (!changed) return; + if (!changed) { + const releasedCount = dragState?.releasedNodeIds instanceof Set ? dragState.releasedNodeIds.size : 0; + if (releasedCount > 0) { + setStatus({ + text: `Removed ${releasedCount} node${releasedCount === 1 ? '' : 's'} from group.`, + level: 'info', + }); + } + return; + } setNodes(sortNodesForParentOrder(nextNodes)); setTimeout(() => { diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 7ccccc6..7983ad6 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -1,5 +1,5 @@ import React, { useContext, useRef, useCallback, useState, useEffect, memo, lazy, Suspense } from 'react'; -import { Handle, Position, useStore } from '@xyflow/react'; +import { Handle, NodeResizeControl, Position, useStore } from '@xyflow/react'; import LinePlotOverlay from './LinePlotOverlay'; const SurfaceView = lazy(() => import('./SurfaceView')); @@ -11,6 +11,7 @@ const MarkupOverlay = lazy(() => import('./MarkupOverlay')); import { DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS, } from './constants'; +import { getGroupMinimumSize } from './groupSizing.js'; // ── Context (provided by App) ───────────────────────────────────────── @@ -44,10 +45,37 @@ function GroupNode({ id, data }) { const childCount = Number(data.childCount) || 0; const collapsed = !!data.collapsed; const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0); + const selected = useStore( + useCallback( + (s) => { + const node = s.nodeLookup?.get(id) || s.nodes?.find((candidate) => candidate.id === id); + return !!node?.selected; + }, + [id], + ), + ); + const groupMinSize = useStore( + useCallback( + (s) => getGroupMinimumSize( + (s.nodes || []).filter((candidate) => String(candidate.parentId || '') === String(id)), + ), + [id], + ), + ); return ( -
-
+ <> + {!collapsed && selected && ( + ctx.onResizeGroup?.(id, params)} + /> + )} +
+
-
+
+ ); } diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 691da4d..baef17a 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -49,7 +49,7 @@ export const SOCKET_COMPATIBILITY = { VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']), ANNOTATION_SOURCE: new Set(['DATA_FIELD', 'IMAGE']), SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']), - SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']), + SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'ANNOTATION_SOURCE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']), FLOAT: new Set(['INT']), INT: new Set(['FLOAT']), LINE: new Set(['COORDPAIR']), diff --git a/frontend/src/groupDrag.js b/frontend/src/groupDrag.js new file mode 100644 index 0000000..9851a41 --- /dev/null +++ b/frontend/src/groupDrag.js @@ -0,0 +1,18 @@ +export const GROUP_DRAG_RELEASE_DISTANCE = 18; + +export function getPointDistanceOutsideRect(rect, point) { + if (!rect || !point) return Infinity; + + const dx = point.x < rect.left + ? rect.left - point.x + : (point.x > rect.right ? point.x - rect.right : 0); + const dy = point.y < rect.top + ? rect.top - point.y + : (point.y > rect.bottom ? point.y - rect.bottom : 0); + + return Math.hypot(dx, dy); +} + +export function shouldReleaseFromGroup(rect, point, threshold = GROUP_DRAG_RELEASE_DISTANCE) { + return getPointDistanceOutsideRect(rect, point) >= threshold; +} diff --git a/frontend/src/groupSizing.js b/frontend/src/groupSizing.js new file mode 100644 index 0000000..0b415b9 --- /dev/null +++ b/frontend/src/groupSizing.js @@ -0,0 +1,35 @@ +const DEFAULT_CHILD_WIDTH = 200; +const DEFAULT_CHILD_HEIGHT = 120; + +function getNodeSize(node, axis) { + const fallback = axis === 'width' ? DEFAULT_CHILD_WIDTH : DEFAULT_CHILD_HEIGHT; + const measured = Number(node?.measured?.[axis]); + if (Number.isFinite(measured) && measured > 0) return measured; + const direct = Number(node?.[axis]); + if (Number.isFinite(direct) && direct > 0) return direct; + const styled = Number(node?.style?.[axis]); + if (Number.isFinite(styled) && styled > 0) return styled; + return fallback; +} + +export function getGroupMinimumSize(memberNodes, { + minWidth = 260, + minHeight = 180, + paddingX = 24, + paddingY = 24, +} = {}) { + let maxRight = 0; + let maxBottom = 0; + + for (const node of memberNodes || []) { + const x = Number(node?.position?.x) || 0; + const y = Number(node?.position?.y) || 0; + maxRight = Math.max(maxRight, x + getNodeSize(node, 'width')); + maxBottom = Math.max(maxBottom, y + getNodeSize(node, 'height')); + } + + return { + width: Math.max(minWidth, Math.ceil(maxRight + paddingX)), + height: Math.max(minHeight, Math.ceil(maxBottom + paddingY)), + }; +} diff --git a/frontend/tests/constants.test.mjs b/frontend/tests/constants.test.mjs new file mode 100644 index 0000000..987b9c6 --- /dev/null +++ b/frontend/tests/constants.test.mjs @@ -0,0 +1,8 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { SOCKET_COMPATIBILITY } from '../src/constants.js'; + +test('SAVE_VALUE accepts ANNOTATION_SOURCE inputs', () => { + assert.equal(SOCKET_COMPATIBILITY.SAVE_VALUE.has('ANNOTATION_SOURCE'), true); +}); diff --git a/frontend/tests/groupDrag.test.mjs b/frontend/tests/groupDrag.test.mjs new file mode 100644 index 0000000..18ad72b --- /dev/null +++ b/frontend/tests/groupDrag.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + GROUP_DRAG_RELEASE_DISTANCE, + getPointDistanceOutsideRect, + shouldReleaseFromGroup, +} from '../src/groupDrag.js'; + +test('getPointDistanceOutsideRect returns zero inside the rect', () => { + const rect = { left: 10, top: 20, right: 110, bottom: 120 }; + assert.equal(getPointDistanceOutsideRect(rect, { x: 60, y: 70 }), 0); +}); + +test('shouldReleaseFromGroup waits for a small overshoot before releasing', () => { + const rect = { left: 10, top: 20, right: 110, bottom: 120 }; + + assert.equal( + shouldReleaseFromGroup(rect, { x: 110 + GROUP_DRAG_RELEASE_DISTANCE - 1, y: 70 }), + false, + ); + assert.equal( + shouldReleaseFromGroup(rect, { x: 110 + GROUP_DRAG_RELEASE_DISTANCE, y: 70 }), + true, + ); +}); diff --git a/frontend/tests/groupSizing.test.mjs b/frontend/tests/groupSizing.test.mjs new file mode 100644 index 0000000..44082d0 --- /dev/null +++ b/frontend/tests/groupSizing.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getGroupMinimumSize } from '../src/groupSizing.js'; + +test('getGroupMinimumSize keeps the base minimum for empty groups', () => { + assert.deepEqual(getGroupMinimumSize([]), { width: 260, height: 180 }); +}); + +test('getGroupMinimumSize grows to fit child bounds plus padding', () => { + const nodes = [ + { + position: { x: 24, y: 60 }, + style: { width: 180, height: 100 }, + }, + { + position: { x: 260, y: 150 }, + style: { width: 220, height: 140 }, + }, + ]; + + assert.deepEqual(getGroupMinimumSize(nodes), { + width: 504, + height: 314, + }); +}); diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 7f4926c..bf63be5 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -2205,11 +2205,13 @@ def test_view3d(): def test_save_generic(): print("=== Test: Save ===") from backend.nodes.save import Save - from backend.data_types import DataField, LineData, MeasureTable, MeshModel, RecordTable + from backend.data_types import DataField, ImageData, LineData, MeasureTable, MeshModel, RecordTable import tifffile from PIL import Image as PILImage node = Save() + format_choices = node.INPUT_TYPES()["required"]["format"][1]["choices_by_source_type"] + assert format_choices["ANNOTATION_SOURCE"] == format_choices["IMAGE"] with tempfile.TemporaryDirectory() as tmpdir: # Save scalar as TXT and JSON @@ -2282,6 +2284,26 @@ def test_save_generic(): image_npz = np.load(Path(tmpdir, "image_npz.npz")) assert np.array_equal(image_npz["image"], image) + # Save ANNOTATION_SOURCE as PNG, TIFF, and NPZ + annotation_image = ImageData( + image, + metadata={"annotation_context": {"si_unit_xy": "um", "si_unit_z": "nm"}}, + ) + node.save(filename="annotation_png", directory_path=tmpdir, format="PNG", value=annotation_image) + annotation_png = np.asarray(PILImage.open(Path(tmpdir, "annotation_png.png"))) + assert annotation_png.shape == image.shape + assert np.array_equal(annotation_png, image) + + node.save(filename="annotation_tiff", directory_path=tmpdir, format="TIFF", value=annotation_image) + annotation_tiff = tifffile.imread(Path(tmpdir, "annotation_tiff.tiff")) + assert annotation_tiff.shape == image.shape + assert annotation_tiff.dtype == np.uint8 + assert np.array_equal(annotation_tiff, image) + + node.save(filename="annotation_npz", directory_path=tmpdir, format="NPZ", value=annotation_image) + annotation_npz = np.load(Path(tmpdir, "annotation_npz.npz")) + assert np.array_equal(annotation_npz["image"], image) + # Save tables as CSV and JSON measure_table = MeasureTable([ {"quantity": "Rq", "value": 1.23, "unit": "nm"},