add snapshot tool, masks, and build for mac

This commit is contained in:
2026-03-23 21:52:17 -07:00
parent 080eefbef6
commit a34b1c980d
29 changed files with 2016 additions and 170 deletions

View File

@@ -4,24 +4,26 @@ import React, {
import {
ReactFlow, Background, Controls, MiniMap,
useNodesState, useEdgesState, addEdge, useReactFlow,
ReactFlowProvider,
ReactFlowProvider, getNodesBounds, getViewportForBounds,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import CustomNode, { NodeContext } from './CustomNode';
import FileBrowser from './FileBrowser';
import * as api from './api';
import { toBlob } from 'html-to-image';
import { embedWorkflow, extractWorkflow } from './pngMetadata';
// ── Constants ─────────────────────────────────────────────────────────
const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']);
const TYPE_COLORS = {
DATA_FIELD: '#3a7abf',
IMAGE: '#4caf50',
LINE: '#ff9800',
TABLE: '#fdd835',
COORD: '#e91e63',
DATA_FIELD: '#ff002f',
IMAGE: '#00ff08a0',
LINE: '#ffbe5c',
TABLE: '#35e2fd',
COORD: '#e91ed1',
};
const NODE_TYPES = { custom: CustomNode };
@@ -272,6 +274,13 @@ function Flow() {
// ── File browser ────────────────────────────────────────────────────
const openFileBrowser = useCallback((callback) => {
// Use native file picker when running inside pywebview (desktop app)
if (window.pywebview?.api?.open_file_dialog) {
window.pywebview.api.open_file_dialog().then((path) => {
if (path) callback(path);
});
return;
}
setFileBrowserCb(() => callback);
}, []);
@@ -427,60 +436,162 @@ function Flow() {
setStatus({ text: 'Graph cleared.', level: 'info' });
}, [setNodes, setEdges]);
const saveWorkflow = useCallback(() => {
const currentNodes = reactFlow.getNodes().map((n) => ({
const applyWorkflowData = useCallback((data) => {
const loadedNodes = data.nodes || [];
const loadedEdges = data.edges || [];
const defs = nodeDefsRef.current;
const hydrated = loadedNodes.map((n) => ({
...n,
data: {
...n.data,
definition: defs[n.data.className] || n.data.definition,
previewImage: null, tableRows: null, meshData: null, overlay: null,
},
}));
setNodes(hydrated);
setEdges(loadedEdges);
const maxId = Math.max(0, ...loadedNodes.map((n) => parseInt(n.id, 10) || 0));
nextIdRef.current = maxId + 1;
}, [setNodes, setEdges]);
const getWorkflowBlob = useCallback(async () => {
const viewportEl = document.querySelector('.react-flow__viewport');
if (!viewportEl) throw new Error('Flow element not found');
const allNodes = reactFlow.getNodes();
if (allNodes.length === 0) throw new Error('No nodes to capture');
const bounds = getNodesBounds(allNodes);
const pad = 0.1; // 10% margin on each side
const imageWidth = Math.ceil(bounds.width * (1 + pad * 2));
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
const blob = await toBlob(viewportEl, {
backgroundColor: '#1a1a1a',
width: imageWidth,
height: imageHeight,
style: {
width: `${imageWidth}px`,
height: `${imageHeight}px`,
transform: `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`,
},
});
if (!blob) throw new Error('Capture returned empty');
const currentNodes = allNodes.map((n) => ({
...n,
data: { ...n.data, previewImage: null, tableRows: null, meshData: null, overlay: null },
}));
const data = { version: 1, nodes: currentNodes, edges: reactFlow.getEdges() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'workflow.json';
a.click();
const workflow = { version: 1, nodes: currentNodes, edges: reactFlow.getEdges() };
return embedWorkflow(blob, workflow);
}, [reactFlow]);
const saveWorkflow = useCallback(async () => {
setStatus({ text: 'Saving…', level: 'info' });
try {
const finalBlob = await getWorkflowBlob();
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({
suggestedName: 'workflow.png',
types: [{ description: 'PNG Image', accept: { 'image/png': ['.png'] } }],
});
const writable = await handle.createWritable();
await writable.write(finalBlob);
await writable.close();
} else {
// Fallback: programmatic download
const a = document.createElement('a');
a.href = URL.createObjectURL(finalBlob);
a.download = 'workflow.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}
setStatus({ text: 'Workflow saved.', level: 'info' });
} catch (err) {
if (err.name === 'AbortError') {
setStatus({ text: 'Save cancelled.', level: 'info' });
} else {
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
}
}
}, [getWorkflowBlob]);
const copySnapshot = useCallback(() => {
setStatus({ text: 'Copying snapshot…', level: 'info' });
// Pass a Promise<Blob> to ClipboardItem so the clipboard.write() call
// happens synchronously within the user gesture, avoiding permission errors.
const blobPromise = getWorkflowBlob().catch((err) => {
setStatus({ text: 'Snapshot failed: ' + err.message, level: 'error' });
throw err;
});
navigator.clipboard.write([new ClipboardItem({ 'image/png': blobPromise })]).then(() => {
setStatus({ text: 'Snapshot copied to clipboard.', level: 'info' });
}).catch((err) => {
setStatus({ text: 'Copy failed: ' + err.message, level: 'error' });
});
}, [getWorkflowBlob]);
const loadWorkflow = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.accept = '.json,.png';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
try {
const data = JSON.parse(text);
const loadedNodes = data.nodes || [];
const loadedEdges = data.edges || [];
// Re-populate definitions from current nodeDefs
const defs = nodeDefsRef.current;
const hydrated = loadedNodes.map((n) => ({
...n,
data: {
...n.data,
definition: defs[n.data.className] || n.data.definition,
previewImage: null,
tableRows: null,
meshData: null,
overlay: null,
},
}));
setNodes(hydrated);
setEdges(loadedEdges);
// Update ID counter to avoid collisions
const maxId = Math.max(0, ...loadedNodes.map((n) => parseInt(n.id, 10) || 0));
nextIdRef.current = maxId + 1;
let data;
if (file.name.endsWith('.png') || file.type === 'image/png') {
data = await extractWorkflow(file);
if (!data) {
setStatus({ text: 'No workflow data found in image.', level: 'error' });
return;
}
} else {
data = JSON.parse(await file.text());
}
applyWorkflowData(data);
setStatus({ text: 'Workflow loaded.', level: 'info' });
} catch {
setStatus({ text: 'Invalid workflow JSON.', level: 'error' });
setStatus({ text: 'Invalid workflow file.', level: 'error' });
}
};
input.click();
}, [setNodes, setEdges]);
}, [applyWorkflowData]);
// ── Drag-and-drop workflow image loading ───────────────────────────
const onDropFile = useCallback(async (event) => {
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
event.preventDefault();
const file = files[0];
if (file.type !== 'image/png') return;
try {
const data = await extractWorkflow(file);
if (!data) {
setStatus({ text: 'No workflow data in this image.', level: 'error' });
return;
}
applyWorkflowData(data);
setStatus({ text: 'Workflow loaded from image.', level: 'info' });
} catch (err) {
setStatus({ text: 'Failed to load: ' + err.message, level: 'error' });
}
}, [applyWorkflowData]);
const onDragOver = useCallback((event) => {
if (event.dataTransfer?.types?.includes('Files')) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
}, []);
// ── Keyboard shortcut ───────────────────────────────────────────────
@@ -509,7 +620,7 @@ function Flow() {
<div className="app-container">
{/* Toolbar */}
<div id="toolbar">
<span id="app-title">Argonode</span>
<span id="app-title">argonode</span>
<div className="toolbar-group">
<button className="btn btn-primary" onClick={runWorkflow} title="Run workflow (Ctrl+Enter)">
@@ -521,12 +632,15 @@ function Flow() {
</div>
<div className="toolbar-group">
<button className="btn" onClick={saveWorkflow} title="Save workflow JSON">
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
Save
</button>
<button className="btn" onClick={loadWorkflow} title="Load workflow JSON">
<button className="btn" onClick={loadWorkflow} title="Load workflow (JSON or PNG)">
Load
</button>
<button className="btn" onClick={copySnapshot} title="Copy workflow screenshot to clipboard">
Snapshot
</button>
</div>
<div className={`status-bar ${status.level}`}>{status.text}</div>
@@ -535,7 +649,7 @@ function Flow() {
{/* React Flow canvas */}
<div className="flow-container" onMouseDown={(e) => {
if (!e.target.closest('.context-menu')) setContextMenu(null);
}}>
}} onDrop={onDropFile} onDragOver={onDragOver}>
<ReactFlow
nodes={nodes}
edges={edges}

129
frontend/src/pngMetadata.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* PNG tEXt chunk utilities for embedding/extracting workflow metadata.
*
* PNG files are composed of chunks: [4-byte length][4-byte type][data][4-byte CRC].
* We add a tEXt chunk with key "workflow" containing the JSON-serialised graph,
* inserted just before the IEND chunk.
*/
// ── CRC32 (PNG uses CRC-32/ISO 3309) ────────────────────────────────
const crcTable = 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);
}
crcTable[i] = c;
}
function crc32(bytes) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
// ── Helpers ──────────────────────────────────────────────────────────
const PNG_SIG = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
function isPng(data) {
if (data.length < 8) return false;
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIG[i]) return false;
}
return true;
}
function chunkType(data, offset) {
return String.fromCharCode(
data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
);
}
// ── Public API ───────────────────────────────────────────────────────
/**
* Embed a workflow object into a PNG blob as a tEXt chunk.
* Returns a new Blob with the metadata inserted before IEND.
*/
export async function embedWorkflow(pngBlob, workflow) {
const data = new Uint8Array(await pngBlob.arrayBuffer());
if (!isPng(data)) throw new Error('Not a valid PNG file');
const encoder = new TextEncoder();
// Build tEXt payload: keyword \0 text
const key = encoder.encode('workflow');
const val = encoder.encode(JSON.stringify(workflow));
const payload = new Uint8Array(key.length + 1 + val.length);
payload.set(key, 0);
// payload[key.length] is already 0 (null separator)
payload.set(val, key.length + 1);
// CRC covers type + payload
const typeBytes = encoder.encode('tEXt');
const forCrc = new Uint8Array(4 + payload.length);
forCrc.set(typeBytes, 0);
forCrc.set(payload, 4);
// Assemble chunk: length(4) + type(4) + payload + crc(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(forCrc));
// Locate IEND
let pos = 8;
let iendPos = data.length;
while (pos < data.length) {
const len = new DataView(data.buffer, pos, 4).getUint32(0);
if (chunkType(data, pos) === 'IEND') { iendPos = pos; break; }
pos += 12 + len;
}
// Splice: [before IEND] + [tEXt chunk] + [IEND]
const result = new Uint8Array(data.length + chunk.length);
result.set(data.subarray(0, iendPos), 0);
result.set(chunk, iendPos);
result.set(data.subarray(iendPos), iendPos + chunk.length);
return new Blob([result], { type: 'image/png' });
}
/**
* Extract the workflow object from a PNG blob's tEXt chunks.
* Returns the parsed object, or null if no "workflow" key is found.
*/
export async function extractWorkflow(pngBlob) {
const data = new Uint8Array(await pngBlob.arrayBuffer());
if (!isPng(data)) return null;
const decoder = new TextDecoder();
let pos = 8;
while (pos + 8 <= data.length) {
const len = new DataView(data.buffer, pos, 4).getUint32(0);
const type = chunkType(data, pos);
if (type === 'tEXt' && pos + 8 + len <= data.length) {
const chunkData = data.subarray(pos + 8, pos + 8 + len);
const nullIdx = chunkData.indexOf(0);
if (nullIdx !== -1) {
const k = decoder.decode(chunkData.subarray(0, nullIdx));
if (k === 'workflow') {
return JSON.parse(decoder.decode(chunkData.subarray(nullIdx + 1)));
}
}
}
if (type === 'IEND') break;
pos += 12 + len;
}
return null;
}

View File

@@ -21,8 +21,8 @@ html, body, #root {
/* ── Toolbar ───────────────────────────────────────────────────────── */
#toolbar {
height: 44px;
background: #16213e;
border-bottom: 1px solid #0f3460;
background: #242424;
border-bottom: 1px solid #000000;
display: flex;
align-items: center;
padding: 0 12px;
@@ -36,7 +36,7 @@ html, body, #root {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.5px;
color: #e94560;
color: #ffffff;
margin-right: 8px;
flex-shrink: 0;
}
@@ -129,8 +129,17 @@ html, body, #root {
cursor: grabbing;
}
.custom-node.selected {
/* Selected node — target via React Flow's wrapper class */
.react-flow__node.selected .custom-node {
border-color: #90caf9;
box-shadow: 0 0 0 1px #90caf9, 0 0 12px rgba(144, 202, 249, 0.4);
}
/* Selected edge */
.react-flow__edge.selected .react-flow__edge-path {
stroke: #90caf9 !important;
stroke-width: 3px !important;
filter: drop-shadow(0 0 4px rgba(144, 202, 249, 0.6));
}
.node-title {