feature focus on 3d viewer, add copy/paste
This commit is contained in:
@@ -16,6 +16,12 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
||||
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
||||
import { hydrateWorkflowState } from './workflowHydration';
|
||||
import { serializeWorkflowState } from './workflowSerialization';
|
||||
import {
|
||||
buildNodeClipboardPayload,
|
||||
instantiateNodeClipboardPayload,
|
||||
NODE_CLIPBOARD_MIME,
|
||||
parseNodeClipboardPayload,
|
||||
} from './nodeClipboard';
|
||||
import { loadDefaultWorkflowAsset } from './defaultWorkflow';
|
||||
import {
|
||||
serializeExecutionGraph,
|
||||
@@ -49,6 +55,12 @@ function sameStringArray(a = [], b = []) {
|
||||
return a.every((item, index) => item === b[index]);
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target || !(target instanceof Element)) return false;
|
||||
if (target.closest('input, textarea, select')) return true;
|
||||
return target.closest('[contenteditable="true"]') !== null;
|
||||
}
|
||||
|
||||
function compareMenuNodes(a, b) {
|
||||
const orderA = Number.isFinite(a?.def?.menu_order) ? a.def.menu_order : Number.MAX_SAFE_INTEGER;
|
||||
const orderB = Number.isFinite(b?.def?.menu_order) ? b.def.menu_order : Number.MAX_SAFE_INTEGER;
|
||||
@@ -254,7 +266,11 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
|
||||
});
|
||||
if (!hasMatch) continue;
|
||||
} else {
|
||||
if (!def.output.some((type) => socketTypesCompatible(type, filterType))) continue;
|
||||
const hasMatch = def.output.some((type) =>
|
||||
socketTypesCompatible(type, filterType)
|
||||
|| (type === 'ANNOTATION_SOURCE' && (filterType === 'DATA_FIELD' || filterType === 'IMAGE'))
|
||||
);
|
||||
if (!hasMatch) continue;
|
||||
}
|
||||
}
|
||||
const cat = def.category || 'uncategorized';
|
||||
@@ -454,6 +470,8 @@ function Flow() {
|
||||
const autoRunTimer = useRef(null);
|
||||
const autoRunRef = useRef(null);
|
||||
const defaultWorkflowLoadAttemptedRef = useRef(false);
|
||||
const lastPastedClipboardTextRef = useRef('');
|
||||
const pasteRepeatCountRef = useRef(0);
|
||||
const reactFlow = useReactFlow();
|
||||
|
||||
// ── WebSocket ───────────────────────────────────────────────────────
|
||||
@@ -554,6 +572,24 @@ function Flow() {
|
||||
}
|
||||
}, [reactFlow, refreshLoadNodeOutputs, setNodeOutputs]);
|
||||
|
||||
const refreshAnnotationNodeOutputs = useCallback((nodeId) => {
|
||||
const node = reactFlow.getNode(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const inputEdge = reactFlow.getEdges().find(
|
||||
(edge) => edge.target === nodeId && getInputName(edge.targetHandle) === 'input'
|
||||
);
|
||||
const outputType = inputEdge ? getHandleType(inputEdge.sourceHandle) : 'ANNOTATION_SOURCE';
|
||||
setNodeOutputs(nodeId, [outputType], ['Output']);
|
||||
|
||||
if (!inputEdge || outputType === 'ANNOTATION_SOURCE') return;
|
||||
|
||||
setEdges((prev) => prev.filter((edge) => {
|
||||
if (edge.source !== nodeId) return true;
|
||||
return socketTypesCompatible(outputType, getHandleType(edge.targetHandle));
|
||||
}));
|
||||
}, [reactFlow, setEdges, setNodeOutputs]);
|
||||
|
||||
useEffect(() => {
|
||||
api.setMessageHandler((msg) => {
|
||||
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
|
||||
@@ -639,14 +675,21 @@ function Flow() {
|
||||
refreshLoadNodeOutputs(params.target);
|
||||
}, 0);
|
||||
}
|
||||
const targetNode = reactFlow.getNode(params.target);
|
||||
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
|
||||
setTimeout(() => {
|
||||
refreshAnnotationNodeOutputs(params.target);
|
||||
}, 0);
|
||||
}
|
||||
scheduleAutoRun();
|
||||
}, [refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
||||
}, [reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
||||
|
||||
const handleEdgesChange = useCallback((changes) => {
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
onEdgesChange(changes);
|
||||
|
||||
const affectedPathTargets = new Set();
|
||||
const affectedAnnotationTargets = new Set();
|
||||
for (const change of changes) {
|
||||
if (change.type !== 'remove') continue;
|
||||
const removedEdge = currentEdges.find((edge) => edge.id === change.id);
|
||||
@@ -654,6 +697,12 @@ function Flow() {
|
||||
if (getInputName(removedEdge.targetHandle) === 'path') {
|
||||
affectedPathTargets.add(removedEdge.target);
|
||||
}
|
||||
if (getInputName(removedEdge.targetHandle) === 'input') {
|
||||
const targetNode = reactFlow.getNode(removedEdge.target);
|
||||
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
|
||||
affectedAnnotationTargets.add(removedEdge.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedPathTargets.size > 0) {
|
||||
@@ -663,7 +712,14 @@ function Flow() {
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}, [onEdgesChange, reactFlow, refreshLoadNodeOutputs]);
|
||||
if (affectedAnnotationTargets.size > 0) {
|
||||
setTimeout(() => {
|
||||
affectedAnnotationTargets.forEach((nodeId) => {
|
||||
refreshAnnotationNodeOutputs(nodeId);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs]);
|
||||
|
||||
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
||||
|
||||
@@ -749,12 +805,6 @@ function Flow() {
|
||||
});
|
||||
}, [reactFlow]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
onWidgetChange,
|
||||
openFileBrowser,
|
||||
onManualTrigger,
|
||||
}), [onWidgetChange, openFileBrowser, onManualTrigger]);
|
||||
|
||||
// ── Add node from context menu ──────────────────────────────────────
|
||||
|
||||
const addNode = useCallback((className, def) => {
|
||||
@@ -789,6 +839,7 @@ function Flow() {
|
||||
className,
|
||||
definition: def,
|
||||
widgetValues,
|
||||
runtimeValues: {},
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
meshData: null,
|
||||
@@ -842,9 +893,12 @@ function Flow() {
|
||||
}
|
||||
} else {
|
||||
// Dragged from an input → connect from the first matching output on the new node
|
||||
const outputIdx = def.output.findIndex((type) => socketTypesCompatible(type, filterType));
|
||||
const outputIdx = def.output.findIndex((type) =>
|
||||
socketTypesCompatible(type, filterType)
|
||||
|| (type === 'ANNOTATION_SOURCE' && (filterType === 'DATA_FIELD' || filterType === 'IMAGE'))
|
||||
);
|
||||
if (outputIdx !== -1) {
|
||||
const outputType = def.output[outputIdx];
|
||||
const outputType = def.output[outputIdx] === 'ANNOTATION_SOURCE' ? filterType : def.output[outputIdx];
|
||||
const sourceHandle = `output::${outputIdx}::${outputType}`;
|
||||
const color = TYPE_COLORS[outputType] || 'var(--fallback-type)';
|
||||
setEdges((eds) => addEdge({
|
||||
@@ -907,6 +961,101 @@ function Flow() {
|
||||
autoRunTimer.current = setTimeout(() => autoRunRef.current?.(), 300);
|
||||
}, []);
|
||||
|
||||
const onRuntimeValuesChange = useCallback((nodeId, patch, { scheduleRun = false } = {}) => {
|
||||
if (!patch || typeof patch !== 'object') return;
|
||||
|
||||
setNodes((ns) => ns.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
runtimeValues: { ...(n.data.runtimeValues || {}), ...patch },
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
if (scheduleRun) {
|
||||
scheduleAutoRun();
|
||||
}
|
||||
}, [setNodes, scheduleAutoRun]);
|
||||
|
||||
const pasteClipboardSelection = useCallback((clipboardText) => {
|
||||
const payload = parseNodeClipboardPayload(clipboardText);
|
||||
if (!payload) return false;
|
||||
|
||||
if (clipboardText === lastPastedClipboardTextRef.current) {
|
||||
pasteRepeatCountRef.current += 1;
|
||||
} else {
|
||||
lastPastedClipboardTextRef.current = clipboardText;
|
||||
pasteRepeatCountRef.current = 1;
|
||||
}
|
||||
|
||||
const offsetAmount = 36 * pasteRepeatCountRef.current;
|
||||
const pasted = instantiateNodeClipboardPayload(
|
||||
payload,
|
||||
nodeDefsRef.current,
|
||||
nextIdRef.current,
|
||||
{ x: offsetAmount, y: offsetAmount },
|
||||
);
|
||||
|
||||
if (pasted.nodes.length === 0) return false;
|
||||
|
||||
nextIdRef.current = pasted.nextNodeId;
|
||||
|
||||
setNodes((existing) => [
|
||||
...existing.map((node) => ({ ...node, selected: false })),
|
||||
...pasted.nodes,
|
||||
]);
|
||||
setEdges((existing) => [
|
||||
...existing.map((edge) => ({ ...edge, selected: false })),
|
||||
...pasted.edges,
|
||||
]);
|
||||
|
||||
setTimeout(() => {
|
||||
pasted.nodes.forEach((node) => {
|
||||
if (node.data.className === 'Folder' && node.data.widgetValues?.folder) {
|
||||
refreshFolderNodeOutputs(node.id, node.data.widgetValues.folder);
|
||||
}
|
||||
});
|
||||
pasted.nodes.forEach((node) => {
|
||||
if (node.data.className === 'Image' || node.data.className === 'ImageDemo') {
|
||||
refreshLoadNodeOutputs(node.id);
|
||||
}
|
||||
});
|
||||
pasted.nodes.forEach((node) => {
|
||||
if (node.data.className === 'Annotations' || node.data.className === 'Markup') {
|
||||
refreshAnnotationNodeOutputs(node.id);
|
||||
}
|
||||
});
|
||||
pasted.nodes.forEach((node) => {
|
||||
reactFlow.updateNodeInternals(node.id);
|
||||
});
|
||||
}, 0);
|
||||
|
||||
setStatus({
|
||||
text: `Pasted ${pasted.nodes.length} node${pasted.nodes.length === 1 ? '' : 's'}.`,
|
||||
level: 'info',
|
||||
});
|
||||
scheduleAutoRun();
|
||||
return true;
|
||||
}, [
|
||||
reactFlow,
|
||||
refreshAnnotationNodeOutputs,
|
||||
refreshFolderNodeOutputs,
|
||||
refreshLoadNodeOutputs,
|
||||
scheduleAutoRun,
|
||||
setEdges,
|
||||
setNodes,
|
||||
]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
onWidgetChange,
|
||||
onRuntimeValuesChange,
|
||||
openFileBrowser,
|
||||
onManualTrigger,
|
||||
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger]);
|
||||
|
||||
const clearGraph = useCallback(() => {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
@@ -930,8 +1079,13 @@ function Flow() {
|
||||
refreshLoadNodeOutputs(node.id);
|
||||
}
|
||||
});
|
||||
hydrated.nodes.forEach((node) => {
|
||||
if (node.data.className === 'Annotations' || node.data.className === 'Markup') {
|
||||
refreshAnnotationNodeOutputs(node.id);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}, [refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]);
|
||||
}, [refreshAnnotationNodeOutputs, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]);
|
||||
|
||||
const loadDefaultWorkflow = useCallback(async () => {
|
||||
if (defaultWorkflowLoadAttemptedRef.current) return;
|
||||
@@ -1168,6 +1322,45 @@ function Flow() {
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [runWorkflow]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCopy = (event) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
|
||||
const payload = buildNodeClipboardPayload(reactFlow.getNodes(), reactFlow.getEdges());
|
||||
if (!payload) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
event.preventDefault();
|
||||
event.clipboardData?.setData(NODE_CLIPBOARD_MIME, serialized);
|
||||
event.clipboardData?.setData('text/plain', serialized);
|
||||
setStatus({
|
||||
text: `Copied ${payload.nodes.length} node${payload.nodes.length === 1 ? '' : 's'}.`,
|
||||
level: 'info',
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaste = (event) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
|
||||
const clipboardText = event.clipboardData?.getData(NODE_CLIPBOARD_MIME)
|
||||
|| event.clipboardData?.getData('text/plain')
|
||||
|| '';
|
||||
if (!clipboardText) return;
|
||||
|
||||
const pasted = pasteClipboardSelection(clipboardText);
|
||||
if (pasted) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('copy', handleCopy);
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => {
|
||||
window.removeEventListener('copy', handleCopy);
|
||||
window.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [pasteClipboardSelection, reactFlow]);
|
||||
|
||||
// ── Context menu ────────────────────────────────────────────────────
|
||||
|
||||
const onPaneContextMenu = useCallback((event) => {
|
||||
|
||||
@@ -1075,7 +1075,13 @@ function CustomNode({ id, data }) {
|
||||
{data.meshData && (
|
||||
<CollapsibleSection title="3D View" defaultOpen={true}>
|
||||
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}>
|
||||
<SurfaceView meshData={data.meshData} />
|
||||
<SurfaceView
|
||||
meshData={data.meshData}
|
||||
nodeId={id}
|
||||
widgetValues={data.widgetValues}
|
||||
runtimeValues={data.runtimeValues}
|
||||
onRuntimeValuesChange={ctx.onRuntimeValuesChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
@@ -8,9 +8,13 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
* meshData: { width, height, z_data (b64 float32), colors (b64 uint8 RGB),
|
||||
* z_min, z_max, z_scale, x_range, y_range }
|
||||
*/
|
||||
export default function SurfaceView({ meshData }) {
|
||||
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }) {
|
||||
const containerRef = useRef(null);
|
||||
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
||||
const syncTimerRef = useRef(null);
|
||||
const lastSnapshotRef = useRef('');
|
||||
const lastAnglesRef = useRef({ azimuth: null, polar: null, distance: null });
|
||||
const hasSyncedInitialSnapshotRef = useRef(false);
|
||||
|
||||
// Decode base64 to typed arrays
|
||||
const decode = useCallback((b64, ArrayType) => {
|
||||
@@ -20,6 +24,52 @@ export default function SurfaceView({ meshData }) {
|
||||
return new ArrayType(bytes.buffer);
|
||||
}, []);
|
||||
|
||||
const syncViewportState = useCallback((scheduleRun = false) => {
|
||||
const state = threeRef.current;
|
||||
if (!state || !nodeId || !onRuntimeValuesChange) return;
|
||||
const { renderer, controls } = state;
|
||||
const azimuth = Number(controls.getAzimuthalAngle().toFixed(4));
|
||||
const polar = Number(controls.getPolarAngle().toFixed(4));
|
||||
const distance = Number(controls.getDistance().toFixed(4));
|
||||
const snapshot = renderer.domElement.toDataURL('image/png');
|
||||
const previous = lastAnglesRef.current;
|
||||
const patch = {};
|
||||
if (previous.azimuth !== azimuth) patch.camera_azimuth = azimuth;
|
||||
if (previous.polar !== polar) patch.camera_polar = polar;
|
||||
if (previous.distance !== distance) patch.camera_distance = distance;
|
||||
if (snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
|
||||
lastAnglesRef.current = { azimuth, polar, distance };
|
||||
lastSnapshotRef.current = snapshot;
|
||||
}
|
||||
}, [nodeId, onRuntimeValuesChange]);
|
||||
|
||||
const scheduleViewportSync = useCallback((delay = 120, scheduleRun = false) => {
|
||||
if (syncTimerRef.current) {
|
||||
clearTimeout(syncTimerRef.current);
|
||||
}
|
||||
syncTimerRef.current = setTimeout(() => {
|
||||
syncTimerRef.current = null;
|
||||
syncViewportState(scheduleRun);
|
||||
}, delay);
|
||||
}, [syncViewportState]);
|
||||
|
||||
const applyCameraState = useCallback((azimuth, polar, distance) => {
|
||||
const state = threeRef.current;
|
||||
if (!state) return;
|
||||
const { camera, controls } = state;
|
||||
const target = controls.target.clone();
|
||||
const spherical = new THREE.Spherical(
|
||||
Math.max(0.3, Number.isFinite(distance) ? distance : 1.8),
|
||||
THREE.MathUtils.clamp(Number.isFinite(polar) ? polar : 1.1, 0.01, Math.PI - 0.01),
|
||||
Number.isFinite(azimuth) ? azimuth : 0.0,
|
||||
);
|
||||
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
||||
camera.position.copy(target).add(offset);
|
||||
controls.update();
|
||||
}, []);
|
||||
|
||||
// Initialize Three.js scene once
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -48,6 +98,8 @@ export default function SurfaceView({ meshData }) {
|
||||
controls.dampingFactor = 0.1;
|
||||
controls.minDistance = 0.3;
|
||||
controls.maxDistance = 10;
|
||||
const handleControlsEnd = () => scheduleViewportSync(0, true);
|
||||
controls.addEventListener('end', handleControlsEnd);
|
||||
|
||||
// Lighting
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
@@ -69,6 +121,11 @@ export default function SurfaceView({ meshData }) {
|
||||
animate();
|
||||
|
||||
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
||||
applyCameraState(
|
||||
Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
||||
Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
||||
Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
||||
);
|
||||
|
||||
// Resize observer to maintain 1:1 aspect when node width changes
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
@@ -86,6 +143,8 @@ export default function SurfaceView({ meshData }) {
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(animId);
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
controls.removeEventListener('end', handleControlsEnd);
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
@@ -93,18 +152,24 @@ export default function SurfaceView({ meshData }) {
|
||||
}
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [applyCameraState, scheduleViewportSync]);
|
||||
|
||||
// Update mesh when data changes
|
||||
useEffect(() => {
|
||||
if (!threeRef.current || !meshData) return;
|
||||
|
||||
const { scene, camera, controls } = threeRef.current;
|
||||
const { width: nx, height: ny, z_data, colors, z_min, z_max, z_scale, x_range, y_range } = meshData;
|
||||
const {
|
||||
width: nx, height: ny, z_data, colors, z_min, z_max, z_scale,
|
||||
positions, indices, vertex_colors, camera_azimuth, camera_polar, camera_distance,
|
||||
} = meshData;
|
||||
|
||||
// Decode arrays
|
||||
const zArr = decode(z_data, Float32Array);
|
||||
const colArr = decode(colors, Uint8Array);
|
||||
const zArr = z_data ? decode(z_data, Float32Array) : null;
|
||||
const colArr = colors ? decode(colors, Uint8Array) : null;
|
||||
const posArr = positions ? decode(positions, Float32Array) : null;
|
||||
const indexArr = indices ? decode(indices, Uint32Array) : null;
|
||||
const vertexColorArr = vertex_colors ? decode(vertex_colors, Uint8Array) : null;
|
||||
|
||||
// Remove old mesh
|
||||
if (threeRef.current.mesh) {
|
||||
@@ -115,45 +180,51 @@ export default function SurfaceView({ meshData }) {
|
||||
|
||||
// Build geometry
|
||||
const geom = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(nx * ny * 3);
|
||||
const colorAttr = new Float32Array(nx * ny * 3);
|
||||
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
|
||||
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3)));
|
||||
|
||||
// Normalize coordinates to roughly [-0.5, 0.5] for good camera framing
|
||||
const zRange = z_max - z_min || 1;
|
||||
if (!posArr) {
|
||||
const zRange = z_max - z_min || 1;
|
||||
for (let iy = 0; iy < ny; iy++) {
|
||||
for (let ix = 0; ix < nx; ix++) {
|
||||
const idx = iy * nx + ix;
|
||||
const px = ix / (nx - 1) - 0.5;
|
||||
const py = iy / (ny - 1) - 0.5;
|
||||
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
||||
|
||||
for (let iy = 0; iy < ny; iy++) {
|
||||
for (let ix = 0; ix < nx; ix++) {
|
||||
const idx = iy * nx + ix;
|
||||
const px = ix / (nx - 1) - 0.5; // [-0.5, 0.5]
|
||||
const py = iy / (ny - 1) - 0.5;
|
||||
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
||||
|
||||
positions[idx * 3] = px;
|
||||
positions[idx * 3 + 1] = pz; // height on Y axis
|
||||
positions[idx * 3 + 2] = py;
|
||||
|
||||
colorAttr[idx * 3] = colArr[idx * 3] / 255;
|
||||
colorAttr[idx * 3 + 1] = colArr[idx * 3 + 1] / 255;
|
||||
colorAttr[idx * 3 + 2] = colArr[idx * 3 + 2] / 255;
|
||||
positionsArray[idx * 3] = px;
|
||||
positionsArray[idx * 3 + 1] = pz;
|
||||
positionsArray[idx * 3 + 2] = py;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const sourceColors = vertexColorArr ?? colArr;
|
||||
if (sourceColors) {
|
||||
for (let i = 0; i < sourceColors.length; i += 1) {
|
||||
colorAttr[i] = sourceColors[i] / 255;
|
||||
}
|
||||
}
|
||||
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positionsArray, 3));
|
||||
geom.setAttribute('color', new THREE.BufferAttribute(colorAttr, 3));
|
||||
|
||||
// Build index (triangles from grid)
|
||||
const indices = [];
|
||||
for (let iy = 0; iy < ny - 1; iy++) {
|
||||
for (let ix = 0; ix < nx - 1; ix++) {
|
||||
const a = iy * nx + ix;
|
||||
const b = a + 1;
|
||||
const c = a + nx;
|
||||
const d = c + 1;
|
||||
indices.push(a, c, b);
|
||||
indices.push(b, c, d);
|
||||
if (indexArr) {
|
||||
geom.setIndex(Array.from(indexArr));
|
||||
} else {
|
||||
const gridIndices = [];
|
||||
for (let iy = 0; iy < ny - 1; iy++) {
|
||||
for (let ix = 0; ix < nx - 1; ix++) {
|
||||
const a = iy * nx + ix;
|
||||
const b = a + 1;
|
||||
const c = a + nx;
|
||||
const d = c + 1;
|
||||
gridIndices.push(a, c, b);
|
||||
gridIndices.push(b, c, d);
|
||||
}
|
||||
}
|
||||
geom.setIndex(gridIndices);
|
||||
}
|
||||
geom.setIndex(indices);
|
||||
geom.computeVertexNormals();
|
||||
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
@@ -169,8 +240,16 @@ export default function SurfaceView({ meshData }) {
|
||||
|
||||
// Reset camera target to center of mesh
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}, [meshData, decode]);
|
||||
if (!hasSyncedInitialSnapshotRef.current) {
|
||||
applyCameraState(
|
||||
Number.isFinite(camera_azimuth) ? camera_azimuth : Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
||||
Number.isFinite(camera_polar) ? camera_polar : Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
||||
Number.isFinite(camera_distance) ? camera_distance : Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
||||
);
|
||||
hasSyncedInitialSnapshotRef.current = true;
|
||||
}
|
||||
scheduleViewportSync(0, false);
|
||||
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
|
||||
|
||||
// Prevent scroll events from propagating to React Flow
|
||||
const onWheel = useCallback((e) => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
export const DATA_TYPES = new Set([
|
||||
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
|
||||
'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP',
|
||||
'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY', 'COORDPAIR',
|
||||
'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'ANNOTATION_SOURCE', 'COLORMAP',
|
||||
'SAVE_LAYER', 'SAVE_VALUE', 'MESH_MODEL', 'FONT', 'FILE_PATH', 'DIRECTORY', 'COORDPAIR',
|
||||
]);
|
||||
|
||||
export const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
|
||||
@@ -22,8 +22,11 @@ export const TYPE_COLORS = {
|
||||
STATS_SOURCE: '#c084fc',
|
||||
CURSOR_SOURCE: '#a78bfa',
|
||||
VALUE_SOURCE: '#60a5fa',
|
||||
ANNOTATION_SOURCE: '#06b6d4',
|
||||
COLORMAP: '#f472b6',
|
||||
SAVE_LAYER: '#22c55e',
|
||||
SAVE_VALUE: '#4ade80',
|
||||
MESH_MODEL: '#14b8a6',
|
||||
FONT: '#fb7185',
|
||||
FILE_PATH: '#f59e0b',
|
||||
DIRECTORY: '#f97316',
|
||||
@@ -44,7 +47,9 @@ export const SOCKET_COMPATIBILITY = {
|
||||
CURSOR_SOURCE: new Set(['DATA_FIELD', 'LINE']),
|
||||
ANY_TABLE: new Set(['MEASURE_TABLE', 'RECORD_TABLE']),
|
||||
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']),
|
||||
FLOAT: new Set(['INT']),
|
||||
INT: new Set(['FLOAT']),
|
||||
LINE: new Set(['COORDPAIR']),
|
||||
|
||||
@@ -52,11 +52,12 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
||||
for (const node of nodes) {
|
||||
if (!runnableNodeIds.has(node.id)) continue;
|
||||
|
||||
const { className, definition, widgetValues } = node.data;
|
||||
const { className, definition, widgetValues, runtimeValues } = node.data;
|
||||
if (!definition) continue;
|
||||
if (excludeManualTrigger && definition.manual_trigger) continue;
|
||||
|
||||
const inputs = {};
|
||||
const valueBag = { ...(widgetValues || {}), ...(runtimeValues || {}) };
|
||||
|
||||
const allWidgets = {
|
||||
...(definition.input.required || {}),
|
||||
@@ -66,8 +67,8 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
if (DATA_TYPES.has(type)) continue;
|
||||
if (type === 'BUTTON') continue;
|
||||
if (widgetValues[name] !== undefined) {
|
||||
inputs[name] = widgetValues[name];
|
||||
if (valueBag[name] !== undefined) {
|
||||
inputs[name] = valueBag[name];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
frontend/src/nodeClipboard.js
Normal file
128
frontend/src/nodeClipboard.js
Normal file
@@ -0,0 +1,128 @@
|
||||
export const NODE_CLIPBOARD_KIND = 'argonode/node-selection';
|
||||
export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection';
|
||||
|
||||
function cloneValue(value) {
|
||||
if (value == null) return value;
|
||||
if (typeof structuredClone === 'function') {
|
||||
try {
|
||||
return structuredClone(value);
|
||||
} catch {
|
||||
// Fall through to JSON clone for simple plain data.
|
||||
}
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clonePlainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return cloneValue(value) || {};
|
||||
}
|
||||
|
||||
export function buildNodeClipboardPayload(nodes, edges) {
|
||||
const selectedNodes = Array.isArray(nodes) ? nodes.filter((node) => node?.selected) : [];
|
||||
if (selectedNodes.length === 0) return null;
|
||||
|
||||
const selectedIds = new Set(selectedNodes.map((node) => String(node.id)));
|
||||
const internalEdges = Array.isArray(edges)
|
||||
? edges.filter((edge) => selectedIds.has(String(edge.source)) && selectedIds.has(String(edge.target)))
|
||||
: [];
|
||||
|
||||
return {
|
||||
kind: NODE_CLIPBOARD_KIND,
|
||||
version: 1,
|
||||
nodes: selectedNodes.map((node) => ({
|
||||
id: String(node.id),
|
||||
type: node.type || 'custom',
|
||||
position: {
|
||||
x: Number(node.position?.x) || 0,
|
||||
y: Number(node.position?.y) || 0,
|
||||
},
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
data: {
|
||||
label: node.data?.label || node.data?.className || 'Node',
|
||||
className: node.data?.className || '',
|
||||
widgetValues: clonePlainObject(node.data?.widgetValues),
|
||||
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
||||
},
|
||||
})),
|
||||
edges: internalEdges.map((edge) => ({
|
||||
source: String(edge.source),
|
||||
sourceHandle: edge.sourceHandle,
|
||||
target: String(edge.target),
|
||||
targetHandle: edge.targetHandle,
|
||||
...(edge.style ? { style: { ...edge.style } } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNodeClipboardPayload(text) {
|
||||
if (typeof text !== 'string' || !text.trim()) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.kind !== NODE_CLIPBOARD_KIND) return null;
|
||||
if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function instantiateNodeClipboardPayload(payload, defs = {}, nextNodeId = 1, offset = { x: 40, y: 40 }) {
|
||||
if (!payload || !Array.isArray(payload.nodes) || payload.nodes.length === 0) {
|
||||
return { nodes: [], edges: [], nextNodeId };
|
||||
}
|
||||
|
||||
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);
|
||||
const className = node.data?.className || '';
|
||||
const definition = className ? defs[className] || null : null;
|
||||
|
||||
return {
|
||||
id: newId,
|
||||
type: node.type || 'custom',
|
||||
position: {
|
||||
x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0),
|
||||
y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0),
|
||||
},
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
selected: true,
|
||||
data: {
|
||||
label: node.data?.label || className || 'Node',
|
||||
className,
|
||||
widgetValues: clonePlainObject(node.data?.widgetValues),
|
||||
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
||||
definition,
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
meshData: null,
|
||||
overlay: null,
|
||||
scalarValue: null,
|
||||
processingTimeMs: null,
|
||||
warning: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const edges = payload.edges
|
||||
.filter((edge) => idMap.has(String(edge.source)) && idMap.has(String(edge.target)))
|
||||
.map((edge, index) => ({
|
||||
id: `e${idMap.get(String(edge.source))}-${idMap.get(String(edge.target))}-${index}`,
|
||||
source: idMap.get(String(edge.source)),
|
||||
sourceHandle: edge.sourceHandle,
|
||||
target: idMap.get(String(edge.target)),
|
||||
targetHandle: edge.targetHandle,
|
||||
selected: false,
|
||||
...(edge.style ? { style: { ...edge.style } } : {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
nextNodeId: currentId,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* PNG files are composed of chunks: [4-byte length][4-byte type][data][4-byte CRC].
|
||||
* We add an iTXt chunk with key "workflow" containing the JSON-serialised graph,
|
||||
* inserted just before the IEND chunk. We still read legacy tEXt chunks.
|
||||
* inserted just before the IEND chunk.
|
||||
*/
|
||||
|
||||
// ── CRC32 (PNG uses CRC-32/ISO 3309) ────────────────────────────────
|
||||
@@ -71,10 +71,6 @@ function parseTextChunk(type, chunkData) {
|
||||
const keyword = decoder.decode(chunkData.subarray(0, keywordEnd));
|
||||
if (keyword !== 'workflow') return null;
|
||||
|
||||
if (type === 'tEXt') {
|
||||
return JSON.parse(decoder.decode(chunkData.subarray(keywordEnd + 1)));
|
||||
}
|
||||
|
||||
if (type !== 'iTXt') return null;
|
||||
|
||||
const compressionFlagIdx = keywordEnd + 1;
|
||||
@@ -139,7 +135,7 @@ export async function embedWorkflow(pngBlob, workflow) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the workflow object from a PNG blob's iTXt/tEXt chunks.
|
||||
* Extract the workflow object from a PNG blob's iTXt chunks.
|
||||
* Returns the parsed object, or null if no "workflow" key is found.
|
||||
*/
|
||||
export async function extractWorkflow(pngBlob) {
|
||||
@@ -154,7 +150,7 @@ export async function extractWorkflow(pngBlob) {
|
||||
if (pos + 12 + len > data.length) break;
|
||||
const type = chunkType(data, pos);
|
||||
|
||||
if (type === 'tEXt' || type === 'iTXt') {
|
||||
if (type === 'iTXt') {
|
||||
const chunkData = data.subarray(pos + 8, pos + 8 + len);
|
||||
const parsed = parseTextChunk(type, chunkData);
|
||||
if (parsed) found = parsed;
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
function mergeDefinition(nodeData, defs) {
|
||||
const savedData = nodeData || {};
|
||||
const savedDefinition = savedData.definition && typeof savedData.definition === 'object'
|
||||
? savedData.definition
|
||||
: null;
|
||||
const registryDefinition = savedData.className ? defs[savedData.className] : null;
|
||||
const definition = registryDefinition || savedDefinition;
|
||||
|
||||
if (!definition) return null;
|
||||
|
||||
const output = Array.isArray(savedData.output)
|
||||
? savedData.output
|
||||
: (Array.isArray(savedDefinition?.output) ? savedDefinition.output : null);
|
||||
const outputName = Array.isArray(savedData.output_name)
|
||||
? savedData.output_name
|
||||
: (Array.isArray(savedDefinition?.output_name) ? savedDefinition.output_name : null);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
...(output ? { output } : {}),
|
||||
...(outputName ? { output_name: outputName } : {}),
|
||||
};
|
||||
return registryDefinition || null;
|
||||
}
|
||||
|
||||
function getSocketType(inputDef) {
|
||||
@@ -28,12 +10,6 @@ function getSocketType(inputDef) {
|
||||
return Array.isArray(type) ? type[0] : type;
|
||||
}
|
||||
|
||||
function getInputType(definition, inputName) {
|
||||
const required = definition?.input?.required || {};
|
||||
const optional = definition?.input?.optional || {};
|
||||
return getSocketType(required[inputName] ?? optional[inputName]);
|
||||
}
|
||||
|
||||
function getInputEntries(definition) {
|
||||
return [
|
||||
...Object.entries(definition?.input?.required || {}),
|
||||
@@ -54,31 +30,6 @@ function sanitizeWidgetValues(widgetValues, definition) {
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function remapLegacyHandle(handleId, kind, nodeData) {
|
||||
if (typeof handleId !== 'string') return handleId;
|
||||
|
||||
const parts = handleId.split('::');
|
||||
if (parts.length !== 3 || parts[2] !== 'TABLE') return handleId;
|
||||
|
||||
if (kind === 'source' && parts[0] === 'output') {
|
||||
const outputSlot = Number.parseInt(parts[1], 10);
|
||||
const outputType = nodeData?.definition?.output?.[outputSlot];
|
||||
if (typeof outputType === 'string' && outputType !== 'TABLE') {
|
||||
return `output::${outputSlot}::${outputType}`;
|
||||
}
|
||||
return handleId;
|
||||
}
|
||||
|
||||
if (kind === 'target' && parts[0] === 'input') {
|
||||
const inputType = getInputType(nodeData?.definition, parts[1]);
|
||||
if (typeof inputType === 'string' && inputType !== 'TABLE') {
|
||||
return `input::${parts[1]}::${inputType}`;
|
||||
}
|
||||
}
|
||||
|
||||
return handleId;
|
||||
}
|
||||
|
||||
export function hydrateWorkflowState(data, defs = {}) {
|
||||
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||
const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
|
||||
@@ -94,6 +45,7 @@ export function hydrateWorkflowState(data, defs = {}) {
|
||||
...node.data,
|
||||
label: node.data?.label || node.data?.className || 'Node',
|
||||
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
||||
runtimeValues: {},
|
||||
definition,
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
@@ -104,13 +56,7 @@ export function hydrateWorkflowState(data, defs = {}) {
|
||||
};
|
||||
});
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [String(node.id), node.data]));
|
||||
|
||||
const edges = loadedEdges.map((edge) => ({
|
||||
...edge,
|
||||
sourceHandle: remapLegacyHandle(edge.sourceHandle, 'source', nodeById.get(String(edge.source))),
|
||||
targetHandle: remapLegacyHandle(edge.targetHandle, 'target', nodeById.get(String(edge.target))),
|
||||
}));
|
||||
const edges = loadedEdges.map((edge) => ({ ...edge }));
|
||||
|
||||
const nextNodeId = Math.max(0, ...loadedNodes.map((node) => parseInt(node.id, 10) || 0)) + 1;
|
||||
|
||||
|
||||
179
frontend/tests/nodeClipboard.test.mjs
Normal file
179
frontend/tests/nodeClipboard.test.mjs
Normal file
@@ -0,0 +1,179 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildNodeClipboardPayload,
|
||||
instantiateNodeClipboardPayload,
|
||||
NODE_CLIPBOARD_KIND,
|
||||
parseNodeClipboardPayload,
|
||||
} from '../src/nodeClipboard.js';
|
||||
|
||||
test('buildNodeClipboardPayload keeps only selected nodes and internal edges', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
selected: true,
|
||||
type: 'custom',
|
||||
position: { x: 10, y: 20 },
|
||||
data: {
|
||||
label: 'Image',
|
||||
className: 'Image',
|
||||
widgetValues: { filename: 'scan.ibw' },
|
||||
runtimeValues: { layerIndex: 2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
selected: true,
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
label: 'Preview',
|
||||
className: 'Preview',
|
||||
widgetValues: { mode: 'auto' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
selected: false,
|
||||
position: { x: 500, y: 600 },
|
||||
data: {
|
||||
label: 'Save',
|
||||
className: 'Save',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges = [
|
||||
{
|
||||
id: 'e1-2',
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
style: { stroke: '#fff', strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'e2-3',
|
||||
source: '2',
|
||||
sourceHandle: 'output::0::IMAGE',
|
||||
target: '3',
|
||||
targetHandle: 'input::value::SAVE_VALUE',
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildNodeClipboardPayload(nodes, edges);
|
||||
|
||||
assert.equal(payload.kind, NODE_CLIPBOARD_KIND);
|
||||
assert.equal(payload.nodes.length, 2);
|
||||
assert.deepEqual(payload.nodes.map((node) => node.id), ['1', '2']);
|
||||
assert.equal(payload.edges.length, 1);
|
||||
assert.deepEqual(payload.edges[0], {
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
style: { stroke: '#fff', strokeWidth: 2 },
|
||||
});
|
||||
|
||||
const reparsed = parseNodeClipboardPayload(JSON.stringify(payload));
|
||||
assert.deepEqual(reparsed, payload);
|
||||
});
|
||||
|
||||
test('instantiateNodeClipboardPayload remaps ids, offsets positions, and hydrates node shells', () => {
|
||||
const payload = {
|
||||
kind: NODE_CLIPBOARD_KIND,
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
position: { x: 10, y: 20 },
|
||||
data: {
|
||||
label: 'Image',
|
||||
className: 'Image',
|
||||
widgetValues: { filename: 'scan.ibw', colormap: 'viridis' },
|
||||
runtimeValues: { layerIndex: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
label: 'Preview',
|
||||
className: 'Preview',
|
||||
widgetValues: { colormap: 'gray' },
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
style: { stroke: '#abc', strokeWidth: 2 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defs = {
|
||||
Image: { output: ['DATA_FIELD'], output_name: ['field'] },
|
||||
Preview: { output: ['IMAGE'], output_name: ['preview'] },
|
||||
};
|
||||
|
||||
const instantiated = instantiateNodeClipboardPayload(payload, defs, 12, { x: 32, y: 48 });
|
||||
|
||||
assert.equal(instantiated.nextNodeId, 14);
|
||||
assert.deepEqual(instantiated.nodes.map((node) => node.id), ['12', '13']);
|
||||
assert.deepEqual(instantiated.nodes.map((node) => node.position), [
|
||||
{ x: 42, y: 68 },
|
||||
{ x: 132, y: 248 },
|
||||
]);
|
||||
assert.equal(instantiated.nodes[0].selected, true);
|
||||
assert.deepEqual(instantiated.nodes[0].data.widgetValues, { filename: 'scan.ibw', colormap: 'viridis' });
|
||||
assert.deepEqual(instantiated.nodes[0].data.runtimeValues, { layerIndex: 1 });
|
||||
assert.equal(instantiated.nodes[0].data.previewImage, null);
|
||||
assert.deepEqual(instantiated.nodes[0].data.definition, defs.Image);
|
||||
|
||||
assert.deepEqual(instantiated.edges, [
|
||||
{
|
||||
id: 'e12-13-0',
|
||||
source: '12',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '13',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
selected: false,
|
||||
style: { stroke: '#abc', strokeWidth: 2 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('clipboard payload deep-copies local widget and runtime fields', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '9',
|
||||
selected: true,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Markup',
|
||||
className: 'Markup',
|
||||
widgetValues: {
|
||||
stroke_width: 3,
|
||||
markup_shapes: [
|
||||
{ kind: 'line', points: [0.1, 0.2, 0.3, 0.4] },
|
||||
],
|
||||
},
|
||||
runtimeValues: {
|
||||
camera: { azimuth: 15, polar: 60 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildNodeClipboardPayload(nodes, []);
|
||||
|
||||
nodes[0].data.widgetValues.markup_shapes[0].points[0] = 0.9;
|
||||
nodes[0].data.runtimeValues.camera.azimuth = 90;
|
||||
|
||||
assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1);
|
||||
assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15);
|
||||
});
|
||||
@@ -9,63 +9,6 @@ function makePngBlob() {
|
||||
return new Blob([Buffer.from(PNG_BASE64, 'base64')], { type: 'image/png' });
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
table[i] = c;
|
||||
}
|
||||
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
crc = table[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
function buildChunk(type, payload) {
|
||||
const typeBytes = new TextEncoder().encode(type);
|
||||
const crcInput = new Uint8Array(4 + payload.length);
|
||||
crcInput.set(typeBytes, 0);
|
||||
crcInput.set(payload, 4);
|
||||
|
||||
const chunk = new Uint8Array(12 + payload.length);
|
||||
const view = new DataView(chunk.buffer);
|
||||
view.setUint32(0, payload.length);
|
||||
chunk.set(typeBytes, 4);
|
||||
chunk.set(payload, 8);
|
||||
view.setUint32(8 + payload.length, crc32(crcInput));
|
||||
return chunk;
|
||||
}
|
||||
|
||||
async function insertTextChunk(blob, workflow) {
|
||||
const png = new Uint8Array(await blob.arrayBuffer());
|
||||
const encoder = new TextEncoder();
|
||||
const key = encoder.encode('workflow');
|
||||
const text = encoder.encode(JSON.stringify(workflow));
|
||||
const payload = new Uint8Array(key.length + 1 + text.length);
|
||||
payload.set(key, 0);
|
||||
payload.set(text, key.length + 1);
|
||||
const chunk = buildChunk('tEXt', payload);
|
||||
|
||||
let pos = 8;
|
||||
while (pos < png.length) {
|
||||
const len = new DataView(png.buffer, pos, 4).getUint32(0);
|
||||
const type = String.fromCharCode(png[pos + 4], png[pos + 5], png[pos + 6], png[pos + 7]);
|
||||
if (type === 'IEND') break;
|
||||
pos += 12 + len;
|
||||
}
|
||||
|
||||
const out = new Uint8Array(png.length + chunk.length);
|
||||
out.set(png.subarray(0, pos), 0);
|
||||
out.set(chunk, pos);
|
||||
out.set(png.subarray(pos), pos + chunk.length);
|
||||
return new Blob([out], { type: 'image/png' });
|
||||
}
|
||||
|
||||
test('embedWorkflow roundtrips workflow data through an iTXt chunk', async () => {
|
||||
const workflow = {
|
||||
version: 1,
|
||||
@@ -88,15 +31,6 @@ test('embedWorkflow roundtrips workflow data through an iTXt chunk', async () =>
|
||||
assert.match(Buffer.from(bytes).toString('latin1'), /iTXt/);
|
||||
});
|
||||
|
||||
test('extractWorkflow still supports legacy tEXt metadata chunks', async () => {
|
||||
const workflow = { version: 1, legacy: true, nodes: [], edges: [] };
|
||||
const legacyBlob = await insertTextChunk(makePngBlob(), workflow);
|
||||
|
||||
const extracted = await extractWorkflow(legacyBlob);
|
||||
|
||||
assert.deepEqual(extracted, workflow);
|
||||
});
|
||||
|
||||
test('extractWorkflow returns the last workflow chunk when an image is re-saved', async () => {
|
||||
const first = { version: 1, name: 'old', nodes: [], edges: [] };
|
||||
const second = { version: 1, name: 'new', nodes: [], edges: [] };
|
||||
|
||||
@@ -95,7 +95,7 @@ test('serializeWorkflowState keeps only stable workflow fields needed for reload
|
||||
assert.equal('selected' in serialized.edges[0], false);
|
||||
});
|
||||
|
||||
test('hydrateWorkflowState clears shared path widgets while restoring saved dynamic outputs', () => {
|
||||
test('hydrateWorkflowState clears shared path widgets and uses registry definitions', () => {
|
||||
const saved = {
|
||||
version: 1,
|
||||
nodes: [
|
||||
@@ -142,12 +142,12 @@ test('hydrateWorkflowState clears shared path widgets while restoring saved dyna
|
||||
assert.equal(hydrated.nodes[0].data.previewImage, null);
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.filename, '');
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.colormap, 'viridis');
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Height', 'Phase']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['field']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.input, defs.Image.input);
|
||||
});
|
||||
|
||||
test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets but preserve other metadata', () => {
|
||||
test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets without restoring saved outputs', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '7',
|
||||
@@ -188,8 +188,8 @@ test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets bu
|
||||
const hydrated = hydrateWorkflowState(serialized, defs);
|
||||
|
||||
assert.deepEqual(hydrated.nodes[0].data.widgetValues, { filename: '', colormap: 'gray' });
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD', 'DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Topography', 'Error', 'Mask']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['field']);
|
||||
assert.deepEqual(hydrated.edges, edges);
|
||||
});
|
||||
|
||||
@@ -223,6 +223,6 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows',
|
||||
const hydrated = hydrateWorkflowState(saved, defs);
|
||||
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.folder, '');
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH', 'PATH']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['scan1.png', 'scan2.png']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user