work on canvas feel

This commit is contained in:
matei jordache
2026-03-27 17:06:54 -07:00
parent 558046e7aa
commit 62d7537555
8 changed files with 405 additions and 95 deletions

View File

@@ -43,6 +43,10 @@ const GROUP_HEADER_HEIGHT = 36;
const GROUP_WORKSPACE_INSET = 12;
const GROUP_MIN_WIDTH = 260;
const GROUP_MIN_HEIGHT = 180;
const CANVAS_MIN_ZOOM = 0.2;
const CANVAS_MAX_ZOOM = 4;
const CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY = 0.0065;
const CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD = 5;
// ── Handle ID helpers ─────────────────────────────────────────────────
@@ -380,6 +384,19 @@ function isEditableTarget(target) {
return target.closest('[contenteditable="true"]') !== null;
}
function clampNumber(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function canStartCanvasRightDragZoom(target) {
if (!target || !(target instanceof Element)) return false;
if (isEditableTarget(target)) return false;
if (target.closest('.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container')) {
return false;
}
return target.closest('.react-flow__pane, .react-flow__background') !== 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;
@@ -791,7 +808,9 @@ function Flow() {
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
const [contextMenu, setContextMenu] = useState(null);
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
const flowContainerRef = useRef(null);
const nodeDefsRef = useRef({});
const nextIdRef = useRef(1);
const autoRunTimer = useRef(null);
@@ -802,6 +821,8 @@ function Flow() {
const duplicateDragRef = useRef(null);
const dragStateRef = useRef(null);
const activeDragNodeIdRef = useRef(null);
const canvasRightZoomRef = useRef(null);
const suppressPaneContextMenuUntilRef = useRef(0);
const reactFlow = useReactFlow();
// ── WebSocket ───────────────────────────────────────────────────────
@@ -2597,9 +2618,106 @@ function Flow() {
const onPaneContextMenu = useCallback((event) => {
event.preventDefault();
if (performance.now() < suppressPaneContextMenuUntilRef.current) {
suppressPaneContextMenuUntilRef.current = 0;
return;
}
setContextMenu({ x: event.clientX, y: event.clientY });
}, []);
const onFlowContainerPointerDown = useCallback((event) => {
if (event.button !== 2) return;
if (!canStartCanvasRightDragZoom(event.target)) return;
event.preventDefault();
event.stopPropagation();
setContextMenu(null);
const viewport = reactFlow.getViewport();
canvasRightZoomRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
startZoom: Number(viewport.zoom) || 1,
moved: false,
};
setIsCanvasRightZooming(true);
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch {
// Ignore capture failures; global listeners still complete the interaction.
}
}, [reactFlow]);
const onFlowContainerContextMenuCapture = useCallback((event) => {
if (canvasRightZoomRef.current?.moved || performance.now() < suppressPaneContextMenuUntilRef.current) {
event.preventDefault();
event.stopPropagation();
}
}, []);
useEffect(() => {
const handlePointerMove = (event) => {
const zoomState = canvasRightZoomRef.current;
if (!zoomState || event.pointerId !== zoomState.pointerId) return;
const deltaY = event.clientY - zoomState.startY;
if (Math.abs(deltaY) < CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD) return;
event.preventDefault();
zoomState.moved = true;
const container = flowContainerRef.current;
if (!container) return;
const bounds = container.getBoundingClientRect();
const localX = event.clientX - bounds.left;
const localY = event.clientY - bounds.top;
const currentViewport = reactFlow.getViewport();
const flowX = (localX - currentViewport.x) / currentViewport.zoom;
const flowY = (localY - currentViewport.y) / currentViewport.zoom;
const nextZoom = clampNumber(
zoomState.startZoom * Math.exp(-deltaY * CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY),
CANVAS_MIN_ZOOM,
CANVAS_MAX_ZOOM,
);
reactFlow.setViewport({
x: localX - (flowX * nextZoom),
y: localY - (flowY * nextZoom),
zoom: nextZoom,
}, { duration: 0 });
};
const finishPointerInteraction = (event) => {
const zoomState = canvasRightZoomRef.current;
if (!zoomState || event.pointerId !== zoomState.pointerId) return;
if (zoomState.moved) {
suppressPaneContextMenuUntilRef.current = performance.now() + 250;
}
canvasRightZoomRef.current = null;
setIsCanvasRightZooming(false);
const container = flowContainerRef.current;
if (container?.hasPointerCapture?.(event.pointerId)) {
try {
container.releasePointerCapture(event.pointerId);
} catch {
// Ignore capture release errors.
}
}
};
window.addEventListener('pointermove', handlePointerMove, true);
window.addEventListener('pointerup', finishPointerInteraction, true);
window.addEventListener('pointercancel', finishPointerInteraction, true);
return () => {
window.removeEventListener('pointermove', handlePointerMove, true);
window.removeEventListener('pointerup', finishPointerInteraction, true);
window.removeEventListener('pointercancel', finishPointerInteraction, true);
};
}, [reactFlow]);
useEffect(() => {
if (!contextMenu) return undefined;
@@ -2648,7 +2766,14 @@ function Flow() {
</div>
{/* React Flow canvas */}
<div className="flow-container" onDrop={onDropFile} onDragOver={onDragOver}>
<div
ref={flowContainerRef}
className={`flow-container${isCanvasRightZooming ? ' canvas-right-zooming' : ''}`}
onDrop={onDropFile}
onDragOver={onDragOver}
onPointerDownCapture={onFlowContainerPointerDown}
onContextMenuCapture={onFlowContainerContextMenuCapture}
>
<ReactFlow
nodes={nodes}
edges={edges}

View File

@@ -66,6 +66,7 @@ function GroupNode({ id, data }) {
),
);
const displayLabel = String(data.label || 'group');
const labelFieldSize = Math.max(2, Math.min(40, String(draftLabel || displayLabel || 'group').length));
useEffect(() => {
if (!isEditingLabel) {
@@ -114,40 +115,43 @@ function GroupNode({ id, data }) {
>
{collapsed ? '▸' : '▾'}
</button>
{isEditingLabel ? (
<input
ref={labelInputRef}
className="group-title-input nodrag"
type="text"
value={draftLabel}
onChange={(event) => setDraftLabel(event.target.value)}
onBlur={commitLabel}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitLabel();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelLabelEdit();
}
}}
/>
) : (
<button
type="button"
className="group-title-button nodrag"
title="rename group"
onClick={(event) => {
event.stopPropagation();
setDraftLabel(displayLabel);
setIsEditingLabel(true);
}}
>
{displayLabel}
</button>
)}
<div className="group-title-slot">
{isEditingLabel ? (
<input
ref={labelInputRef}
className="group-title-input nodrag"
type="text"
value={draftLabel}
size={labelFieldSize}
onChange={(event) => setDraftLabel(event.target.value)}
onBlur={commitLabel}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitLabel();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelLabelEdit();
}
}}
/>
) : (
<button
type="button"
className="group-title-button nodrag"
title="rename group"
onClick={(event) => {
event.stopPropagation();
setDraftLabel(displayLabel);
setIsEditingLabel(true);
}}
>
{displayLabel}
</button>
)}
</div>
<div className="group-node-actions">
<button
type="button"

View File

@@ -249,7 +249,6 @@ export default function LinePlotOverlay({
<>
<line x1={cursorA.x} y1={plotTop} x2={cursorA.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
<line x1={cursorB.x} y1={plotTop} x2={cursorB.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
<line x1={cursorA.x} y1={cursorA.y} x2={cursorB.x} y2={cursorB.y} stroke="var(--accent-light)" strokeWidth={measureStroke} opacity="0.95" />
<circle
cx={cursorA.x}

View File

@@ -2,6 +2,69 @@ import React, { useRef, useEffect, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const DEFAULT_CAMERA_STATE = {
azimuth: 0.0,
polar: 1.1,
distance: 1.8,
targetX: 0.0,
targetY: 0.0,
targetZ: 0.0,
};
function getFiniteNumber(...values) {
for (const value of values) {
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return numeric;
}
}
return null;
}
function getCameraState(meshData, widgetValues, runtimeValues, fallbackTarget = null) {
return {
azimuth: getFiniteNumber(
runtimeValues?.camera_azimuth,
widgetValues?.camera_azimuth,
meshData?.camera_azimuth,
DEFAULT_CAMERA_STATE.azimuth,
),
polar: getFiniteNumber(
runtimeValues?.camera_polar,
widgetValues?.camera_polar,
meshData?.camera_polar,
DEFAULT_CAMERA_STATE.polar,
),
distance: getFiniteNumber(
runtimeValues?.camera_distance,
widgetValues?.camera_distance,
meshData?.camera_distance,
DEFAULT_CAMERA_STATE.distance,
),
targetX: getFiniteNumber(
runtimeValues?.camera_target_x,
widgetValues?.camera_target_x,
meshData?.camera_target_x,
fallbackTarget?.x,
DEFAULT_CAMERA_STATE.targetX,
),
targetY: getFiniteNumber(
runtimeValues?.camera_target_y,
widgetValues?.camera_target_y,
meshData?.camera_target_y,
fallbackTarget?.y,
DEFAULT_CAMERA_STATE.targetY,
),
targetZ: getFiniteNumber(
runtimeValues?.camera_target_z,
widgetValues?.camera_target_z,
meshData?.camera_target_z,
fallbackTarget?.z,
DEFAULT_CAMERA_STATE.targetZ,
),
};
}
/**
* Interactive 3D surface viewer using Three.js.
* Props:
@@ -13,8 +76,14 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
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);
const lastCameraStateRef = useRef({
azimuth: null,
polar: null,
distance: null,
targetX: null,
targetY: null,
targetZ: null,
});
// Decode base64 to typed arrays
const decode = useCallback((b64, ArrayType) => {
@@ -28,19 +97,27 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
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 cameraState = {
azimuth: Number(controls.getAzimuthalAngle().toFixed(4)),
polar: Number(controls.getPolarAngle().toFixed(4)),
distance: Number(controls.getDistance().toFixed(4)),
targetX: Number(controls.target.x.toFixed(4)),
targetY: Number(controls.target.y.toFixed(4)),
targetZ: Number(controls.target.z.toFixed(4)),
};
const snapshot = renderer.domElement.toDataURL('image/png');
const previous = lastAnglesRef.current;
const previous = lastCameraStateRef.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 (previous.azimuth !== cameraState.azimuth) patch.camera_azimuth = cameraState.azimuth;
if (previous.polar !== cameraState.polar) patch.camera_polar = cameraState.polar;
if (previous.distance !== cameraState.distance) patch.camera_distance = cameraState.distance;
if (previous.targetX !== cameraState.targetX) patch.camera_target_x = cameraState.targetX;
if (previous.targetY !== cameraState.targetY) patch.camera_target_y = cameraState.targetY;
if (previous.targetZ !== cameraState.targetZ) patch.camera_target_z = cameraState.targetZ;
if (snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
if (Object.keys(patch).length > 0) {
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
lastAnglesRef.current = { azimuth, polar, distance };
lastCameraStateRef.current = cameraState;
lastSnapshotRef.current = snapshot;
}
}, [nodeId, onRuntimeValuesChange]);
@@ -55,17 +132,26 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
}, delay);
}, [syncViewportState]);
const applyCameraState = useCallback((azimuth, polar, distance) => {
const applyCameraState = useCallback((cameraState = {}) => {
const state = threeRef.current;
if (!state) return;
const { camera, controls } = state;
const target = controls.target.clone();
const target = new THREE.Vector3(
getFiniteNumber(cameraState.targetX, controls.target.x, DEFAULT_CAMERA_STATE.targetX),
getFiniteNumber(cameraState.targetY, controls.target.y, DEFAULT_CAMERA_STATE.targetY),
getFiniteNumber(cameraState.targetZ, controls.target.z, DEFAULT_CAMERA_STATE.targetZ),
);
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,
Math.max(0.3, getFiniteNumber(cameraState.distance, DEFAULT_CAMERA_STATE.distance)),
THREE.MathUtils.clamp(
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar),
0.01,
Math.PI - 0.01,
),
getFiniteNumber(cameraState.azimuth, DEFAULT_CAMERA_STATE.azimuth),
);
const offset = new THREE.Vector3().setFromSpherical(spherical);
controls.target.copy(target);
camera.position.copy(target).add(offset);
controls.update();
}, []);
@@ -96,8 +182,26 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.enablePan = true;
controls.enableZoom = true;
controls.screenSpacePanning = true;
controls.panSpeed = 1.0;
controls.zoomSpeed = 2.2;
controls.minDistance = 0.3;
controls.maxDistance = 10;
controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.DOLLY,
};
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
};
if ('zoomToCursor' in controls) {
controls.zoomToCursor = true;
}
renderer.domElement.style.touchAction = 'none';
const handleControlsEnd = () => scheduleViewportSync(0, true);
controls.addEventListener('end', handleControlsEnd);
@@ -121,11 +225,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
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),
);
applyCameraState(getCameraState(meshData, widgetValues, runtimeValues));
// Resize observer to maintain 1:1 aspect when node width changes
const ro = new ResizeObserver((entries) => {
@@ -152,16 +252,17 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
}
threeRef.current = null;
};
}, [applyCameraState, scheduleViewportSync]);
}, [applyCameraState, meshData, runtimeValues, scheduleViewportSync, widgetValues]);
// Update mesh when data changes
useEffect(() => {
if (!threeRef.current || !meshData) return;
const { scene, camera, controls } = threeRef.current;
const { scene, controls } = threeRef.current;
const {
width: nx, height: ny, z_data, colors, z_min, z_max, z_scale,
positions, indices, vertex_colors, camera_azimuth, camera_polar, camera_distance,
positions, indices, vertex_colors,
surface_extent_x, surface_extent_y,
} = meshData;
// Decode arrays
@@ -182,14 +283,16 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const geom = new THREE.BufferGeometry();
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3)));
const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0);
const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0);
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 px = (ix / Math.max(nx - 1, 1) - 0.5) * surfaceExtentX;
const py = (iy / Math.max(ny - 1, 1) - 0.5) * surfaceExtentY;
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
positionsArray[idx * 3] = px;
@@ -238,21 +341,24 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
scene.add(mesh);
threeRef.current.mesh = mesh;
// Reset camera target to center of mesh
controls.target.set(0, 0, 0);
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;
}
const bounds = new THREE.Box3().setFromObject(mesh);
const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3());
const size = bounds.isEmpty() ? new THREE.Vector3(1, 1, 1) : bounds.getSize(new THREE.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z, 0.25);
controls.minDistance = Math.max(0.1, maxDimension * 0.35);
controls.maxDistance = Math.max(10, maxDimension * 14);
applyCameraState(getCameraState(meshData, widgetValues, runtimeValues, center));
scheduleViewportSync(0, false);
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
// Prevent scroll events from propagating to React Flow
const onWheel = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
}, []);
const onContextMenu = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
}, []);
@@ -261,6 +367,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
ref={containerRef}
className="nodrag nowheel surface-view-container"
onWheelCapture={onWheel}
onContextMenu={onContextMenu}
/>
);
}

View File

@@ -217,6 +217,11 @@ html, body, #root {
flex: 1;
position: relative;
}
.flow-container.canvas-right-zooming,
.flow-container.canvas-right-zooming .react-flow__pane,
.flow-container.canvas-right-zooming .react-flow__background {
cursor: ns-resize !important;
}
/* ── React Flow dark overrides ─────────────────────────────────────── */
.react-flow {
@@ -259,9 +264,18 @@ html, body, #root {
flex: 1;
}
.group-title-button {
flex: 1;
.group-title-slot {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
}
.group-title-button {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
@@ -276,8 +290,10 @@ html, body, #root {
}
.group-title-input {
flex: 1;
flex: 0 1 auto;
min-width: 0;
max-width: min(40ch, 100%);
width: auto;
height: 22px;
padding: 2px 6px;
border: 1px solid rgba(148, 163, 184, 0.45);