add snapshot tool, masks, and build for mac
This commit is contained in:
@@ -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
129
frontend/src/pngMetadata.js
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user