@miadi/stateloom-web 0.1.0 → 0.1.2
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/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +4 -4
- package/server/.next/prerender-manifest.json +3 -3
- package/server/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/server/.next/server/app/_global-error/page.js +1 -1
- package/server/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/server/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/server/.next/server/app/_not-found/page.js +1 -1
- package/server/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js +3 -2
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page/build-manifest.json +2 -2
- package/server/.next/server/app/page.js +1 -1
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +8 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__2955c0d8._.js +3 -0
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__b4e7f753._.js → [root-of-the-server]__385c85a7._.js} +2 -2
- package/server/.next/server/chunks/ssr/[root-of-the-server]__a977f174._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__bfd16096._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +4 -4
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__9cafe12a._.js → [root-of-the-server]__f25b51be._.js} +2 -2
- package/server/.next/server/middleware-build-manifest.js +2 -2
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/server/server-reference-manifest.js +1 -1
- package/server/.next/server/server-reference-manifest.json +1 -1
- package/server/.next/static/chunks/0a0f393b77d3422b.css +2 -0
- package/server/.next/static/chunks/{8a03c0d2436d4c0f.js → 662d1b0ce457c16b.js} +1 -1
- package/server/.next/static/chunks/aea5b1716c68391c.js +1 -0
- package/server/.next/static/chunks/e29b67abadc7c68a.js +10 -0
- package/server/.next/static/chunks/{turbopack-8f57da603e40457d.js → turbopack-34fc7de1568e2a7f.js} +1 -1
- package/server/package-lock.json +38 -5
- package/server/package.json +2 -1
- package/server/src/app/api/file/route.ts +20 -6
- package/server/src/app/api/watch/route.ts +11 -3
- package/server/src/app/globals.css +7 -64
- package/server/src/app/layout.tsx +3 -0
- package/server/src/components/BridgeProvider.tsx +10 -3
- package/server/src/components/Canvas.tsx +78 -1325
- package/server/src/components/DocSwitcher.tsx +84 -0
- package/server/src/components/SettingsPanel.tsx +11 -6
- package/server/src/components/SocketBridgeProvider.tsx +11 -4
- package/server/src/components/Toolbar.tsx +11 -3
- package/server/src/lib/docParam.ts +51 -0
- package/server/src/lib/layoutMemory.ts +20 -2
- package/server/src/lib/projectFile.ts +68 -1
- package/server/src/types/definition.ts +12 -2
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__0a18d740._.js +0 -8
- package/server/.next/server/chunks/ssr/[root-of-the-server]__517c9b38._.js +0 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__632c82d7._.js +0 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__ecf19999._.js +0 -3
- package/server/.next/static/chunks/367d28d76d2be05f.js +0 -10
- package/server/.next/static/chunks/b6bd7719e3445512.css +0 -1
- /package/server/.next/static/{GkITWCpEKNsMrqxlkqzGB → Q7HQy4udl4A_DaAl9bdUt}/_buildManifest.js +0 -0
- /package/server/.next/static/{GkITWCpEKNsMrqxlkqzGB → Q7HQy4udl4A_DaAl9bdUt}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{GkITWCpEKNsMrqxlkqzGB → Q7HQy4udl4A_DaAl9bdUt}/_ssgManifest.js +0 -0
|
@@ -1,139 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from "@miadi/stateloom-react";
|
|
20
|
-
import type { Glyph, Viewport } from "@miadi/stateloom-react";
|
|
3
|
+
/**
|
|
4
|
+
* The designer's board — now a binding rather than an implementation.
|
|
5
|
+
*
|
|
6
|
+
* Every pixel below used to live here: 1,300 lines of gesture bookkeeping,
|
|
7
|
+
* edge routing, chip settling and touch heuristics, all of it reaching into
|
|
8
|
+
* `useDesignerStore` for its truth. Forgewright then wanted the same board and
|
|
9
|
+
* had no way to have it, because the board and the store were one object.
|
|
10
|
+
*
|
|
11
|
+
* So the surface moved out to `@miadi/stateloom-canvas`, prop-driven and
|
|
12
|
+
* store-blind, and what remains here is the only part that was ever specific to
|
|
13
|
+
* this application: which store field answers which prop. The behaviour is
|
|
14
|
+
* unchanged — same wheel, same pinch, same held-press menu, same arrows — and
|
|
15
|
+
* anything that improves in the package now reaches both applications at once.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { StateMachineCanvas } from "@miadi/stateloom-canvas";
|
|
21
19
|
import { useDesignerStore } from "@/store/useDesignerStore";
|
|
22
|
-
import type { ContextMenuState } from "@/store/useDesignerStore";
|
|
23
20
|
import { CANVAS_PINCH_OWNER } from "@/lib/gestureOwner";
|
|
24
21
|
import { CANVAS_SVG_ID } from "@/lib/exportImage";
|
|
25
22
|
import { useLayoutMemory } from "@/lib/layoutMemory";
|
|
26
|
-
import type { StatePosition, StateDef } from "@/types/definition";
|
|
27
|
-
|
|
28
|
-
interface DragState {
|
|
29
|
-
name: string;
|
|
30
|
-
/** Which pointer owns this drag — a second finger must not end it. */
|
|
31
|
-
pointerId: number;
|
|
32
|
-
startX: number;
|
|
33
|
-
startY: number;
|
|
34
|
-
origX: number;
|
|
35
|
-
origY: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** A pan in progress: where the pointer went down and where the view was then. */
|
|
39
|
-
interface PanState {
|
|
40
|
-
pointerId: number;
|
|
41
|
-
startX: number;
|
|
42
|
-
startY: number;
|
|
43
|
-
origX: number;
|
|
44
|
-
origY: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Two fingers steering the view at once. Everything is measured against the
|
|
49
|
-
* frame the gesture *started* in — the distance, the midpoint, the viewport —
|
|
50
|
-
* so the transform is recomputed from scratch on every move instead of
|
|
51
|
-
* accumulating, and a pinch can never drift away from the fingers holding it.
|
|
52
|
-
*/
|
|
53
|
-
interface PinchState {
|
|
54
|
-
a: number;
|
|
55
|
-
b: number;
|
|
56
|
-
/** Screen distance between the fingers when the pinch began. Never zero. */
|
|
57
|
-
startDist: number;
|
|
58
|
-
/** Canvas-relative midpoint when it began — the point the zoom is anchored to. */
|
|
59
|
-
startMidX: number;
|
|
60
|
-
startMidY: number;
|
|
61
|
-
start: Viewport;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** A finger resting still, on its way to becoming the context menu. */
|
|
65
|
-
interface LongPressState {
|
|
66
|
-
pointerId: number;
|
|
67
|
-
x: number;
|
|
68
|
-
y: number;
|
|
69
|
-
target: ContextMenuState["target"];
|
|
70
|
-
timer: ReturnType<typeof setTimeout>;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Wheel notches arrive in three units; normalise them to pixels. */
|
|
74
|
-
const WHEEL_LINE_HEIGHT = 16;
|
|
75
|
-
const WHEEL_PAGE_HEIGHT = 400;
|
|
76
|
-
|
|
77
|
-
/** How long a finger must rest in place before the press becomes a menu. */
|
|
78
|
-
const LONG_PRESS_MS = 500;
|
|
79
|
-
/**
|
|
80
|
-
* How far a finger may wander and still count as resting. It doubles as the
|
|
81
|
-
* line between a pan and a tap: a fingertip never lands perfectly still, and a
|
|
82
|
-
* one-pixel tremor must not swallow the tap that clears the selection.
|
|
83
|
-
*/
|
|
84
|
-
const TOUCH_SLOP_PX = 8;
|
|
85
|
-
/** Screen-space cuff added around a box so a fingertip (~9mm) can find it. */
|
|
86
|
-
const TOUCH_TARGET_PAD_PX = 10;
|
|
87
|
-
/** Screen-space width of the invisible ribbon that catches a tap on an edge. */
|
|
88
|
-
const TOUCH_EDGE_STROKE_PX = 28;
|
|
89
|
-
|
|
90
|
-
function wheelPixels(delta: number, mode: number): number {
|
|
91
|
-
if (mode === 1) return delta * WHEEL_LINE_HEIGHT;
|
|
92
|
-
if (mode === 2) return delta * WHEEL_PAGE_HEIGHT;
|
|
93
|
-
return delta;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Space is a pan modifier on the canvas and a plain space bar everywhere else —
|
|
98
|
-
* a name being typed in a panel, or a focused button waiting to be pressed,
|
|
99
|
-
* must never make the board slide.
|
|
100
|
-
*/
|
|
101
|
-
function isTypingTarget(node: EventTarget | null): boolean {
|
|
102
|
-
const el = node as HTMLElement | null;
|
|
103
|
-
if (!el || typeof el.tagName !== "string") return false;
|
|
104
|
-
if (el.isContentEditable) return true;
|
|
105
|
-
return ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "OPTION"].includes(el.tagName);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* The trigger mark on an event chip: a check for a confirmation, chevrons for
|
|
110
|
-
* an advance, two bars for a pause. Six chips reading `advance_context` are six
|
|
111
|
-
* identical words until something in front of them differs.
|
|
112
|
-
*
|
|
113
|
-
* Drawn from the protocol's glyph data in a 24-unit box, scaled where it
|
|
114
|
-
* stands — the same paths the exporter writes, so a picture taken of the board
|
|
115
|
-
* carries the marks the board showed.
|
|
116
|
-
*/
|
|
117
|
-
function TriggerGlyph({ mark, x, y, color }: { mark: Glyph; x: number; y: number; color: string }) {
|
|
118
|
-
return (
|
|
119
|
-
<g
|
|
120
|
-
transform={`translate(${x} ${y}) scale(${GLYPH_SIZE / 24})`}
|
|
121
|
-
fill="none"
|
|
122
|
-
stroke={color}
|
|
123
|
-
strokeWidth={mark.strokeWidth}
|
|
124
|
-
strokeLinecap="round"
|
|
125
|
-
strokeLinejoin="round"
|
|
126
|
-
className="pointer-events-none"
|
|
127
|
-
>
|
|
128
|
-
{(mark.circles ?? []).map((c, i) => (
|
|
129
|
-
<circle key={`c${i}`} cx={c.cx} cy={c.cy} r={c.r} fill={c.filled ? color : "none"} />
|
|
130
|
-
))}
|
|
131
|
-
{mark.paths.map((d, i) => (
|
|
132
|
-
<path key={`p${i}`} d={d} />
|
|
133
|
-
))}
|
|
134
|
-
</g>
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
23
|
|
|
138
24
|
export default function Canvas() {
|
|
139
25
|
const definition = useDesignerStore((s) => s.definition);
|
|
@@ -153,1213 +39,80 @@ export default function Canvas() {
|
|
|
153
39
|
const redo = useDesignerStore((s) => s.redo);
|
|
154
40
|
const errors = useDesignerStore((s) => s.errors);
|
|
155
41
|
const navigationPath = useDesignerStore((s) => s.navigationPath);
|
|
156
|
-
const currentParent = useDesignerStore((s) => s.currentParent);
|
|
157
42
|
const navigateInto = useDesignerStore((s) => s.navigateInto);
|
|
158
43
|
const navigateUp = useDesignerStore((s) => s.navigateUp);
|
|
159
|
-
const getCurrentChildren = useDesignerStore((s) => s.getCurrentChildren);
|
|
160
44
|
const activeStates = useDesignerStore((s) => s.activeStates);
|
|
161
45
|
const viewport = useDesignerStore((s) => s.viewport);
|
|
162
46
|
const setViewport = useDesignerStore((s) => s.setViewport);
|
|
163
47
|
|
|
164
|
-
const svgRef = useRef<SVGSVGElement>(null);
|
|
165
|
-
const [drag, setDrag] = useState<DragState | null>(null);
|
|
166
|
-
// The same truth as `drag`, readable by a handler that may run before the
|
|
167
|
-
// render carrying it — a second finger, or a lift, can arrive inside the frame
|
|
168
|
-
// the press started in, and must still know which pointer owns the box.
|
|
169
|
-
const dragRef = useRef<DragState | null>(null);
|
|
170
|
-
const applyDrag = useCallback((next: DragState | null) => {
|
|
171
|
-
dragRef.current = next;
|
|
172
|
-
setDrag(next);
|
|
173
|
-
}, []);
|
|
174
|
-
const [eventPicker, setEventPicker] = useState<{ stateName: string; targetName: string; x: number; y: number } | null>(null);
|
|
175
|
-
|
|
176
|
-
// Navigation. `spaceHeld` / `panning` drive the cursor (they must re-render);
|
|
177
|
-
// the refs carry the same truth into native listeners and pointer handlers
|
|
178
|
-
// that would otherwise close over a stale value.
|
|
179
|
-
const spaceRef = useRef(false);
|
|
180
|
-
const [spaceHeld, setSpaceHeld] = useState(false);
|
|
181
|
-
const panRef = useRef<PanState | null>(null);
|
|
182
|
-
const [panning, setPanning] = useState(false);
|
|
183
|
-
// A pan that actually moved must not end as a click that clears the selection.
|
|
184
|
-
const panMovedRef = useRef(false);
|
|
185
|
-
// Who holds each live pointer, keyed by id, so it can be released again. A map
|
|
186
|
-
// rather than a single slot: with two fingers down, one box can be holding the
|
|
187
|
-
// first while the surface holds the second, and releasing the wrong one strands
|
|
188
|
-
// the gesture.
|
|
189
|
-
const capturesRef = useRef<Map<number, Element>>(new Map());
|
|
190
|
-
|
|
191
|
-
// Touch. Every finger currently on the canvas, in the order it landed — the
|
|
192
|
-
// map is what makes a *second* finger legible as a pinch rather than as a
|
|
193
|
-
// second, competing drag.
|
|
194
|
-
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
|
195
|
-
const pinchRef = useRef<PinchState | null>(null);
|
|
196
|
-
const [pinching, setPinching] = useState(false);
|
|
197
|
-
const longPressRef = useRef<LongPressState | null>(null);
|
|
198
|
-
// A press that became a menu must not also read as a click: the shell hides
|
|
199
|
-
// the menu on any click that reaches it, closing it on the way back up.
|
|
200
|
-
const suppressClickRef = useRef(false);
|
|
201
|
-
// Coarse pointer: hit areas grow for a fingertip, and only then, so a mouse
|
|
202
|
-
// keeps the precision the visual design was drawn for. Resolved in an effect
|
|
203
|
-
// (never during render) so the server's HTML and the first client paint agree.
|
|
204
|
-
const [coarsePointer, setCoarsePointer] = useState(false);
|
|
205
|
-
|
|
206
|
-
// While the view is being steered — dragged by a finger, pinched, or panned
|
|
207
|
-
// with the middle button — the board underneath stops answering the pointer,
|
|
208
|
-
// so a box never fights the gesture that is moving the whole world.
|
|
209
|
-
const navigating = panning || pinching;
|
|
210
|
-
|
|
211
48
|
// Remembered board: restore this browser's drags, then keep writing them back.
|
|
212
49
|
useLayoutMemory();
|
|
213
50
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}, []);
|
|
219
|
-
|
|
220
|
-
/** Hand the pointer back to whoever was holding it, and forget the holder. */
|
|
221
|
-
const releaseCapture = useCallback((pointerId: number) => {
|
|
222
|
-
const held = capturesRef.current.get(pointerId) as (Element & {
|
|
223
|
-
hasPointerCapture?: (id: number) => boolean;
|
|
224
|
-
releasePointerCapture?: (id: number) => void;
|
|
225
|
-
}) | undefined;
|
|
226
|
-
capturesRef.current.delete(pointerId);
|
|
227
|
-
if (held?.hasPointerCapture?.(pointerId)) held.releasePointerCapture?.(pointerId);
|
|
228
|
-
}, []);
|
|
229
|
-
|
|
230
|
-
/** Disarm the pending menu — for one pointer, or for whichever is armed. */
|
|
231
|
-
const cancelLongPress = useCallback((pointerId?: number) => {
|
|
232
|
-
const held = longPressRef.current;
|
|
233
|
-
if (!held) return;
|
|
234
|
-
if (pointerId !== undefined && held.pointerId !== pointerId) return;
|
|
235
|
-
clearTimeout(held.timer);
|
|
236
|
-
longPressRef.current = null;
|
|
237
|
-
}, []);
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Arm the hold that stands in for a right-click. The finger keeps whatever it
|
|
241
|
-
* already started — a drag, a pan — until the timer fires; only then does the
|
|
242
|
-
* gesture let go, so the board cannot slide out from under the menu.
|
|
243
|
-
*/
|
|
244
|
-
const armLongPress = useCallback(
|
|
245
|
-
(e: React.PointerEvent, target: ContextMenuState["target"]) => {
|
|
246
|
-
if (e.pointerType !== "touch") return;
|
|
247
|
-
cancelLongPress();
|
|
248
|
-
const { pointerId, clientX: x, clientY: y } = e;
|
|
249
|
-
const timer = setTimeout(() => {
|
|
250
|
-
longPressRef.current = null;
|
|
251
|
-
applyDrag(null);
|
|
252
|
-
panRef.current = null;
|
|
253
|
-
setPanning(false);
|
|
254
|
-
// The capture stays where it is: the finger now steers nothing, but the
|
|
255
|
-
// element holding it is still guaranteed to receive the eventual lift.
|
|
256
|
-
suppressClickRef.current = true;
|
|
257
|
-
navigator.vibrate?.(10);
|
|
258
|
-
showContextMenu(x, y, target);
|
|
259
|
-
}, LONG_PRESS_MS);
|
|
260
|
-
longPressRef.current = { pointerId, x, y, target, timer };
|
|
261
|
-
},
|
|
262
|
-
[cancelLongPress, showContextMenu, applyDrag]
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
// A fingertip asks for room a cursor does not. Detected once, watched for
|
|
266
|
-
// change (a tablet docking to a mouse flips this), and never read in render
|
|
267
|
-
// before the effect has run — so hydration matches the server's `false`.
|
|
268
|
-
useEffect(() => {
|
|
269
|
-
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
270
|
-
const mq = window.matchMedia("(pointer: coarse)");
|
|
271
|
-
const apply = () => setCoarsePointer(mq.matches);
|
|
272
|
-
apply();
|
|
273
|
-
mq.addEventListener("change", apply);
|
|
274
|
-
return () => mq.removeEventListener("change", apply);
|
|
275
|
-
}, []);
|
|
276
|
-
|
|
277
|
-
// Safety net. Pointer capture makes the canvas's own `pointerup` reliable, but
|
|
278
|
-
// a gesture torn away by the OS — a call arriving, the app backgrounded — can
|
|
279
|
-
// still leave a finger recorded that is no longer on the glass. A stale entry
|
|
280
|
-
// would make the *next* single touch look like the second half of a pinch, so
|
|
281
|
-
// every pointer that ends anywhere is forgotten here too.
|
|
282
|
-
useEffect(() => {
|
|
283
|
-
const forget = (e: PointerEvent) => {
|
|
284
|
-
pointersRef.current.delete(e.pointerId);
|
|
285
|
-
};
|
|
286
|
-
window.addEventListener("pointerup", forget);
|
|
287
|
-
window.addEventListener("pointercancel", forget);
|
|
288
|
-
return () => {
|
|
289
|
-
window.removeEventListener("pointerup", forget);
|
|
290
|
-
window.removeEventListener("pointercancel", forget);
|
|
291
|
-
};
|
|
292
|
-
}, []);
|
|
293
|
-
|
|
294
|
-
// Unmounting mid-hold must not leave a timer alive to open a menu over a
|
|
295
|
-
// canvas that no longer exists.
|
|
296
|
-
useEffect(() => () => cancelLongPress(), [cancelLongPress]);
|
|
297
|
-
|
|
298
|
-
// WS7b — live-animation tracking. The prev* refs are read/written ONLY inside
|
|
299
|
-
// the commit effect (never during render). The rendered classes are driven by
|
|
300
|
-
// state (enteringNames / enteringEdges), so the render stays a pure function
|
|
301
|
-
// of props+state. `exitingNodes` holds nodes that vanished from the current
|
|
302
|
-
// drill level so they outlive React's unmount and animate out.
|
|
303
|
-
const prevNamesRef = useRef<Set<string>>(new Set());
|
|
304
|
-
const prevNodeDataRef = useRef<Map<string, { state: StateDef; pos: StatePosition }>>(new Map());
|
|
305
|
-
const prevParentRef = useRef<string>(currentParent);
|
|
306
|
-
const prevEdgeKeysRef = useRef<Set<string>>(new Set());
|
|
307
|
-
const [enteringNames, setEnteringNames] = useState<Set<string>>(new Set());
|
|
308
|
-
const [enteringEdges, setEnteringEdges] = useState<Set<string>>(new Set());
|
|
309
|
-
const [exitingNodes, setExitingNodes] = useState<Map<string, { state: StateDef; pos: StatePosition }>>(new Map());
|
|
310
|
-
|
|
311
|
-
// Show only children of current navigation level (not flattened)
|
|
312
|
-
const currentChildren = getCurrentChildren();
|
|
313
|
-
const allEvents = definition.events.flatMap((src) => src.events ?? []);
|
|
314
|
-
|
|
315
|
-
// Touch hit areas. Both live in world units — everything inside the navigated
|
|
316
|
-
// group does — so each is a screen-pixel budget divided by the scale: the cuff
|
|
317
|
-
// around a box and the ribbon along an edge stay the same size *in the hand*
|
|
318
|
-
// whether the board is zoomed in or pushed away. Zero for a mouse, which means
|
|
319
|
-
// the extra geometry is never rendered at all and the cursor keeps hitting
|
|
320
|
-
// exactly what it is pointed at.
|
|
321
|
-
const touchPad = coarsePointer ? TOUCH_TARGET_PAD_PX / viewport.scale : 0;
|
|
322
|
-
const touchEdgeStroke = coarsePointer ? TOUCH_EDGE_STROKE_PX / viewport.scale : 0;
|
|
323
|
-
/** HUD buttons in screen space: a thumb needs the padding a cursor does not. */
|
|
324
|
-
const hudButton = coarsePointer ? "px-3 py-2" : "px-1.5 py-0.5";
|
|
325
|
-
|
|
326
|
-
// Keyboard shortcuts
|
|
327
|
-
useEffect(() => {
|
|
328
|
-
const handler = (e: KeyboardEvent) => {
|
|
329
|
-
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
|
|
330
|
-
if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.shiftKey && e.key === "z"))) { e.preventDefault(); redo(); }
|
|
331
|
-
if (e.key === "Escape") { setDrawMode("select"); setEventPicker(null); }
|
|
332
|
-
if (e.key === "Delete" && selection.kind === "state" && selection.id) {
|
|
333
|
-
removeState(selection.id);
|
|
334
|
-
clearSelection();
|
|
335
|
-
}
|
|
336
|
-
};
|
|
337
|
-
window.addEventListener("keydown", handler);
|
|
338
|
-
return () => window.removeEventListener("keydown", handler);
|
|
339
|
-
}, [undo, redo, setDrawMode, selection, removeState, clearSelection]);
|
|
340
|
-
|
|
341
|
-
const getPos = useCallback(
|
|
342
|
-
(name: string): StatePosition =>
|
|
343
|
-
layout.positions[name] ?? { x: 100, y: 100, width: 160, height: 60 },
|
|
344
|
-
[layout]
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
const hasError = useCallback(
|
|
348
|
-
(name: string) => errors.some((e) => e.element === name),
|
|
349
|
-
[errors]
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
/** Begin sliding the whole board with one pointer. Shared by mouse and finger. */
|
|
353
|
-
const beginPan = useCallback((e: React.PointerEvent) => {
|
|
354
|
-
const vp = useDesignerStore.getState().viewport;
|
|
355
|
-
panRef.current = {
|
|
356
|
-
pointerId: e.pointerId,
|
|
357
|
-
startX: e.clientX,
|
|
358
|
-
startY: e.clientY,
|
|
359
|
-
origX: vp.x,
|
|
360
|
-
origY: vp.y,
|
|
361
|
-
};
|
|
362
|
-
panMovedRef.current = false;
|
|
363
|
-
setPanning(true);
|
|
364
|
-
const svg = svgRef.current;
|
|
365
|
-
if (!svg) return;
|
|
366
|
-
capturesRef.current.set(e.pointerId, svg);
|
|
367
|
-
svg.setPointerCapture(e.pointerId);
|
|
368
|
-
}, []);
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Promote whatever is happening to a two-finger gesture. Everything the single
|
|
372
|
-
* pointer had claimed — a box mid-drag, a pan mid-slide, a hold on its way to
|
|
373
|
-
* a menu — is given up here: with both fingers on the glass the user is
|
|
374
|
-
* steering the view, not editing the machine.
|
|
375
|
-
*/
|
|
376
|
-
const beginPinch = useCallback(() => {
|
|
377
|
-
const live = [...pointersRef.current.entries()];
|
|
378
|
-
if (live.length < 2) return;
|
|
379
|
-
const [[idA, a], [idB, b]] = live;
|
|
380
|
-
|
|
381
|
-
cancelLongPress();
|
|
382
|
-
applyDrag(null);
|
|
383
|
-
panRef.current = null;
|
|
384
|
-
|
|
385
|
-
// Fingers landing on the same pixel would divide by zero; a floor of one
|
|
386
|
-
// pixel makes the first frame a no-op instead of an infinity.
|
|
387
|
-
const startDist = Math.max(1, Math.hypot(b.x - a.x, b.y - a.y));
|
|
388
|
-
const mid = toCanvasPoint((a.x + b.x) / 2, (a.y + b.y) / 2);
|
|
389
|
-
pinchRef.current = {
|
|
390
|
-
a: idA,
|
|
391
|
-
b: idB,
|
|
392
|
-
startDist,
|
|
393
|
-
startMidX: mid.x,
|
|
394
|
-
startMidY: mid.y,
|
|
395
|
-
start: useDesignerStore.getState().viewport,
|
|
396
|
-
};
|
|
397
|
-
// A pinch has plainly travelled; its lift is never a tap on empty space.
|
|
398
|
-
panMovedRef.current = true;
|
|
399
|
-
setPinching(true);
|
|
400
|
-
|
|
401
|
-
// A finger that arrived while the capture phase was swallowing the event was
|
|
402
|
-
// never captured by anyone. Take it on the surface so the gesture survives it
|
|
403
|
-
// sliding off the canvas; fingers already held by a box keep that holder,
|
|
404
|
-
// since their moves bubble here regardless.
|
|
405
|
-
const svg = svgRef.current;
|
|
406
|
-
if (!svg) return;
|
|
407
|
-
for (const id of [idA, idB]) {
|
|
408
|
-
if (capturesRef.current.has(id)) continue;
|
|
409
|
-
try {
|
|
410
|
-
svg.setPointerCapture(id);
|
|
411
|
-
capturesRef.current.set(id, svg);
|
|
412
|
-
} catch {
|
|
413
|
-
// The pointer ended between the event and this line. Nothing to hold.
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}, [cancelLongPress, toCanvasPoint, applyDrag]);
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Runs before any box or the surface sees the press, so it can count fingers
|
|
420
|
-
* that a node's `stopPropagation` would otherwise hide. Bookkeeping only —
|
|
421
|
-
* except for the one decision that has to be made this early: a second finger
|
|
422
|
-
* turns the gesture into a pinch, and nothing below may start a rival one.
|
|
423
|
-
*/
|
|
424
|
-
const handlePointerDownCapture = useCallback(
|
|
425
|
-
(e: React.PointerEvent) => {
|
|
426
|
-
// The click that closes out the previous gesture has been and gone (or was
|
|
427
|
-
// never sent, as on iOS after a hold). Either way, stop swallowing.
|
|
428
|
-
suppressClickRef.current = false;
|
|
429
|
-
if (e.pointerType !== "touch") return;
|
|
430
|
-
setCoarsePointer(true);
|
|
431
|
-
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
432
|
-
const count = pointersRef.current.size;
|
|
433
|
-
if (count < 2) return;
|
|
434
|
-
e.stopPropagation();
|
|
435
|
-
if (count === 2) beginPinch();
|
|
436
|
-
// A third finger is noise: recorded so its lift is accounted for, but the
|
|
437
|
-
// pinch keeps the two fingers it started with.
|
|
438
|
-
},
|
|
439
|
-
[beginPinch]
|
|
440
|
-
);
|
|
441
|
-
|
|
442
|
-
const handleNodePointerDown = useCallback(
|
|
443
|
-
(e: React.PointerEvent, name: string) => {
|
|
444
|
-
// Middle button, or space held: this gesture belongs to the surface. Let
|
|
445
|
-
// it bubble un-stopped so the pan handler below picks it up.
|
|
446
|
-
if (e.button !== 0 || spaceRef.current) return;
|
|
447
|
-
e.stopPropagation();
|
|
448
|
-
// A finger on a box may still be asking for the menu rather than a drag.
|
|
449
|
-
// Arm the hold either way; travel past the slop disarms it.
|
|
450
|
-
armLongPress(e, { kind: "state", id: name });
|
|
451
|
-
if (drawMode === "transition") {
|
|
452
|
-
if (!drawSource) {
|
|
453
|
-
setDrawSource(name);
|
|
454
|
-
} else if (drawSource !== name) {
|
|
455
|
-
// Show event picker
|
|
456
|
-
setEventPicker({ stateName: drawSource, targetName: name, x: e.clientX, y: e.clientY });
|
|
457
|
-
}
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
select("state", name);
|
|
461
|
-
const pos = getPos(name);
|
|
462
|
-
applyDrag({ name, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y });
|
|
463
|
-
// Capture on the node's own group, so the gesture survives the pointer
|
|
464
|
-
// outrunning the box or leaving the canvas. It must be *this* element and
|
|
465
|
-
// not the surface: a capture retargets the click and dblclick that follow,
|
|
466
|
-
// and retargeting them to the <svg> would read as a click on empty space —
|
|
467
|
-
// clearing the selection the press just made, and stealing the
|
|
468
|
-
// double-click that drills into a composite.
|
|
469
|
-
const node = e.currentTarget as Element;
|
|
470
|
-
capturesRef.current.set(e.pointerId, node);
|
|
471
|
-
node.setPointerCapture?.(e.pointerId);
|
|
472
|
-
},
|
|
473
|
-
[select, getPos, drawMode, drawSource, setDrawSource, armLongPress, applyDrag]
|
|
474
|
-
);
|
|
475
|
-
|
|
476
|
-
const handleSurfacePointerDown = useCallback(
|
|
477
|
-
(e: React.PointerEvent) => {
|
|
478
|
-
if (e.pointerType === "touch") {
|
|
479
|
-
// A phone has no middle button and no space bar, so the plainest gesture
|
|
480
|
-
// has to be the one that moves the board: one finger on bare canvas pans.
|
|
481
|
-
// No `preventDefault` — the click and double-click synthesised from this
|
|
482
|
-
// touch are what still clear the selection and drill into a composite.
|
|
483
|
-
armLongPress(e, { kind: "canvas" });
|
|
484
|
-
beginPan(e);
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
const wantsPan = e.button === 1 || (e.button === 0 && spaceRef.current);
|
|
488
|
-
if (!wantsPan) return;
|
|
489
|
-
// Suppresses the compatibility mouse events, and with them Chrome's
|
|
490
|
-
// middle-click autoscroll.
|
|
491
|
-
e.preventDefault();
|
|
492
|
-
beginPan(e);
|
|
493
|
-
},
|
|
494
|
-
[armLongPress, beginPan]
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
const handleSurfacePointerMove = useCallback(
|
|
498
|
-
(e: React.PointerEvent) => {
|
|
499
|
-
if (pointersRef.current.has(e.pointerId)) {
|
|
500
|
-
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// A hold that wanders is a drag changing its mind.
|
|
504
|
-
const hold = longPressRef.current;
|
|
505
|
-
if (hold && hold.pointerId === e.pointerId) {
|
|
506
|
-
if (Math.hypot(e.clientX - hold.x, e.clientY - hold.y) > TOUCH_SLOP_PX) {
|
|
507
|
-
cancelLongPress(e.pointerId);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
51
|
+
// The store's breadcrumb carries the root name at index 0; the canvas takes
|
|
52
|
+
// the path *below* the root and names the root itself separately. `navigateUp`
|
|
53
|
+
// indexes the full path, which is exactly what the breadcrumb hands back.
|
|
54
|
+
const path = navigationPath.slice(1);
|
|
510
55
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const pan = panRef.current;
|
|
539
|
-
if (pan) {
|
|
540
|
-
if (pan.pointerId !== e.pointerId) return;
|
|
541
|
-
const dx = e.clientX - pan.startX;
|
|
542
|
-
const dy = e.clientY - pan.startY;
|
|
543
|
-
// A mouse that moved at all was dragged. A fingertip is never that
|
|
544
|
-
// still, so a touch has to clear the slop before its lift stops
|
|
545
|
-
// counting as a tap.
|
|
546
|
-
const slop = e.pointerType === "touch" ? TOUCH_SLOP_PX : 0;
|
|
547
|
-
if (Math.abs(dx) > slop || Math.abs(dy) > slop) panMovedRef.current = true;
|
|
548
|
-
const scale = useDesignerStore.getState().viewport.scale;
|
|
549
|
-
setViewport({ x: pan.origX + dx, y: pan.origY + dy, scale });
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
553
|
-
// The pointer moves in screen pixels; the box lives in world units. At
|
|
554
|
-
// scale k a screen pixel is 1/k world units, which is what keeps a
|
|
555
|
-
// dragged state pinned under the cursor at any zoom. Pan cancels out of a
|
|
556
|
-
// delta, so only the scale appears here.
|
|
557
|
-
const scale = useDesignerStore.getState().viewport.scale;
|
|
558
|
-
const dx = (e.clientX - drag.startX) / scale;
|
|
559
|
-
const dy = (e.clientY - drag.startY) / scale;
|
|
560
|
-
const pos = getPos(drag.name);
|
|
561
|
-
setStatePosition(drag.name, {
|
|
562
|
-
...pos,
|
|
563
|
-
x: Math.max(0, drag.origX + dx),
|
|
564
|
-
y: Math.max(0, drag.origY + dy),
|
|
565
|
-
});
|
|
566
|
-
},
|
|
567
|
-
[drag, getPos, setStatePosition, setViewport, cancelLongPress, toCanvasPoint]
|
|
568
|
-
);
|
|
569
|
-
|
|
570
|
-
const handleSurfacePointerUp = useCallback(
|
|
571
|
-
(e: React.PointerEvent) => {
|
|
572
|
-
pointersRef.current.delete(e.pointerId);
|
|
573
|
-
cancelLongPress(e.pointerId);
|
|
574
|
-
releaseCapture(e.pointerId);
|
|
575
|
-
|
|
576
|
-
const pinch = pinchRef.current;
|
|
577
|
-
if (pinch && (pinch.a === e.pointerId || pinch.b === e.pointerId)) {
|
|
578
|
-
pinchRef.current = null;
|
|
579
|
-
setPinching(false);
|
|
580
|
-
// One finger lifting out of a pinch leaves the other still on the glass,
|
|
581
|
-
// and a hand that is still touching the board expects it to keep
|
|
582
|
-
// following. Re-open a pan from where that finger is *now*, against the
|
|
583
|
-
// viewport the pinch just left behind — measuring from where it first
|
|
584
|
-
// landed would snap the board across the screen.
|
|
585
|
-
const survivorId = pinch.a === e.pointerId ? pinch.b : pinch.a;
|
|
586
|
-
const survivor = pointersRef.current.get(survivorId);
|
|
587
|
-
if (survivor) {
|
|
588
|
-
const vp = useDesignerStore.getState().viewport;
|
|
589
|
-
panRef.current = {
|
|
590
|
-
pointerId: survivorId,
|
|
591
|
-
startX: survivor.x,
|
|
592
|
-
startY: survivor.y,
|
|
593
|
-
origX: vp.x,
|
|
594
|
-
origY: vp.y,
|
|
595
|
-
};
|
|
596
|
-
panMovedRef.current = true;
|
|
597
|
-
setPanning(true);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (panRef.current?.pointerId === e.pointerId) panRef.current = null;
|
|
602
|
-
if (dragRef.current?.pointerId === e.pointerId) applyDrag(null);
|
|
603
|
-
|
|
604
|
-
// Sweep. Whether the view is being steered is asked of the refs, not of
|
|
605
|
-
// which pointer just ended — a finger the OS reclaimed, or one whose lift
|
|
606
|
-
// never reached us, would otherwise leave the board inert for good, every
|
|
607
|
-
// box deaf to the pointer with nothing left to explain why.
|
|
608
|
-
if (!panRef.current) setPanning(false);
|
|
609
|
-
if (!pinchRef.current) setPinching(false);
|
|
610
|
-
},
|
|
611
|
-
[cancelLongPress, releaseCapture, applyDrag]
|
|
612
|
-
);
|
|
613
|
-
|
|
614
|
-
// Wheel: pan by default, zoom under Ctrl/⌘ (which is also how a browser
|
|
615
|
-
// delivers a trackpad pinch). Registered natively with `passive: false` —
|
|
616
|
-
// React's own wheel listener is passive, so `preventDefault` there cannot stop
|
|
617
|
-
// the page from zooming out from under the canvas.
|
|
618
|
-
useEffect(() => {
|
|
619
|
-
const svg = svgRef.current;
|
|
620
|
-
if (!svg) return;
|
|
621
|
-
const onWheel = (e: WheelEvent) => {
|
|
622
|
-
e.preventDefault();
|
|
623
|
-
const vp = useDesignerStore.getState().viewport;
|
|
624
|
-
const dx = wheelPixels(e.deltaX, e.deltaMode);
|
|
625
|
-
const dy = wheelPixels(e.deltaY, e.deltaMode);
|
|
626
|
-
|
|
627
|
-
if (e.ctrlKey || e.metaKey) {
|
|
628
|
-
const anchor = toCanvasPoint(e.clientX, e.clientY);
|
|
629
|
-
const next = zoomAt(vp, Math.exp(-dy * 0.0025), anchor);
|
|
630
|
-
if (next !== vp) setViewport(next);
|
|
631
|
-
return;
|
|
56
|
+
return (
|
|
57
|
+
<StateMachineCanvas
|
|
58
|
+
definition={definition}
|
|
59
|
+
positions={layout.positions}
|
|
60
|
+
path={path}
|
|
61
|
+
rootLabel={navigationPath[0]}
|
|
62
|
+
viewport={viewport}
|
|
63
|
+
onViewportChange={setViewport}
|
|
64
|
+
// The store also knows about a selected *event*, which is a sidebar
|
|
65
|
+
// concern with nothing on the board to outline.
|
|
66
|
+
selection={{
|
|
67
|
+
kind: selection.kind === "state" || selection.kind === "transition" ? selection.kind : null,
|
|
68
|
+
id: selection.id,
|
|
69
|
+
}}
|
|
70
|
+
activeStates={activeStates}
|
|
71
|
+
errorElements={errors.flatMap((e) => (e.element ? [e.element] : []))}
|
|
72
|
+
drawMode={drawMode === "transition" ? "transition" : "select"}
|
|
73
|
+
drawSource={drawSource}
|
|
74
|
+
svgId={CANVAS_SVG_ID}
|
|
75
|
+
pinchOwner={CANVAS_PINCH_OWNER}
|
|
76
|
+
emptyHint={
|
|
77
|
+
typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches
|
|
78
|
+
? "Press and hold to add a state, or load a .smdf.json file"
|
|
79
|
+
: "Right-click to add a state, or load a .smdf.json file"
|
|
632
80
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
svg.addEventListener("wheel", onWheel, { passive: false });
|
|
640
|
-
return () => svg.removeEventListener("wheel", onWheel);
|
|
641
|
-
}, [setViewport, toCanvasPoint]);
|
|
642
|
-
|
|
643
|
-
// Space arms the pan grip. Held down it must not scroll the page, and it must
|
|
644
|
-
// stay inert while the user is typing into a panel.
|
|
645
|
-
useEffect(() => {
|
|
646
|
-
const down = (e: KeyboardEvent) => {
|
|
647
|
-
if (e.code !== "Space" || e.repeat) return;
|
|
648
|
-
if (isTypingTarget(e.target) || isTypingTarget(document.activeElement)) return;
|
|
649
|
-
e.preventDefault();
|
|
650
|
-
spaceRef.current = true;
|
|
651
|
-
setSpaceHeld(true);
|
|
652
|
-
};
|
|
653
|
-
const up = (e: KeyboardEvent) => {
|
|
654
|
-
if (e.code !== "Space") return;
|
|
655
|
-
spaceRef.current = false;
|
|
656
|
-
setSpaceHeld(false);
|
|
657
|
-
};
|
|
658
|
-
// Losing the window mid-hold would otherwise leave the grip stuck on.
|
|
659
|
-
const release = () => {
|
|
660
|
-
spaceRef.current = false;
|
|
661
|
-
setSpaceHeld(false);
|
|
662
|
-
};
|
|
663
|
-
window.addEventListener("keydown", down);
|
|
664
|
-
window.addEventListener("keyup", up);
|
|
665
|
-
window.addEventListener("blur", release);
|
|
666
|
-
return () => {
|
|
667
|
-
window.removeEventListener("keydown", down);
|
|
668
|
-
window.removeEventListener("keyup", up);
|
|
669
|
-
window.removeEventListener("blur", release);
|
|
670
|
-
};
|
|
671
|
-
}, []);
|
|
672
|
-
|
|
673
|
-
/** Zoom from the buttons: anchored at the middle of the canvas. */
|
|
674
|
-
const zoomFromCentre = useCallback(
|
|
675
|
-
(factor: number) => {
|
|
676
|
-
const rect = svgRef.current?.getBoundingClientRect();
|
|
677
|
-
const anchor = { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 };
|
|
678
|
-
const vp = useDesignerStore.getState().viewport;
|
|
679
|
-
const next = zoomAt(vp, factor, anchor);
|
|
680
|
-
if (next !== vp) setViewport(next);
|
|
681
|
-
},
|
|
682
|
-
[setViewport]
|
|
683
|
-
);
|
|
684
|
-
|
|
685
|
-
const resetZoom = useCallback(() => {
|
|
686
|
-
const rect = svgRef.current?.getBoundingClientRect();
|
|
687
|
-
const anchor = { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 };
|
|
688
|
-
setViewport(zoomTo(useDesignerStore.getState().viewport, 1, anchor));
|
|
689
|
-
}, [setViewport]);
|
|
690
|
-
|
|
691
|
-
/** Frame every box of the current drill level — the answer to "where did it go". */
|
|
692
|
-
const fitToView = useCallback(() => {
|
|
693
|
-
const rect = svgRef.current?.getBoundingClientRect();
|
|
694
|
-
if (!rect) return;
|
|
695
|
-
const boxes = currentChildren.map((s) => getPos(s.name));
|
|
696
|
-
setViewport(
|
|
697
|
-
boxes.length ? fitToBoxes(boxes, rect.width, rect.height) : { ...IDENTITY_VIEWPORT }
|
|
698
|
-
);
|
|
699
|
-
}, [currentChildren, getPos, setViewport]);
|
|
700
|
-
|
|
701
|
-
const handleContextMenu = useCallback(
|
|
702
|
-
(e: React.MouseEvent, stateName?: string) => {
|
|
703
|
-
e.preventDefault();
|
|
704
|
-
e.stopPropagation();
|
|
705
|
-
showContextMenu(e.clientX, e.clientY, stateName ? { kind: "state", id: stateName } : { kind: "canvas" });
|
|
706
|
-
},
|
|
707
|
-
[showContextMenu]
|
|
708
|
-
);
|
|
709
|
-
|
|
710
|
-
const handleEventPick = (eventId: string) => {
|
|
711
|
-
if (!eventPicker) return;
|
|
712
|
-
addTransition(eventPicker.stateName, { event: eventId, nextState: eventPicker.targetName });
|
|
713
|
-
setEventPicker(null);
|
|
714
|
-
setDrawSource(null);
|
|
715
|
-
setDrawMode("select");
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
// Collect transitions for arrows (within current navigation level). Memoized
|
|
719
|
-
// so its identity is stable across renders (drag/selection) — the WS7b commit
|
|
720
|
-
// effect lists it as a dependency.
|
|
721
|
-
const arrows = useMemo(() => {
|
|
722
|
-
const childNames = new Set(currentChildren.map((s) => s.name));
|
|
723
|
-
const result: { from: string; to: string; event: string; stateName: string; index: number; condition?: string }[] = [];
|
|
724
|
-
for (const state of currentChildren) {
|
|
725
|
-
for (const [i, t] of (state.transitions ?? []).entries()) {
|
|
726
|
-
if (t.nextState && childNames.has(t.nextState)) {
|
|
727
|
-
result.push({ from: state.name, to: t.nextState, event: t.event, stateName: state.name, index: i, condition: t.condition });
|
|
81
|
+
onSelect={(kind, id) => {
|
|
82
|
+
// In draw mode the first click on a box names the source of the
|
|
83
|
+
// transition rather than selecting it.
|
|
84
|
+
if (kind === "state" && drawMode === "transition" && !drawSource) {
|
|
85
|
+
setDrawSource(id);
|
|
86
|
+
return;
|
|
728
87
|
}
|
|
88
|
+
select(kind, id);
|
|
89
|
+
}}
|
|
90
|
+
onClearSelection={clearSelection}
|
|
91
|
+
onStateMove={setStatePosition}
|
|
92
|
+
onNavigateInto={navigateInto}
|
|
93
|
+
onNavigateTo={(depth) => navigateUp(depth)}
|
|
94
|
+
onContextMenu={(x, y, target) =>
|
|
95
|
+
showContextMenu(
|
|
96
|
+
x,
|
|
97
|
+
y,
|
|
98
|
+
target.kind === "state" ? { kind: "state", id: target.id } : { kind: "canvas" }
|
|
99
|
+
)
|
|
729
100
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
* it through the gap that is actually there (an edge to a state above leaves
|
|
747
|
-
* the top, not the bottom). `placeLabels` then nudges any chip that still has
|
|
748
|
-
* a neighbour. The curve and the walker the chip rides come from the same
|
|
749
|
-
* control points, so a label can never be settled against a line other than
|
|
750
|
-
* the one drawn.
|
|
751
|
-
*
|
|
752
|
-
* Recomputed while a box is being dragged, which is what makes the arrows and
|
|
753
|
-
* their labels re-settle under the hand instead of after it.
|
|
754
|
-
*/
|
|
755
|
-
const edges = useMemo(() => {
|
|
756
|
-
const curves = routeEdges(arrows, getPos);
|
|
757
|
-
const spots = placeLabels(
|
|
758
|
-
arrows.map((arrow, i) => {
|
|
759
|
-
const box = getPos(arrow.from);
|
|
760
|
-
return {
|
|
761
|
-
id: `${arrow.stateName}:${arrow.index}`,
|
|
762
|
-
event: arrow.event,
|
|
763
|
-
condition: arrow.condition,
|
|
764
|
-
glyph: true,
|
|
765
|
-
// A self-loop's arc is a short bulge with no room to carry a word on
|
|
766
|
-
// it; its chip sits just beyond the bulge instead.
|
|
767
|
-
at:
|
|
768
|
-
arrow.from === arrow.to
|
|
769
|
-
? () => ({
|
|
770
|
-
x: box.x + box.width + SELF_LOOP_BULGE + 30,
|
|
771
|
-
y: box.y + box.height / 2 + 4,
|
|
772
|
-
})
|
|
773
|
-
: curves[i].at,
|
|
774
|
-
};
|
|
775
|
-
}),
|
|
776
|
-
currentChildren.map((s) => getPos(s.name))
|
|
777
|
-
);
|
|
778
|
-
const byId = new Map(spots.map((spot) => [spot.label.id, spot]));
|
|
779
|
-
return arrows.flatMap((arrow, i) => {
|
|
780
|
-
const id = `${arrow.stateName}:${arrow.index}`;
|
|
781
|
-
const spot = byId.get(id);
|
|
782
|
-
return spot ? [{ arrow, id, curve: curves[i], spot }] : [];
|
|
783
|
-
});
|
|
784
|
-
}, [arrows, currentChildren, getPos]);
|
|
785
|
-
|
|
786
|
-
// WS7b — after each commit, diff the freshly-rendered nodes/edges against the
|
|
787
|
-
// previous render (held in refs, touched only here). Newly-present names/edges
|
|
788
|
-
// are flagged as "entering" (drives sm-node-enter / sm-edge-enter on the NEXT
|
|
789
|
-
// render's fresh element); names that vanished from the current drill level are
|
|
790
|
-
// captured with their last def + position into `exitingNodes` so they animate
|
|
791
|
-
// out. Drill-level changes are NOT add/remove: the new level just blooms in and
|
|
792
|
-
// stale exit nodes are cleared.
|
|
793
|
-
useEffect(() => {
|
|
794
|
-
const currentNames = new Set(currentChildren.map((s) => s.name));
|
|
795
|
-
const currentEdgeKeys = new Set(arrows.map((a) => `${a.stateName}:${a.index}`));
|
|
796
|
-
const sameLevel = prevParentRef.current === currentParent;
|
|
797
|
-
|
|
798
|
-
const newNames = [...currentNames].filter((n) => !prevNamesRef.current.has(n));
|
|
799
|
-
if (newNames.length) {
|
|
800
|
-
setEnteringNames((prev) => {
|
|
801
|
-
const next = new Set(prev);
|
|
802
|
-
newNames.forEach((n) => next.add(n));
|
|
803
|
-
return next;
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
const newEdges = [...currentEdgeKeys].filter((k) => !prevEdgeKeysRef.current.has(k));
|
|
808
|
-
if (newEdges.length) {
|
|
809
|
-
setEnteringEdges((prev) => {
|
|
810
|
-
const next = new Set(prev);
|
|
811
|
-
newEdges.forEach((k) => next.add(k));
|
|
812
|
-
return next;
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Prune enter markers for nodes/edges that no longer exist (keeps the sets
|
|
817
|
-
// bounded to what is currently on-canvas).
|
|
818
|
-
setEnteringNames((prev) => {
|
|
819
|
-
let changed = false;
|
|
820
|
-
const next = new Set(prev);
|
|
821
|
-
prev.forEach((n) => {
|
|
822
|
-
if (!currentNames.has(n)) { next.delete(n); changed = true; }
|
|
823
|
-
});
|
|
824
|
-
return changed ? next : prev;
|
|
825
|
-
});
|
|
826
|
-
setEnteringEdges((prev) => {
|
|
827
|
-
let changed = false;
|
|
828
|
-
const next = new Set(prev);
|
|
829
|
-
prev.forEach((k) => {
|
|
830
|
-
if (!currentEdgeKeys.has(k)) { next.delete(k); changed = true; }
|
|
831
|
-
});
|
|
832
|
-
return changed ? next : prev;
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
if (sameLevel) {
|
|
836
|
-
setExitingNodes((prev) => {
|
|
837
|
-
const next = new Map(prev);
|
|
838
|
-
let changed = false;
|
|
839
|
-
prevNodeDataRef.current.forEach((data, name) => {
|
|
840
|
-
if (!currentNames.has(name) && !next.has(name)) {
|
|
841
|
-
next.set(name, data);
|
|
842
|
-
changed = true;
|
|
843
|
-
}
|
|
844
|
-
});
|
|
845
|
-
// A node that reappeared must never linger in the exiting layer.
|
|
846
|
-
currentNames.forEach((name) => {
|
|
847
|
-
if (next.delete(name)) changed = true;
|
|
848
|
-
});
|
|
849
|
-
return changed ? next : prev;
|
|
850
|
-
});
|
|
851
|
-
} else {
|
|
852
|
-
setExitingNodes((prev) => (prev.size ? new Map() : prev));
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
prevNamesRef.current = currentNames;
|
|
856
|
-
prevNodeDataRef.current = new Map(
|
|
857
|
-
currentChildren.map((s) => [s.name, { state: s, pos: getPos(s.name) }])
|
|
858
|
-
);
|
|
859
|
-
prevEdgeKeysRef.current = currentEdgeKeys;
|
|
860
|
-
prevParentRef.current = currentParent;
|
|
861
|
-
}, [currentChildren, currentParent, arrows, getPos]);
|
|
862
|
-
|
|
863
|
-
return (
|
|
864
|
-
// The canvas *pane*, not just the SVG. Two things hang off this element.
|
|
865
|
-
//
|
|
866
|
-
// `data-pinch-owner` is what the chrome pinch reads to disqualify itself
|
|
867
|
-
// (see lib/gestureOwner.ts): a finger anywhere in here — the board, the
|
|
868
|
-
// breadcrumb, the zoom HUD — means this gesture is about the machine, never
|
|
869
|
-
// about the size of the menus.
|
|
870
|
-
//
|
|
871
|
-
// `touch-action: none` extends the SVG's own refusal to the overlays sitting
|
|
872
|
-
// on top of it. The SVG had it; the HUD and the breadcrumb did not, so a
|
|
873
|
-
// pinch that happened to open with a finger on the zoom bar was still a
|
|
874
|
-
// browser page zoom. Nothing in this pane scrolls, so denying everything
|
|
875
|
-
// costs nothing.
|
|
876
|
-
<div
|
|
877
|
-
className="relative w-full h-full"
|
|
878
|
-
style={{ touchAction: "none" }}
|
|
879
|
-
data-pinch-owner={CANVAS_PINCH_OWNER}
|
|
880
|
-
>
|
|
881
|
-
{/* Breadcrumb navigation */}
|
|
882
|
-
{navigationPath.length > 1 && (
|
|
883
|
-
<div className="absolute top-2 left-2 z-10 flex items-center gap-1 bg-gray-900/90 border border-gray-700 rounded-lg px-3 py-1.5 text-sm">
|
|
884
|
-
{navigationPath.map((name, i) => (
|
|
885
|
-
<span key={name} className="flex items-center gap-1">
|
|
886
|
-
{i > 0 && <span className="text-gray-600">›</span>}
|
|
887
|
-
<button
|
|
888
|
-
className={`hover:text-blue-400 ${i === navigationPath.length - 1 ? "text-blue-400 font-semibold" : "text-gray-400"}`}
|
|
889
|
-
onClick={() => navigateUp(i)}
|
|
890
|
-
>
|
|
891
|
-
{name}
|
|
892
|
-
</button>
|
|
893
|
-
</span>
|
|
894
|
-
))}
|
|
895
|
-
</div>
|
|
896
|
-
)}
|
|
897
|
-
<svg
|
|
898
|
-
ref={svgRef}
|
|
899
|
-
// Named so the export can find the board without a ref threaded through
|
|
900
|
-
// the toolbar — the picture it writes is this element, serialized.
|
|
901
|
-
id={CANVAS_SVG_ID}
|
|
902
|
-
className="w-full h-full bg-gray-950"
|
|
903
|
-
style={{
|
|
904
|
-
cursor: panning ? "grabbing" : spaceHeld ? "grab" : undefined,
|
|
905
|
-
// The browser's own gestures are all suppressed here, so every one of
|
|
906
|
-
// them has to be answered below: one finger pans, two pinch, and a
|
|
907
|
-
// held finger opens the menu.
|
|
908
|
-
touchAction: "none",
|
|
909
|
-
// On a touch device a resting finger would otherwise raise the system
|
|
910
|
-
// selection callout on top of our own held-press menu.
|
|
911
|
-
...(coarsePointer
|
|
912
|
-
? {
|
|
913
|
-
WebkitUserSelect: "none" as const,
|
|
914
|
-
userSelect: "none" as const,
|
|
915
|
-
WebkitTouchCallout: "none" as const,
|
|
916
|
-
}
|
|
917
|
-
: {}),
|
|
918
|
-
}}
|
|
919
|
-
onPointerDownCapture={handlePointerDownCapture}
|
|
920
|
-
onPointerDown={handleSurfacePointerDown}
|
|
921
|
-
onPointerMove={handleSurfacePointerMove}
|
|
922
|
-
onPointerUp={handleSurfacePointerUp}
|
|
923
|
-
onPointerCancel={handleSurfacePointerUp}
|
|
924
|
-
// Middle-click on Linux/Windows would otherwise paste or autoscroll.
|
|
925
|
-
onAuxClick={(e) => e.preventDefault()}
|
|
926
|
-
onClick={(e) => {
|
|
927
|
-
// A hold that opened the menu still ends in a click on most touch
|
|
928
|
-
// browsers. It must not reach the shell, which closes the menu on any
|
|
929
|
-
// click that arrives — the menu would flash and vanish on the lift.
|
|
930
|
-
if (suppressClickRef.current) {
|
|
931
|
-
suppressClickRef.current = false;
|
|
932
|
-
e.stopPropagation();
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
// A pan that travelled is not a click on empty space.
|
|
936
|
-
if (panMovedRef.current) {
|
|
937
|
-
panMovedRef.current = false;
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
if ((e.target as SVGElement)?.tagName === 'svg') {
|
|
941
|
-
clearSelection();
|
|
942
|
-
setEventPicker(null);
|
|
943
|
-
}
|
|
944
|
-
}}
|
|
945
|
-
onContextMenu={(e) => handleContextMenu(e)}
|
|
946
|
-
>
|
|
947
|
-
<defs>
|
|
948
|
-
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
|
949
|
-
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8" />
|
|
950
|
-
</marker>
|
|
951
|
-
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
|
952
|
-
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6" />
|
|
953
|
-
</marker>
|
|
954
|
-
</defs>
|
|
955
|
-
|
|
956
|
-
{/* Draw mode indicator line */}
|
|
957
|
-
{drawMode === "transition" && drawSource && (
|
|
958
|
-
<text x="50%" y="30" fill="#f59e0b" fontSize={13} textAnchor="middle" className="pointer-events-none select-none">
|
|
959
|
-
Click target state to create transition from "{drawSource}"
|
|
960
|
-
</text>
|
|
961
|
-
)}
|
|
962
|
-
|
|
963
|
-
{/* The navigated world. One transform on one group: every arrow, box and
|
|
964
|
-
label below inherits the pan and the zoom without knowing they exist.
|
|
965
|
-
While the view is being steered — panned or pinched — the content
|
|
966
|
-
stops answering the pointer, so the grabbing cursor is not fought
|
|
967
|
-
over by the boxes underneath, and a finger that drifts onto a box
|
|
968
|
-
mid-pinch does not start dragging it. */}
|
|
969
|
-
<g
|
|
970
|
-
transform={viewportTransform(viewport)}
|
|
971
|
-
style={navigating ? { pointerEvents: "none" } : undefined}
|
|
972
|
-
>
|
|
973
|
-
|
|
974
|
-
{/* Transition arrows */}
|
|
975
|
-
{edges.map(({ arrow, id: edgeId, curve, spot }) => {
|
|
976
|
-
const isSelected = selection.kind === "transition" && selection.id === edgeId;
|
|
977
|
-
// WS7b — a new edge (flagged in the commit effect) draws itself on.
|
|
978
|
-
const edgeEntering = enteringEdges.has(edgeId);
|
|
979
|
-
// A chip that had to move is drawn back to its own curve with a hair
|
|
980
|
-
// line, so a displaced label still says which transition it names.
|
|
981
|
-
const anchor = curve.at(0.5);
|
|
982
|
-
const strayed = Math.hypot(anchor.x - spot.cx, anchor.y - spot.cy) > 4;
|
|
983
|
-
|
|
984
|
-
return (
|
|
985
|
-
<g key={`arrow-${edgeId}`}>
|
|
986
|
-
{/* A 1.5px thread is a fair target for a cursor and none at all for
|
|
987
|
-
a fingertip. This traces the same curve with a wide invisible
|
|
988
|
-
stroke underneath it — same selection, same path, just a band
|
|
989
|
-
a finger can actually land on. `pointerEvents="stroke"` hits
|
|
990
|
-
the perimeter geometry regardless of what the paint does. */}
|
|
991
|
-
{coarsePointer && (
|
|
992
|
-
<path
|
|
993
|
-
d={curve.path}
|
|
994
|
-
fill="none"
|
|
995
|
-
stroke="transparent"
|
|
996
|
-
strokeWidth={touchEdgeStroke}
|
|
997
|
-
strokeLinecap="round"
|
|
998
|
-
pointerEvents="stroke"
|
|
999
|
-
onClick={(e) => {
|
|
1000
|
-
e.stopPropagation();
|
|
1001
|
-
select("transition", edgeId);
|
|
1002
|
-
}}
|
|
1003
|
-
/>
|
|
1004
|
-
)}
|
|
1005
|
-
<path
|
|
1006
|
-
d={curve.path}
|
|
1007
|
-
fill="none"
|
|
1008
|
-
pathLength={1}
|
|
1009
|
-
stroke={isSelected ? "#3b82f6" : "#94a3b8"}
|
|
1010
|
-
strokeWidth={isSelected ? 2.5 : 1.5}
|
|
1011
|
-
markerEnd={isSelected ? "url(#arrowhead-blue)" : "url(#arrowhead)"}
|
|
1012
|
-
className={`cursor-pointer hover:stroke-blue-400${edgeEntering ? " sm-edge-enter" : ""}`}
|
|
1013
|
-
onClick={(e) => {
|
|
1014
|
-
e.stopPropagation();
|
|
1015
|
-
select("transition", edgeId);
|
|
1016
|
-
}}
|
|
1017
|
-
/>
|
|
1018
|
-
{/* Event label on edge — at the spot settled for it above, on a
|
|
1019
|
-
plate wide enough to hold its own text. */}
|
|
1020
|
-
<g
|
|
1021
|
-
className="cursor-pointer"
|
|
1022
|
-
onClick={(e) => {
|
|
1023
|
-
e.stopPropagation();
|
|
1024
|
-
select("transition", edgeId);
|
|
1025
|
-
}}
|
|
1026
|
-
>
|
|
1027
|
-
{strayed && (
|
|
1028
|
-
<line
|
|
1029
|
-
x1={anchor.x}
|
|
1030
|
-
y1={anchor.y}
|
|
1031
|
-
x2={spot.cx}
|
|
1032
|
-
y2={spot.cy - 5}
|
|
1033
|
-
stroke={isSelected ? "#3b82f6" : "#334155"}
|
|
1034
|
-
strokeWidth={1}
|
|
1035
|
-
strokeDasharray="2 3"
|
|
1036
|
-
className="pointer-events-none"
|
|
1037
|
-
/>
|
|
1038
|
-
)}
|
|
1039
|
-
{/* The same cuff the boxes get, so the event chip is reachable
|
|
1040
|
-
without the label itself being redrawn any larger. */}
|
|
1041
|
-
{coarsePointer && (
|
|
1042
|
-
<rect
|
|
1043
|
-
x={spot.x - touchPad}
|
|
1044
|
-
y={spot.y - touchPad}
|
|
1045
|
-
width={spot.width + touchPad * 2}
|
|
1046
|
-
height={spot.height + touchPad * 2}
|
|
1047
|
-
fill="transparent"
|
|
1048
|
-
pointerEvents="all"
|
|
1049
|
-
/>
|
|
1050
|
-
)}
|
|
1051
|
-
<rect
|
|
1052
|
-
x={spot.x}
|
|
1053
|
-
y={spot.y}
|
|
1054
|
-
width={spot.width}
|
|
1055
|
-
height={spot.height}
|
|
1056
|
-
rx={4}
|
|
1057
|
-
fill={isSelected ? "#1e3a5f" : "#1e293b"}
|
|
1058
|
-
stroke={isSelected ? "#3b82f6" : "#334155"}
|
|
1059
|
-
strokeWidth={1}
|
|
1060
|
-
className="hover:fill-gray-800"
|
|
1061
|
-
/>
|
|
1062
|
-
<TriggerGlyph
|
|
1063
|
-
mark={eventGlyph(arrow.event)}
|
|
1064
|
-
x={glyphAt(spot).x}
|
|
1065
|
-
y={glyphAt(spot).y}
|
|
1066
|
-
color={isSelected ? "#93c5fd" : "#7c93b0"}
|
|
1067
|
-
/>
|
|
1068
|
-
<text
|
|
1069
|
-
x={spot.cx}
|
|
1070
|
-
y={spot.cy - 5}
|
|
1071
|
-
fill={isSelected ? "#93c5fd" : "#94a3b8"}
|
|
1072
|
-
fontSize={11}
|
|
1073
|
-
fontWeight={500}
|
|
1074
|
-
textAnchor="middle"
|
|
1075
|
-
className="pointer-events-none select-none"
|
|
1076
|
-
>
|
|
1077
|
-
{arrow.event}
|
|
1078
|
-
</text>
|
|
1079
|
-
{arrow.condition && (
|
|
1080
|
-
// Elided to the width the chip was measured for. A guard is
|
|
1081
|
-
// prose and can run to a paragraph; the whole of it is in the
|
|
1082
|
-
// properties panel, and on the tooltip here.
|
|
1083
|
-
<text
|
|
1084
|
-
x={spot.cx}
|
|
1085
|
-
y={spot.cy + 7}
|
|
1086
|
-
fill="#64748b"
|
|
1087
|
-
fontSize={9}
|
|
1088
|
-
textAnchor="middle"
|
|
1089
|
-
className="select-none"
|
|
1090
|
-
>
|
|
1091
|
-
<title>{arrow.condition}</title>
|
|
1092
|
-
{guardText(arrow.condition)}
|
|
1093
|
-
</text>
|
|
1094
|
-
)}
|
|
1095
|
-
</g>
|
|
1096
|
-
</g>
|
|
1097
|
-
);
|
|
1098
|
-
})}
|
|
1099
|
-
|
|
1100
|
-
{/* State nodes */}
|
|
1101
|
-
{currentChildren.map((state) => {
|
|
1102
|
-
const pos = getPos(state.name);
|
|
1103
|
-
const isSelected = selection.kind === "state" && selection.id === state.name;
|
|
1104
|
-
const isFinal = state.kind === "final";
|
|
1105
|
-
const isHistory = state.kind === "history";
|
|
1106
|
-
const isComposite = (state.states?.length ?? 0) > 0;
|
|
1107
|
-
const isDrawSource = drawSource === state.name;
|
|
1108
|
-
const errored = hasError(state.name);
|
|
1109
|
-
const hasEntry = (state.onEntry?.actions?.length ?? 0) > 0;
|
|
1110
|
-
const hasExit = (state.onExit?.actions?.length ?? 0) > 0;
|
|
1111
|
-
const isActive = activeStates.includes(state.name);
|
|
1112
|
-
// WS7b — a node first seen this session (flagged in the commit effect)
|
|
1113
|
-
// carries sm-node-enter; the bloom only fires on the freshly-mounted
|
|
1114
|
-
// element, so the marker staying set for existing nodes is inert.
|
|
1115
|
-
const entering = enteringNames.has(state.name);
|
|
1116
|
-
|
|
1117
|
-
return (
|
|
1118
|
-
<g
|
|
1119
|
-
key={state.name}
|
|
1120
|
-
onPointerDown={(e) => handleNodePointerDown(e, state.name)}
|
|
1121
|
-
onDoubleClick={(e) => {
|
|
1122
|
-
e.stopPropagation();
|
|
1123
|
-
if (isComposite) navigateInto(state.name);
|
|
1124
|
-
}}
|
|
1125
|
-
onContextMenu={(e) => handleContextMenu(e, state.name)}
|
|
1126
|
-
className={
|
|
1127
|
-
spaceHeld
|
|
1128
|
-
? undefined // the surface owns the cursor while the pan grip is armed
|
|
1129
|
-
: drawMode === "transition"
|
|
1130
|
-
? "cursor-crosshair"
|
|
1131
|
-
: isComposite
|
|
1132
|
-
? "cursor-pointer"
|
|
1133
|
-
: "cursor-grab active:cursor-grabbing"
|
|
1134
|
-
}
|
|
1135
|
-
>
|
|
1136
|
-
{/* An invisible cuff, drawn first so it sits behind everything and
|
|
1137
|
-
changes nothing you can see. It widens the box's answer to a
|
|
1138
|
-
fingertip without widening the box. Outside the entering group
|
|
1139
|
-
on purpose: the bloom animation is a visual, and the target
|
|
1140
|
-
must be live from the first frame. */}
|
|
1141
|
-
{coarsePointer && (
|
|
1142
|
-
<rect
|
|
1143
|
-
x={pos.x - touchPad}
|
|
1144
|
-
y={pos.y - touchPad}
|
|
1145
|
-
width={pos.width + touchPad * 2}
|
|
1146
|
-
height={pos.height + touchPad * 2}
|
|
1147
|
-
rx={12}
|
|
1148
|
-
fill="transparent"
|
|
1149
|
-
pointerEvents="all"
|
|
1150
|
-
/>
|
|
1151
|
-
)}
|
|
1152
|
-
<g className={entering ? "sm-node-enter" : undefined}>
|
|
1153
|
-
<rect
|
|
1154
|
-
x={pos.x}
|
|
1155
|
-
y={pos.y}
|
|
1156
|
-
width={pos.width}
|
|
1157
|
-
height={pos.height}
|
|
1158
|
-
rx={isHistory ? 30 : isFinal ? 4 : 8}
|
|
1159
|
-
fill={isDrawSource ? "#1e3a5f" : isFinal ? "#1e293b" : isComposite ? "#0c1020" : "#0f172a"}
|
|
1160
|
-
stroke={errored ? "#ef4444" : isSelected ? "#3b82f6" : isDrawSource ? "#f59e0b" : "#475569"}
|
|
1161
|
-
strokeWidth={isSelected ? 2.5 : errored ? 2 : 1.5}
|
|
1162
|
-
strokeDasharray={isFinal ? "6 3" : isComposite ? "4 2" : undefined}
|
|
1163
|
-
className={isActive ? "sm-node-active" : undefined}
|
|
1164
|
-
/>
|
|
1165
|
-
{/* State name */}
|
|
1166
|
-
<text
|
|
1167
|
-
x={pos.x + pos.width / 2}
|
|
1168
|
-
y={pos.y + (hasEntry || hasExit ? pos.height / 2 : pos.height / 2 + 5)}
|
|
1169
|
-
fill="#e2e8f0"
|
|
1170
|
-
fontSize={14}
|
|
1171
|
-
fontWeight={600}
|
|
1172
|
-
textAnchor="middle"
|
|
1173
|
-
className="pointer-events-none select-none"
|
|
1174
|
-
>
|
|
1175
|
-
{state.name}
|
|
1176
|
-
</text>
|
|
1177
|
-
{/* Kind badge */}
|
|
1178
|
-
{state.kind && state.kind !== "normal" && (
|
|
1179
|
-
<text
|
|
1180
|
-
x={pos.x + pos.width - 8}
|
|
1181
|
-
y={pos.y + 14}
|
|
1182
|
-
fill="#64748b"
|
|
1183
|
-
fontSize={10}
|
|
1184
|
-
textAnchor="end"
|
|
1185
|
-
className="pointer-events-none select-none"
|
|
1186
|
-
>
|
|
1187
|
-
{state.kind}
|
|
1188
|
-
</text>
|
|
1189
|
-
)}
|
|
1190
|
-
{/* Entry/exit indicators */}
|
|
1191
|
-
{hasEntry && (
|
|
1192
|
-
<text x={pos.x + 6} y={pos.y + pos.height - 6} fill="#22c55e" fontSize={9} className="pointer-events-none select-none">
|
|
1193
|
-
▸entry
|
|
1194
|
-
</text>
|
|
1195
|
-
)}
|
|
1196
|
-
{hasExit && (
|
|
1197
|
-
<text x={pos.x + pos.width - 6} y={pos.y + pos.height - 6} fill="#f97316" fontSize={9} textAnchor="end" className="pointer-events-none select-none">
|
|
1198
|
-
exit◂
|
|
1199
|
-
</text>
|
|
1200
|
-
)}
|
|
1201
|
-
{/* Error indicator */}
|
|
1202
|
-
{errored && (
|
|
1203
|
-
<text x={pos.x + 6} y={pos.y + 14} fill="#ef4444" fontSize={12} className="pointer-events-none select-none">
|
|
1204
|
-
⚠
|
|
1205
|
-
</text>
|
|
1206
|
-
)}
|
|
1207
|
-
{/* Composite indicator (drill-down hint) */}
|
|
1208
|
-
{isComposite && (
|
|
1209
|
-
<text x={pos.x + pos.width / 2} y={pos.y + pos.height - 6} fill="#6366f1" fontSize={9} textAnchor="middle" className="pointer-events-none select-none">
|
|
1210
|
-
▼ {state.states?.length} children — double-click to enter
|
|
1211
|
-
</text>
|
|
1212
|
-
)}
|
|
1213
|
-
</g>
|
|
1214
|
-
</g>
|
|
1215
|
-
);
|
|
1216
|
-
})}
|
|
1217
|
-
|
|
1218
|
-
{/* Exiting nodes — outlive React's unmount to animate out (WS7b). Rendered
|
|
1219
|
-
from captured def + last position; non-interactive; removed on end. */}
|
|
1220
|
-
{Array.from(exitingNodes.entries()).map(([name, { state, pos }]) => {
|
|
1221
|
-
const exFinal = state.kind === "final";
|
|
1222
|
-
const exComposite = (state.states?.length ?? 0) > 0;
|
|
1223
|
-
return (
|
|
1224
|
-
<g
|
|
1225
|
-
key={`exit-${name}`}
|
|
1226
|
-
className="sm-node-exit"
|
|
1227
|
-
style={{ pointerEvents: "none" }}
|
|
1228
|
-
onAnimationEnd={(e) => {
|
|
1229
|
-
if (e.animationName !== "sm-node-out") return;
|
|
1230
|
-
setExitingNodes((prev) => {
|
|
1231
|
-
if (!prev.has(name)) return prev;
|
|
1232
|
-
const next = new Map(prev);
|
|
1233
|
-
next.delete(name);
|
|
1234
|
-
return next;
|
|
1235
|
-
});
|
|
1236
|
-
}}
|
|
1237
|
-
>
|
|
1238
|
-
<rect
|
|
1239
|
-
x={pos.x}
|
|
1240
|
-
y={pos.y}
|
|
1241
|
-
width={pos.width}
|
|
1242
|
-
height={pos.height}
|
|
1243
|
-
rx={state.kind === "history" ? 30 : exFinal ? 4 : 8}
|
|
1244
|
-
fill={exFinal ? "#1e293b" : exComposite ? "#0c1020" : "#0f172a"}
|
|
1245
|
-
stroke="#475569"
|
|
1246
|
-
strokeWidth={1.5}
|
|
1247
|
-
strokeDasharray={exFinal ? "6 3" : exComposite ? "4 2" : undefined}
|
|
1248
|
-
/>
|
|
1249
|
-
<text
|
|
1250
|
-
x={pos.x + pos.width / 2}
|
|
1251
|
-
y={pos.y + pos.height / 2 + 5}
|
|
1252
|
-
fill="#e2e8f0"
|
|
1253
|
-
fontSize={14}
|
|
1254
|
-
fontWeight={600}
|
|
1255
|
-
textAnchor="middle"
|
|
1256
|
-
className="select-none"
|
|
1257
|
-
>
|
|
1258
|
-
{name}
|
|
1259
|
-
</text>
|
|
1260
|
-
</g>
|
|
1261
|
-
);
|
|
1262
|
-
})}
|
|
1263
|
-
</g>
|
|
1264
|
-
{/* — end of the navigated world; everything below is screen space — */}
|
|
1265
|
-
|
|
1266
|
-
{/* Empty state message */}
|
|
1267
|
-
{currentChildren.length === 0 && (
|
|
1268
|
-
<text x="50%" y="50%" fill="#475569" fontSize={16} textAnchor="middle">
|
|
1269
|
-
{coarsePointer
|
|
1270
|
-
? "Press and hold to add a state, or load a .smdf.json file"
|
|
1271
|
-
: "Right-click to add a state, or load a .smdf.json file"}
|
|
1272
|
-
</text>
|
|
1273
|
-
)}
|
|
1274
|
-
</svg>
|
|
1275
|
-
|
|
1276
|
-
{/* Navigation HUD — screen space, never transformed. The buttons grow for
|
|
1277
|
-
a fingertip and only then; a cursor keeps the compact bar. */}
|
|
1278
|
-
<div className="absolute bottom-3 left-3 z-10 flex items-center gap-0.5 bg-gray-900/90 border border-gray-700 rounded-lg px-1.5 py-1 text-xs text-gray-400 select-none">
|
|
1279
|
-
<button
|
|
1280
|
-
className={`${hudButton} rounded hover:bg-gray-800 hover:text-gray-200 disabled:opacity-40 disabled:hover:bg-transparent`}
|
|
1281
|
-
title="Zoom out (Ctrl + wheel down, or pinch in)"
|
|
1282
|
-
disabled={viewport.scale <= VIEWPORT_LIMITS.min}
|
|
1283
|
-
onClick={() => zoomFromCentre(1 / 1.25)}
|
|
1284
|
-
>
|
|
1285
|
-
−
|
|
1286
|
-
</button>
|
|
1287
|
-
<button
|
|
1288
|
-
className={`${hudButton} rounded hover:bg-gray-800 hover:text-gray-200 tabular-nums min-w-[3.25rem]`}
|
|
1289
|
-
title="Reset zoom to 100%"
|
|
1290
|
-
onClick={resetZoom}
|
|
1291
|
-
>
|
|
1292
|
-
{Math.round(viewport.scale * 100)}%
|
|
1293
|
-
</button>
|
|
1294
|
-
<button
|
|
1295
|
-
className={`${hudButton} rounded hover:bg-gray-800 hover:text-gray-200 disabled:opacity-40 disabled:hover:bg-transparent`}
|
|
1296
|
-
title="Zoom in (Ctrl + wheel up, or pinch out)"
|
|
1297
|
-
disabled={viewport.scale >= VIEWPORT_LIMITS.max}
|
|
1298
|
-
onClick={() => zoomFromCentre(1.25)}
|
|
1299
|
-
>
|
|
1300
|
-
+
|
|
1301
|
-
</button>
|
|
1302
|
-
<span className="text-gray-700 px-0.5">|</span>
|
|
1303
|
-
<button
|
|
1304
|
-
className={`${hudButton} rounded hover:bg-gray-800 hover:text-gray-200`}
|
|
1305
|
-
title="Fit the whole machine in view"
|
|
1306
|
-
onClick={fitToView}
|
|
1307
|
-
>
|
|
1308
|
-
⤢ Fit
|
|
1309
|
-
</button>
|
|
1310
|
-
<span className="text-gray-700 px-0.5">|</span>
|
|
1311
|
-
<span
|
|
1312
|
-
className="px-1 text-[10px] text-gray-600"
|
|
1313
|
-
title={
|
|
1314
|
-
coarsePointer
|
|
1315
|
-
? "Drag to pan · pinch with two fingers to zoom · press and hold for the menu · double-tap a composite to enter it"
|
|
1316
|
-
: "Wheel scrolls · Shift+wheel scrolls sideways · Ctrl+wheel zooms at the cursor · middle-drag or Space+drag pans"
|
|
1317
|
-
}
|
|
1318
|
-
>
|
|
1319
|
-
{pinching
|
|
1320
|
-
? "pinching"
|
|
1321
|
-
: panning
|
|
1322
|
-
? "panning"
|
|
1323
|
-
: coarsePointer
|
|
1324
|
-
? "drag · pinch · hold"
|
|
1325
|
-
: spaceHeld
|
|
1326
|
-
? "space to pan"
|
|
1327
|
-
: "wheel · ⌃wheel · space"}
|
|
1328
|
-
</span>
|
|
1329
|
-
</div>
|
|
1330
|
-
|
|
1331
|
-
{/* Event picker popup */}
|
|
1332
|
-
{eventPicker && (
|
|
1333
|
-
<div
|
|
1334
|
-
className="absolute z-50 bg-gray-900 border border-gray-700 rounded-lg shadow-xl p-2 min-w-[160px]"
|
|
1335
|
-
style={{ left: eventPicker.x, top: eventPicker.y - 80 }}
|
|
1336
|
-
onClick={(e) => e.stopPropagation()}
|
|
1337
|
-
>
|
|
1338
|
-
<div className="text-xs text-gray-400 mb-1 px-1">
|
|
1339
|
-
{eventPicker.stateName} → {eventPicker.targetName}
|
|
1340
|
-
</div>
|
|
1341
|
-
<div className="text-xs text-gray-500 mb-1 px-1">Select event:</div>
|
|
1342
|
-
{allEvents.length === 0 ? (
|
|
1343
|
-
<div className="text-xs text-gray-600 px-1 py-2">No events defined. Add events first.</div>
|
|
1344
|
-
) : (
|
|
1345
|
-
allEvents.map((evt) => (
|
|
1346
|
-
<button
|
|
1347
|
-
key={evt.id}
|
|
1348
|
-
className="block w-full text-left text-sm text-gray-300 hover:bg-gray-800 px-2 py-1 rounded"
|
|
1349
|
-
onClick={() => handleEventPick(evt.id)}
|
|
1350
|
-
>
|
|
1351
|
-
{evt.id}
|
|
1352
|
-
</button>
|
|
1353
|
-
))
|
|
1354
|
-
)}
|
|
1355
|
-
<button
|
|
1356
|
-
className="block w-full text-left text-xs text-gray-500 hover:text-gray-300 px-2 py-1 mt-1 border-t border-gray-800"
|
|
1357
|
-
onClick={() => { setEventPicker(null); setDrawSource(null); }}
|
|
1358
|
-
>
|
|
1359
|
-
Cancel
|
|
1360
|
-
</button>
|
|
1361
|
-
</div>
|
|
1362
|
-
)}
|
|
1363
|
-
</div>
|
|
101
|
+
onCreateTransition={(from, to, event) => {
|
|
102
|
+
addTransition(from, { event, nextState: to });
|
|
103
|
+
setDrawSource(null);
|
|
104
|
+
setDrawMode("select");
|
|
105
|
+
}}
|
|
106
|
+
onCancelDraw={() => {
|
|
107
|
+
setDrawMode("select");
|
|
108
|
+
setDrawSource(null);
|
|
109
|
+
}}
|
|
110
|
+
onDeleteState={(name) => {
|
|
111
|
+
removeState(name);
|
|
112
|
+
clearSelection();
|
|
113
|
+
}}
|
|
114
|
+
onUndo={undo}
|
|
115
|
+
onRedo={redo}
|
|
116
|
+
/>
|
|
1364
117
|
);
|
|
1365
118
|
}
|