@pauloricca/spagbol 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -4
- package/dist/core/controller.js +4 -4
- package/dist/core/controller.js.map +1 -1
- package/dist/core/model.d.ts +2 -1
- package/dist/core/model.d.ts.map +1 -1
- package/dist/core/model.js.map +1 -1
- package/dist/editors.css +157 -0
- package/dist/react/SpagbolCanvas.d.ts.map +1 -1
- package/dist/react/SpagbolCanvas.js +106 -29
- package/dist/react/SpagbolCanvas.js.map +1 -1
- package/dist/react/SpagbolCanvasControls.d.ts.map +1 -1
- package/dist/react/SpagbolCanvasControls.js +5 -5
- package/dist/react/SpagbolCanvasControls.js.map +1 -1
- package/dist/react/SpagbolControlJoystick.d.ts +9 -0
- package/dist/react/SpagbolControlJoystick.d.ts.map +1 -0
- package/dist/react/SpagbolControlJoystick.js +111 -0
- package/dist/react/SpagbolControlJoystick.js.map +1 -0
- package/dist/react/SpagbolControlZoom.d.ts.map +1 -1
- package/dist/react/SpagbolControlZoom.js +3 -2
- package/dist/react/SpagbolControlZoom.js.map +1 -1
- package/dist/react/SpagbolCurveEditor.d.ts +45 -0
- package/dist/react/SpagbolCurveEditor.d.ts.map +1 -0
- package/dist/react/SpagbolCurveEditor.js +142 -0
- package/dist/react/SpagbolCurveEditor.js.map +1 -0
- package/dist/react/SpagbolNodeFrame.d.ts.map +1 -1
- package/dist/react/SpagbolNodeFrame.js +18 -16
- package/dist/react/SpagbolNodeFrame.js.map +1 -1
- package/dist/react/SpagbolNodeFrameTypePicker.js +1 -1
- package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -1
- package/dist/react/SpagbolSequencer.d.ts +35 -0
- package/dist/react/SpagbolSequencer.d.ts.map +1 -0
- package/dist/react/SpagbolSequencer.js +166 -0
- package/dist/react/SpagbolSequencer.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/nodeDrag.d.ts +7 -0
- package/dist/react/nodeDrag.d.ts.map +1 -0
- package/dist/react/nodeDrag.js +27 -0
- package/dist/react/nodeDrag.js.map +1 -0
- package/dist/react/zoom.d.ts +5 -0
- package/dist/react/zoom.d.ts.map +1 -0
- package/dist/react/zoom.js +19 -0
- package/dist/react/zoom.js.map +1 -0
- package/dist/styles.css +112 -41
- package/docs/language/standard-node-comparison.md +92 -0
- package/docs/language/standard-node-contracts.md +237 -0
- package/docs/language/standard-nodes.md +198 -0
- package/docs/language/ui-components.md +79 -0
- package/package.json +5 -3
- package/src/core/controller.ts +4 -4
- package/src/core/model.ts +2 -1
- package/src/react/SpagbolCanvas.tsx +121 -31
- package/src/react/SpagbolCanvasControls.tsx +2 -7
- package/src/react/SpagbolControlJoystick.tsx +147 -0
- package/src/react/SpagbolControlZoom.tsx +3 -2
- package/src/react/SpagbolCurveEditor.tsx +166 -0
- package/src/react/SpagbolNodeFrame.tsx +30 -24
- package/src/react/SpagbolNodeFrameTypePicker.tsx +1 -1
- package/src/react/SpagbolSequencer.tsx +265 -0
- package/src/react/editors.css +157 -0
- package/src/react/index.ts +4 -0
- package/src/react/nodeDrag.ts +33 -0
- package/src/react/styles.css +112 -41
- package/src/react/zoom.ts +18 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { SPAGBOL_ZOOM_BASELINE, spagbolHeaderTitleScale } from "./zoom.js";
|
|
2
|
+
import { previewNodePositions, applyNodePositions } from "./nodeDrag.js";
|
|
1
3
|
import { createPortal } from "react-dom";
|
|
2
4
|
import { SpagbolCanvasLinkControls } from "./SpagbolCanvasLinkControls.js";
|
|
3
5
|
import { SpagbolCanvasFlow } from "./SpagbolCanvasFlow.js";
|
|
4
6
|
import {
|
|
7
|
+
applyNodeChanges,
|
|
5
8
|
BaseEdge,
|
|
6
9
|
ConnectionLineType,
|
|
7
10
|
EdgeLabelRenderer,
|
|
@@ -146,6 +149,8 @@ export interface SpagbolCanvasProps extends SpagbolCanvasSlots {
|
|
|
146
149
|
controls?: SpagbolCanvasControlsOptions | false;
|
|
147
150
|
ariaLabel?: string;
|
|
148
151
|
}
|
|
152
|
+
const defaultGraphViewport = { x: 0, y: 0, zoom: SPAGBOL_ZOOM_BASELINE };
|
|
153
|
+
const emptyFlowNodes: FlowNode[] = [];
|
|
149
154
|
const emptyViews: Readonly<Record<string, SpagbolNodeView>> = Object.freeze({});
|
|
150
155
|
const emptyPresentations: Readonly<Record<string, SpagbolLinkPresentation>> =
|
|
151
156
|
Object.freeze({});
|
|
@@ -340,20 +345,31 @@ function SpagbolCanvasArea({ data, selected }: NodeProps<FlowNode>) {
|
|
|
340
345
|
onPointerDown={(event) => start(event)}
|
|
341
346
|
{...pointerHandlers}
|
|
342
347
|
>
|
|
343
|
-
{titleEditing ? <input autoFocus className="spagbol-canvas-area-title-input nodrag nopan" aria-label="Area title" value={titleDraft} onFocus={event => event.currentTarget.select()} onChange={event => setTitleDraft(event.currentTarget.value)} onPointerDown={event => event.stopPropagation()} onBlur={() => { editor.updateArea(area.id, { title: titleDraft.trim() || '
|
|
348
|
+
{titleEditing ? <input autoFocus className="spagbol-canvas-area-title-input nodrag nopan" aria-label="Area title" value={titleDraft} onFocus={event => event.currentTarget.select()} onChange={event => setTitleDraft(event.currentTarget.value)} onPointerDown={event => event.stopPropagation()} onBlur={() => { editor.updateArea(area.id, { title: titleDraft.trim() || 'Container' }); setTitleEditing(false); }} onKeyDown={event => { event.stopPropagation(); if (event.key === 'Enter') event.currentTarget.blur(); if (event.key === 'Escape') { setTitleDraft(area.title); setTitleEditing(false); } }} /> : <span className="spagbol-canvas-area-title" onDoubleClick={event => { event.stopPropagation(); if (editor.editing) { setTitleDraft(area.title); setTitleEditing(true); } }}>{area.title}</span>}
|
|
344
349
|
<div
|
|
345
350
|
className="spagbol-canvas-area-actions"
|
|
346
351
|
onPointerDown={(event) => event.stopPropagation()}
|
|
347
352
|
>
|
|
348
353
|
<button
|
|
349
|
-
|
|
350
|
-
|
|
354
|
+
type="button"
|
|
355
|
+
className="spagbol-canvas-area-lock"
|
|
356
|
+
title={area.locked ? "Unlock area membership" : "Lock area membership"}
|
|
357
|
+
aria-label={area.locked ? "Unlock area membership" : "Lock area membership"}
|
|
358
|
+
aria-pressed={area.locked === true}
|
|
351
359
|
disabled={!editor.editing}
|
|
352
|
-
|
|
360
|
+
onDoubleClick={event => event.stopPropagation()}
|
|
361
|
+
onClick={event => {
|
|
362
|
+
event.preventDefault();
|
|
363
|
+
event.stopPropagation();
|
|
364
|
+
editor.lockArea(area.id, !area.locked);
|
|
365
|
+
}}
|
|
353
366
|
>
|
|
354
|
-
|
|
367
|
+
<span className="spagbol-canvas-area-lock-icon" aria-hidden="true" />
|
|
355
368
|
</button>
|
|
356
369
|
<button
|
|
370
|
+
type="button"
|
|
371
|
+
className="spagbol-canvas-area-compact"
|
|
372
|
+
aria-pressed={area.collapsed === true}
|
|
357
373
|
title={area.collapsed ? "Expand area" : "Collapse area"}
|
|
358
374
|
aria-label={area.collapsed ? "Expand area" : "Collapse area"}
|
|
359
375
|
disabled={!editor.editing}
|
|
@@ -404,8 +420,8 @@ const SpagbolCanvasLink = memo(function SpagbolCanvasLink(props: EdgeProps<FlowL
|
|
|
404
420
|
const flow = useReactFlow();
|
|
405
421
|
const viewport = useViewport();
|
|
406
422
|
const [controlsVisible, setControlsVisible] = useState(false);
|
|
407
|
-
const
|
|
408
|
-
const showControls = props.selected &&
|
|
423
|
+
const selectedLinkId = useEditorSelector(editor, snapshot => snapshot.selection.linkIds.length === 1 ? snapshot.selection.linkIds[0] : undefined);
|
|
424
|
+
const showControls = props.selected && selectedLinkId === props.id && editor.editing && !connecting && !props.data?.virtual;
|
|
409
425
|
useEffect(() => {
|
|
410
426
|
if (!showControls) { setControlsVisible(false); return; }
|
|
411
427
|
const timer = window.setTimeout(() => setControlsVisible(true), 260);
|
|
@@ -507,16 +523,18 @@ function SpagbolCanvasInner({
|
|
|
507
523
|
const flow = useReactFlow<FlowNode, FlowLink>();
|
|
508
524
|
const flowStore = useStoreApi();
|
|
509
525
|
const nodesInitialized = useNodesInitialized();
|
|
510
|
-
|
|
526
|
+
// Only cable-property anchors need live native geometry. Subscribing to every
|
|
527
|
+
// position change otherwise causes redundant full-canvas renders during drags.
|
|
528
|
+
const flowNodes = useStore((state) => features.linkProperties ? state.nodes : emptyFlowNodes);
|
|
511
529
|
// Measurements are canvas state, not saved graph data. Retaining them keeps
|
|
512
530
|
// React Flow from hiding/reinitializing every node on each drag preview frame.
|
|
513
531
|
const measuredSizes = useRef(
|
|
514
532
|
new Map<string, { width: number; height: number }>(),
|
|
515
533
|
);
|
|
516
534
|
const [measurementRevision, setMeasurementRevision] = useState(0);
|
|
517
|
-
const [zoom, setZoom] = useState(props.defaultViewport?.zoom ??
|
|
535
|
+
const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? SPAGBOL_ZOOM_BASELINE);
|
|
518
536
|
const [settledViewport, setSettledViewport] = useState<Viewport>(
|
|
519
|
-
props.defaultViewport ??
|
|
537
|
+
props.defaultViewport ?? defaultGraphViewport,
|
|
520
538
|
);
|
|
521
539
|
const [editorSize, setEditorSize] = useState({ width: 0, height: 0 });
|
|
522
540
|
const viewportMoving = useRef(false);
|
|
@@ -543,6 +561,9 @@ function SpagbolCanvasInner({
|
|
|
543
561
|
);
|
|
544
562
|
const [picker, setPicker] = useState<Picker | null>(null);
|
|
545
563
|
const [search, setSearch] = useState("");
|
|
564
|
+
const [pickerIndex, setPickerIndex] = useState(0);
|
|
565
|
+
const pickerListId = useId();
|
|
566
|
+
const pickerList = useRef<HTMLDivElement>(null);
|
|
546
567
|
const [error, setError] = useState<string | null>(null);
|
|
547
568
|
const [drawing, setDrawing] = useState<{
|
|
548
569
|
start: Point;
|
|
@@ -559,10 +580,25 @@ function SpagbolCanvasInner({
|
|
|
559
580
|
const duplicate = useRef<DuplicateDrag | null>(null);
|
|
560
581
|
const [duplicatePreview, setDuplicatePreview] =
|
|
561
582
|
useState<DuplicateDrag | null>(null);
|
|
562
|
-
const
|
|
583
|
+
const cancelledNodeDrag = useRef(false);
|
|
584
|
+
// Position previews belong to the canvas, not the persistent document. A drag
|
|
585
|
+
// commits once, avoiding whole-document edits/validation on every pointer move.
|
|
586
|
+
const nodeDrag = useRef<ReadonlyMap<string, Point> | null>(null);
|
|
587
|
+
const [positionRevision, setPositionRevision] = useState(0);
|
|
588
|
+
const clearNodeDrag = () => {
|
|
589
|
+
nodeDrag.current = null;
|
|
590
|
+
// Force a controlled refresh on cancellation, even though the saved position
|
|
591
|
+
// never changed: React Flow currently holds the transient pointer position.
|
|
592
|
+
setPositionRevision(value => value + 1);
|
|
593
|
+
};
|
|
594
|
+
|
|
563
595
|
useEffect(() => {
|
|
596
|
+
if (!snapshot.gesture && nodeDrag.current) {
|
|
597
|
+
cancelledNodeDrag.current = true;
|
|
598
|
+
clearNodeDrag();
|
|
599
|
+
}
|
|
564
600
|
if (!snapshot.gesture && duplicate.current) {
|
|
565
|
-
|
|
601
|
+
cancelledNodeDrag.current = true;
|
|
566
602
|
duplicate.current = null;
|
|
567
603
|
setDuplicatePreview(null);
|
|
568
604
|
}
|
|
@@ -612,7 +648,7 @@ function SpagbolCanvasInner({
|
|
|
612
648
|
if (phase === 'start') {
|
|
613
649
|
const area = editor.graph.areas.find(area => area.id === id);
|
|
614
650
|
if (!area) return;
|
|
615
|
-
|
|
651
|
+
cancelledNodeDrag.current = false;
|
|
616
652
|
const fragment = copyFragment(editor.graph, [], [id]);
|
|
617
653
|
duplicate.current = { namespace: createId('preview'), fragment, nodeId: '', origin: area.position, positions: new Map(fragment.nodes.map(node => [node.id, node.position])), areaPositions: new Map(fragment.areas.map(area => [area.id, area.position])), external };
|
|
618
654
|
setDuplicatePreview(duplicate.current);
|
|
@@ -723,7 +759,7 @@ function SpagbolCanvasInner({
|
|
|
723
759
|
(node): FlowNode => ({
|
|
724
760
|
id: nodeFlowId(node.id),
|
|
725
761
|
type: "spagbolNode",
|
|
726
|
-
position: node.position,
|
|
762
|
+
position: nodeDrag.current?.get(node.id) ?? node.position,
|
|
727
763
|
selected: snapshot.selection.nodeIds.includes(node.id),
|
|
728
764
|
hidden: collapsed.nodes.has(node.id),
|
|
729
765
|
draggable:
|
|
@@ -742,6 +778,7 @@ function SpagbolCanvasInner({
|
|
|
742
778
|
deletable: false,
|
|
743
779
|
dragHandle: ".spagbol-canvas-drag-handle",
|
|
744
780
|
data: {
|
|
781
|
+
positionRevision,
|
|
745
782
|
node: duplicatePresentation?.originalNodes.get(node.id) ?? node,
|
|
746
783
|
projected: collapsed.projected.has(node.id),
|
|
747
784
|
stationaryOriginal: duplicatePresentation?.originalNodes.has(
|
|
@@ -770,6 +807,7 @@ function SpagbolCanvasInner({
|
|
|
770
807
|
],
|
|
771
808
|
[
|
|
772
809
|
renderGraph,
|
|
810
|
+
positionRevision,
|
|
773
811
|
duplicatePresentation,
|
|
774
812
|
snapshot.selection,
|
|
775
813
|
snapshot.mode,
|
|
@@ -836,6 +874,7 @@ function SpagbolCanvasInner({
|
|
|
836
874
|
collapsed,
|
|
837
875
|
flow,
|
|
838
876
|
measurementKey,
|
|
877
|
+
measurementRevision,
|
|
839
878
|
settledViewport.zoom,
|
|
840
879
|
editorSize,
|
|
841
880
|
props.panBounds,
|
|
@@ -1181,7 +1220,17 @@ function SpagbolCanvasInner({
|
|
|
1181
1220
|
setDuplicatePreview(next);
|
|
1182
1221
|
return;
|
|
1183
1222
|
}
|
|
1184
|
-
if (
|
|
1223
|
+
if (cancelledNodeDrag.current) return;
|
|
1224
|
+
if (positions.length && nodeDrag.current) {
|
|
1225
|
+
const next = previewNodePositions(nodeDrag.current, positions);
|
|
1226
|
+
if (next !== nodeDrag.current) nodeDrag.current = next;
|
|
1227
|
+
// This is a controlled React Flow: recording positions in a ref does not
|
|
1228
|
+
// update native geometry. Publish only the transient node presentation;
|
|
1229
|
+
// the graph, validation and history still change once, on drop.
|
|
1230
|
+
const state = flowStore.getState();
|
|
1231
|
+
state.setNodes(applyNodeChanges(positions, state.nodes));
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1185
1234
|
if (positions.length)
|
|
1186
1235
|
editor.edit("Move nodes", "layout", (graph) => {
|
|
1187
1236
|
for (const change of positions)
|
|
@@ -1212,6 +1261,7 @@ function SpagbolCanvasInner({
|
|
|
1212
1261
|
if (!editor.editing) return;
|
|
1213
1262
|
const bounds = root.current?.getBoundingClientRect();
|
|
1214
1263
|
setSearch("");
|
|
1264
|
+
setPickerIndex(0);
|
|
1215
1265
|
setPicker({
|
|
1216
1266
|
point: flow.screenToFlowPosition(client),
|
|
1217
1267
|
screen: {
|
|
@@ -1362,7 +1412,8 @@ function SpagbolCanvasInner({
|
|
|
1362
1412
|
const command = event.metaKey || event.ctrlKey,
|
|
1363
1413
|
key = event.key.toLowerCase();
|
|
1364
1414
|
if (key === "escape") {
|
|
1365
|
-
if (duplicate.current)
|
|
1415
|
+
if (duplicate.current || nodeDrag.current) cancelledNodeDrag.current = true;
|
|
1416
|
+
if (nodeDrag.current) clearNodeDrag();
|
|
1366
1417
|
duplicate.current = null;
|
|
1367
1418
|
setDuplicatePreview(null);
|
|
1368
1419
|
editor.cancelGesture();
|
|
@@ -1411,7 +1462,7 @@ function SpagbolCanvasInner({
|
|
|
1411
1462
|
}
|
|
1412
1463
|
if (command && key === "0") {
|
|
1413
1464
|
event.preventDefault();
|
|
1414
|
-
void flow.zoomTo(
|
|
1465
|
+
void flow.zoomTo(SPAGBOL_ZOOM_BASELINE);
|
|
1415
1466
|
return;
|
|
1416
1467
|
}
|
|
1417
1468
|
if (key === "delete" || key === "backspace") {
|
|
@@ -1459,13 +1510,18 @@ function SpagbolCanvasInner({
|
|
|
1459
1510
|
const touch = event.changedTouches[0];
|
|
1460
1511
|
return touch ? { x: touch.clientX, y: touch.clientY } : undefined;
|
|
1461
1512
|
}
|
|
1462
|
-
const availableNodes = [...editor.nodes.list().filter(definition => !props.nodeKinds || props.nodeKinds.includes(definition.kind)), ...(features.areas && !picker?.origin && !picker?.linkId ? [{ kind: 'spagbol.area', label: '
|
|
1513
|
+
const availableNodes = [...editor.nodes.list().filter(definition => !props.nodeKinds || props.nodeKinds.includes(definition.kind)), ...(features.areas && !picker?.origin && !picker?.linkId ? [{ kind: 'spagbol.area', label: 'Container', createData: () => ({}), ports: () => [] } as import('../core/index.js').NodeDefinition] : [])]
|
|
1463
1514
|
.filter(definition => snapshot.path.length > 0 || ![spagbolGroupKinds.inputs, spagbolGroupKinds.outputs].includes(definition.kind as typeof spagbolGroupKinds.inputs))
|
|
1464
1515
|
.filter((definition) =>
|
|
1465
1516
|
`${definition.label} ${definition.category ?? ""} ${definition.keywords?.join(" ") ?? ""}`
|
|
1466
1517
|
.toLowerCase()
|
|
1467
1518
|
.includes(search.toLowerCase()),
|
|
1468
1519
|
);
|
|
1520
|
+
const selectedPickerIndex = Math.min(pickerIndex, Math.max(0, availableNodes.length - 1));
|
|
1521
|
+
const selectedPickerNode = availableNodes[selectedPickerIndex];
|
|
1522
|
+
useEffect(() => {
|
|
1523
|
+
if (picker) pickerList.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
1524
|
+
}, [picker, selectedPickerIndex, search]);
|
|
1469
1525
|
|
|
1470
1526
|
return (
|
|
1471
1527
|
<GraphContext.Provider value={context}>
|
|
@@ -1475,7 +1531,7 @@ function SpagbolCanvasInner({
|
|
|
1475
1531
|
style={
|
|
1476
1532
|
{
|
|
1477
1533
|
...props.style,
|
|
1478
|
-
"--spagbol-title-scale": zoom
|
|
1534
|
+
"--spagbol-title-scale": spagbolHeaderTitleScale(zoom),
|
|
1479
1535
|
"--spagbol-canvas-height": `${editorSize.height}px`,
|
|
1480
1536
|
} as CSSProperties
|
|
1481
1537
|
}
|
|
@@ -1539,7 +1595,8 @@ function SpagbolCanvasInner({
|
|
|
1539
1595
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
1540
1596
|
}}
|
|
1541
1597
|
onPointerCancel={() => {
|
|
1542
|
-
if (duplicate.current)
|
|
1598
|
+
if (duplicate.current || nodeDrag.current) cancelledNodeDrag.current = true;
|
|
1599
|
+
if (nodeDrag.current) clearNodeDrag();
|
|
1543
1600
|
duplicate.current = null;
|
|
1544
1601
|
setDuplicatePreview(null);
|
|
1545
1602
|
setDrawing(null);
|
|
@@ -1569,10 +1626,11 @@ function SpagbolCanvasInner({
|
|
|
1569
1626
|
}}
|
|
1570
1627
|
onNodeDragStart={(event, node) => {
|
|
1571
1628
|
if (!node.data.node) return;
|
|
1572
|
-
|
|
1629
|
+
cancelledNodeDrag.current = false;
|
|
1573
1630
|
editor.beginGesture(
|
|
1574
1631
|
event.altKey ? "Duplicate nodes" : "Move nodes",
|
|
1575
1632
|
);
|
|
1633
|
+
if (!event.altKey) nodeDrag.current = new Map();
|
|
1576
1634
|
if (event.altKey) {
|
|
1577
1635
|
const selection = editor.getSnapshot().selection;
|
|
1578
1636
|
const ids = selection.nodeIds.includes(node.data.node.id)
|
|
@@ -1594,11 +1652,13 @@ function SpagbolCanvasInner({
|
|
|
1594
1652
|
}
|
|
1595
1653
|
}}
|
|
1596
1654
|
onNodeDragStop={(_event, node) => {
|
|
1655
|
+
const positions = nodeDrag.current;
|
|
1656
|
+
if (positions) clearNodeDrag();
|
|
1597
1657
|
const current = duplicate.current;
|
|
1598
1658
|
duplicate.current = null;
|
|
1599
1659
|
setDuplicatePreview(null);
|
|
1600
|
-
if (
|
|
1601
|
-
|
|
1660
|
+
if (cancelledNodeDrag.current) {
|
|
1661
|
+
cancelledNodeDrag.current = false;
|
|
1602
1662
|
editor.cancelGesture();
|
|
1603
1663
|
editor.select(editor.getSnapshot().selection);
|
|
1604
1664
|
return;
|
|
@@ -1620,6 +1680,8 @@ function SpagbolCanvasInner({
|
|
|
1620
1680
|
return;
|
|
1621
1681
|
}
|
|
1622
1682
|
}
|
|
1683
|
+
if (positions?.size)
|
|
1684
|
+
editor.edit("Move nodes", "layout", graph => applyNodePositions(graph, positions));
|
|
1623
1685
|
editor.commitGesture();
|
|
1624
1686
|
}}
|
|
1625
1687
|
onConnectStart={(_event, params) => {
|
|
@@ -1745,7 +1807,7 @@ function SpagbolCanvasInner({
|
|
|
1745
1807
|
stroke: editor.modes.get(activeMode)?.color,
|
|
1746
1808
|
strokeWidth: 2,
|
|
1747
1809
|
}}
|
|
1748
|
-
defaultViewport={props.defaultViewport}
|
|
1810
|
+
defaultViewport={props.defaultViewport ?? defaultGraphViewport}
|
|
1749
1811
|
onInit={instance => props.onInit?.(instance as unknown as ReactFlowInstance)}
|
|
1750
1812
|
onMoveStart={() => {
|
|
1751
1813
|
viewportMoving.current = true;
|
|
@@ -1807,7 +1869,9 @@ function SpagbolCanvasInner({
|
|
|
1807
1869
|
onPointerDown={(event) => event.stopPropagation()}
|
|
1808
1870
|
>
|
|
1809
1871
|
<div className="spagbol-canvas-picker-heading">
|
|
1810
|
-
|
|
1872
|
+
<span className="spagbol-canvas-picker-title">
|
|
1873
|
+
{picker.linkId ? "Insert a node" : "Add a node"}
|
|
1874
|
+
</span>
|
|
1811
1875
|
<button
|
|
1812
1876
|
aria-label="Cancel node creation"
|
|
1813
1877
|
onClick={() => setPicker(null)}
|
|
@@ -1818,20 +1882,46 @@ function SpagbolCanvasInner({
|
|
|
1818
1882
|
<input
|
|
1819
1883
|
autoFocus
|
|
1820
1884
|
aria-label="Search node types"
|
|
1885
|
+
role="combobox"
|
|
1886
|
+
aria-expanded="true"
|
|
1887
|
+
aria-autocomplete="list"
|
|
1888
|
+
aria-controls={pickerListId}
|
|
1889
|
+
aria-activedescendant={selectedPickerNode ? `${pickerListId}-${selectedPickerIndex}` : undefined}
|
|
1821
1890
|
placeholder="Search nodes…"
|
|
1822
1891
|
value={search}
|
|
1823
|
-
onChange={(event) =>
|
|
1892
|
+
onChange={(event) => {
|
|
1893
|
+
setSearch(event.target.value);
|
|
1894
|
+
setPickerIndex(0);
|
|
1895
|
+
}}
|
|
1824
1896
|
onKeyDown={(event) => {
|
|
1825
1897
|
event.stopPropagation();
|
|
1826
|
-
if (event.
|
|
1827
|
-
if (event.key === "
|
|
1828
|
-
|
|
1898
|
+
if (event.nativeEvent.isComposing) return;
|
|
1899
|
+
if (event.key === "ArrowDown") {
|
|
1900
|
+
event.preventDefault();
|
|
1901
|
+
setPickerIndex(Math.min(selectedPickerIndex + 1, Math.max(0, availableNodes.length - 1)));
|
|
1902
|
+
} else if (event.key === "ArrowUp") {
|
|
1903
|
+
event.preventDefault();
|
|
1904
|
+
setPickerIndex(Math.max(0, selectedPickerIndex - 1));
|
|
1905
|
+
} else if (event.key === "Escape") {
|
|
1906
|
+
event.preventDefault();
|
|
1907
|
+
setPicker(null);
|
|
1908
|
+
} else if (event.key === "Enter") {
|
|
1909
|
+
event.preventDefault();
|
|
1910
|
+
if (selectedPickerNode) chooseNode(selectedPickerNode.kind);
|
|
1911
|
+
}
|
|
1829
1912
|
}}
|
|
1830
1913
|
/>
|
|
1831
|
-
<div className="spagbol-canvas-picker-list">
|
|
1832
|
-
{availableNodes.map((definition) => (
|
|
1914
|
+
<div ref={pickerList} id={pickerListId} className="spagbol-canvas-picker-list nowheel" role="listbox" aria-label="Node types">
|
|
1915
|
+
{availableNodes.map((definition, index) => (
|
|
1833
1916
|
<button
|
|
1834
1917
|
key={definition.kind}
|
|
1918
|
+
id={`${pickerListId}-${index}`}
|
|
1919
|
+
type="button"
|
|
1920
|
+
role="option"
|
|
1921
|
+
aria-selected={index === selectedPickerIndex}
|
|
1922
|
+
tabIndex={-1}
|
|
1923
|
+
onPointerMove={() => setPickerIndex(index)}
|
|
1924
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1835
1925
|
onClick={() => chooseNode(definition.kind)}
|
|
1836
1926
|
>
|
|
1837
1927
|
<span>{definition.label}</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useReactFlow } from "@xyflow/react";
|
|
2
|
-
import { isGroupBoundary } from "../core/index.js";
|
|
2
|
+
import { graphAt, isGroupBoundary } from "../core/index.js";
|
|
3
3
|
import { useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import { SpagbolControlZoom } from "./SpagbolControlZoom.js";
|
|
5
5
|
import { SpagbolCanvasMenu } from "./SpagbolCanvasMenu.js";
|
|
@@ -154,11 +154,6 @@ export function SpagbolCanvasActions(options: SpagbolCanvasControlsOptions) {
|
|
|
154
154
|
void invoke(action, fromMenu);
|
|
155
155
|
}}
|
|
156
156
|
>
|
|
157
|
-
{action.checked !== undefined && fromMenu ? (
|
|
158
|
-
<span className="spagbol-canvas-menu-check" aria-hidden="true">
|
|
159
|
-
{action.checked ? "✓" : ""}
|
|
160
|
-
</span>
|
|
161
|
-
) : null}
|
|
162
157
|
{action.label}
|
|
163
158
|
</button>
|
|
164
159
|
);
|
|
@@ -237,7 +232,7 @@ export function SpagbolCanvasControls(options: SpagbolCanvasControlsOptions) {
|
|
|
237
232
|
}
|
|
238
233
|
onClick={() => editor.navigate(snapshot.path.slice(0, index + 1))}
|
|
239
234
|
>
|
|
240
|
-
{id}
|
|
235
|
+
{graphAt(snapshot.document, snapshot.path.slice(0, index)).nodes.find(node => node.id === id)?.label?.trim() || 'Group'}
|
|
241
236
|
</button>
|
|
242
237
|
))}
|
|
243
238
|
</nav>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useEffect, useRef, type PointerEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SpagbolControlJoystickProps {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
elasticity?: number;
|
|
7
|
+
className?: string;
|
|
8
|
+
onChange: (x: number, y: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SpagbolControlJoystick({ x, y, elasticity = 0, onChange, className = '' }: SpagbolControlJoystickProps) {
|
|
12
|
+
const changeRef = useRef(onChange); changeRef.current = onChange;
|
|
13
|
+
const dragPointerRef = useRef<number | null>(null);
|
|
14
|
+
const returnFrameRef = useRef<number | null>(null);
|
|
15
|
+
const returnTimeRef = useRef(0);
|
|
16
|
+
const elasticityRef = useRef(elasticity);
|
|
17
|
+
const normalizedX = clamp(x, 0, 1);
|
|
18
|
+
const normalizedY = clamp(y, 0, 1);
|
|
19
|
+
const positionRef = useRef({ x: normalizedX, y: normalizedY });
|
|
20
|
+
|
|
21
|
+
elasticityRef.current = elasticity;
|
|
22
|
+
if (dragPointerRef.current === null && returnFrameRef.current === null) {
|
|
23
|
+
positionRef.current = { x: normalizedX, y: normalizedY };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
useEffect(() => () => {
|
|
27
|
+
if (returnFrameRef.current !== null) {
|
|
28
|
+
window.cancelAnimationFrame(returnFrameRef.current);
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
function stopReturn() {
|
|
33
|
+
if (returnFrameRef.current !== null) {
|
|
34
|
+
window.cancelAnimationFrame(returnFrameRef.current);
|
|
35
|
+
returnFrameRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function startReturn() {
|
|
40
|
+
stopReturn();
|
|
41
|
+
if (!(elasticityRef.current > 0)) return;
|
|
42
|
+
returnTimeRef.current = performance.now();
|
|
43
|
+
|
|
44
|
+
function stepReturn(timestamp: number) {
|
|
45
|
+
if (dragPointerRef.current !== null || !(elasticityRef.current > 0)) {
|
|
46
|
+
returnFrameRef.current = null;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const elapsedSeconds = Math.min(0.05, Math.max(0, (timestamp - returnTimeRef.current) / 1000));
|
|
51
|
+
returnTimeRef.current = timestamp;
|
|
52
|
+
const current = positionRef.current;
|
|
53
|
+
const deltaX = 0.5 - current.x;
|
|
54
|
+
const deltaY = 0.5 - current.y;
|
|
55
|
+
const distance = Math.hypot(deltaX, deltaY);
|
|
56
|
+
const travel = elasticityRef.current * elapsedSeconds;
|
|
57
|
+
|
|
58
|
+
if (distance <= Math.max(0.0001, travel)) {
|
|
59
|
+
positionRef.current = { x: 0.5, y: 0.5 };
|
|
60
|
+
returnFrameRef.current = null;
|
|
61
|
+
changeRef.current(0.5, 0.5);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const scale = travel / distance;
|
|
66
|
+
const next = {
|
|
67
|
+
x: current.x + deltaX * scale,
|
|
68
|
+
y: current.y + deltaY * scale,
|
|
69
|
+
};
|
|
70
|
+
positionRef.current = next;
|
|
71
|
+
changeRef.current(next.x, next.y);
|
|
72
|
+
returnFrameRef.current = window.requestAnimationFrame(stepReturn);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
returnFrameRef.current = window.requestAnimationFrame(stepReturn);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateFromPointer(event: PointerEvent<HTMLDivElement>) {
|
|
79
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
80
|
+
const nextX = (event.clientX - bounds.left) / Math.max(1, bounds.width);
|
|
81
|
+
const nextY = 1 - ((event.clientY - bounds.top) / Math.max(1, bounds.height));
|
|
82
|
+
const next = { x: clamp(nextX, 0, 1), y: clamp(nextY, 0, 1) };
|
|
83
|
+
positionRef.current = next;
|
|
84
|
+
onChange(next.x, next.y);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className={`spagbol-control-joystick ${className} nodrag nopan`}
|
|
90
|
+
role="group"
|
|
91
|
+
aria-label="Joystick"
|
|
92
|
+
aria-valuetext={`X ${normalizedX.toFixed(3)}, Y ${normalizedY.toFixed(3)}`}
|
|
93
|
+
onPointerDown={(event) => {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
event.stopPropagation();
|
|
96
|
+
if (!event.isPrimary || event.button !== 0) return;
|
|
97
|
+
stopReturn();
|
|
98
|
+
dragPointerRef.current = event.pointerId;
|
|
99
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
100
|
+
updateFromPointer(event);
|
|
101
|
+
}}
|
|
102
|
+
onPointerMove={(event) => {
|
|
103
|
+
if (dragPointerRef.current !== event.pointerId) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
updateFromPointer(event);
|
|
107
|
+
}}
|
|
108
|
+
onPointerUp={(event) => {
|
|
109
|
+
event.stopPropagation();
|
|
110
|
+
if (dragPointerRef.current === event.pointerId) {
|
|
111
|
+
dragPointerRef.current = null;
|
|
112
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
113
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
114
|
+
}
|
|
115
|
+
startReturn();
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
onPointerCancel={(event) => {
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
if (dragPointerRef.current === event.pointerId) {
|
|
121
|
+
dragPointerRef.current = null;
|
|
122
|
+
startReturn();
|
|
123
|
+
}
|
|
124
|
+
}}
|
|
125
|
+
onLostPointerCapture={(event) => {
|
|
126
|
+
if (dragPointerRef.current === event.pointerId) {
|
|
127
|
+
dragPointerRef.current = null;
|
|
128
|
+
startReturn();
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
onClick={(event) => event.stopPropagation()}
|
|
132
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
133
|
+
>
|
|
134
|
+
<span
|
|
135
|
+
className="spagbol-control-joystick-handle"
|
|
136
|
+
style={{
|
|
137
|
+
left: `calc(${normalizedX} * (100% - var(--joystick-handle-size)))`,
|
|
138
|
+
top: `calc(${1 - normalizedY} * (100% - var(--joystick-handle-size)))`,
|
|
139
|
+
}}
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, Number.isFinite(value) ? value : 0.5)); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SPAGBOL_ZOOM_BASELINE, spagbolZoomPercentage } from "./zoom.js";
|
|
1
2
|
import { ControlButton, Controls, useReactFlow } from "@xyflow/react";
|
|
2
3
|
import type { ReactNode } from "react";
|
|
3
4
|
|
|
@@ -17,10 +18,10 @@ export function SpagbolControlZoom({
|
|
|
17
18
|
layout = "flow",
|
|
18
19
|
}: SpagbolControlZoomProps) {
|
|
19
20
|
const flow = useReactFlow();
|
|
20
|
-
const percentage =
|
|
21
|
+
const percentage = spagbolZoomPercentage(zoom);
|
|
21
22
|
const reset = () => {
|
|
22
23
|
if (onReset) onReset();
|
|
23
|
-
else void flow.zoomTo(
|
|
24
|
+
else void flow.zoomTo(SPAGBOL_ZOOM_BASELINE);
|
|
24
25
|
};
|
|
25
26
|
const label = `Reset zoom to 100% (currently ${percentage})`;
|
|
26
27
|
if (layout === "flow")
|