castle-web-cli 0.4.104 → 0.4.106
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/dist/agent-failures.d.ts +6 -0
- package/dist/agent-failures.js +39 -7
- package/dist/agent.js +79 -74
- package/dist/shell/assets/{index-BoH7rGKy.js → index-B-RU1Sa9.js} +1 -1
- package/dist/shell/assets/{index-DIcWN-RS.css → index-B2quTIav.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PxArtEditor.jsx +71 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +72 -1
- package/kits/physics-2d/editors/editorHistory.js +18 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +41 -8
- package/kits/physics-2d/editors/pxArtTimeline.module.css +18 -0
- package/kits/physics-2d/engine/gestureZoomPan.js +145 -0
- package/kits/physics-2d/engine/ui.jsx +208 -71
- package/kits/physics-2d/engine/ui.module.css +162 -0
- package/package.json +1 -1
|
@@ -50,6 +50,7 @@ import { SelectionOverlay } from './SelectionOverlay';
|
|
|
50
50
|
import { BlueprintLibrary, BlueprintThumbnail } from './BlueprintLibrary';
|
|
51
51
|
import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
|
|
52
52
|
import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
|
|
53
|
+
import { useTwoFingerZoomPan } from '../engine/gestureZoomPan';
|
|
53
54
|
// A blueprint file's `actors[0]` has no `id` (it's the template, not an
|
|
54
55
|
// instance) -- injected/stripped at the read/write boundary below so the rest
|
|
55
56
|
// of this editor (selection, drag, inspector) can keep treating it like any
|
|
@@ -69,6 +70,18 @@ const ZOOM_CONTROLS_STYLE = {
|
|
|
69
70
|
gap: 4,
|
|
70
71
|
zIndex: 5,
|
|
71
72
|
};
|
|
73
|
+
// Floating undo/redo cluster in the top-left of the stage — the flow single-editor
|
|
74
|
+
// mount renders no header, so this is the only on-screen undo affordance there
|
|
75
|
+
// (the two/three-finger tap gestures are the touch equivalent).
|
|
76
|
+
const UNDO_CONTROLS_STYLE = {
|
|
77
|
+
position: 'absolute',
|
|
78
|
+
left: 10,
|
|
79
|
+
top: 10,
|
|
80
|
+
display: 'flex',
|
|
81
|
+
flexDirection: 'row',
|
|
82
|
+
gap: 6,
|
|
83
|
+
zIndex: 5,
|
|
84
|
+
};
|
|
72
85
|
const EDIT_VIEWPORT = {
|
|
73
86
|
width: cardSize.width * 3,
|
|
74
87
|
height: cardSize.height * 3,
|
|
@@ -291,6 +304,24 @@ export function SceneEditor({
|
|
|
291
304
|
recordSceneSnapshot: history.recordSnapshot,
|
|
292
305
|
});
|
|
293
306
|
const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
|
|
307
|
+
// Touch: one finger selects/drags actors, a second finger pinch-zooms about the
|
|
308
|
+
// centroid and pans. The second finger aborts the in-flight one-finger drag so
|
|
309
|
+
// it doesn't strand a half-move. Off in play — the running game owns touch.
|
|
310
|
+
useTwoFingerZoomPan({
|
|
311
|
+
targetRef: canvasRef,
|
|
312
|
+
enabled: !isPlaying,
|
|
313
|
+
onGestureStart: () => {
|
|
314
|
+
gesture.cancel();
|
|
315
|
+
panGesture.cancel();
|
|
316
|
+
},
|
|
317
|
+
onGestureMove: ({ factor, cx, cy, dx, dy }) => {
|
|
318
|
+
panGesture.zoomByFactorAtPointer(cx, cy, factor);
|
|
319
|
+
panGesture.panByScreenDelta(-dx, -dy);
|
|
320
|
+
},
|
|
321
|
+
// Procreate convention: two-finger tap undoes, three-finger tap redoes.
|
|
322
|
+
onUndo: () => history.undo(),
|
|
323
|
+
onRedo: () => history.redo(),
|
|
324
|
+
});
|
|
294
325
|
useSelectionKeyboard({
|
|
295
326
|
sceneData,
|
|
296
327
|
selectedActorIds,
|
|
@@ -533,6 +564,9 @@ export function SceneEditor({
|
|
|
533
564
|
<canvas
|
|
534
565
|
ref={canvasRef}
|
|
535
566
|
className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
|
|
567
|
+
// Own the raw multi-touch stream so a two-finger pinch zooms the
|
|
568
|
+
// editor instead of the browser page (see useTwoFingerZoomPan).
|
|
569
|
+
style={{ touchAction: 'none' }}
|
|
536
570
|
// Focusable so pointer-down pulls keyboard focus into this
|
|
537
571
|
// editor iframe and Cmd+Z reaches our undo handler instead of
|
|
538
572
|
// the browser's (see PxArtEditor startTool for the full
|
|
@@ -592,6 +626,22 @@ export function SceneEditor({
|
|
|
592
626
|
/>
|
|
593
627
|
)}
|
|
594
628
|
</div>
|
|
629
|
+
{!isPlaying && (
|
|
630
|
+
<div style={UNDO_CONTROLS_STYLE}>
|
|
631
|
+
<IconButton
|
|
632
|
+
icon="undo"
|
|
633
|
+
label="Undo"
|
|
634
|
+
onClick={history.undo}
|
|
635
|
+
disabled={!history.canUndo}
|
|
636
|
+
/>
|
|
637
|
+
<IconButton
|
|
638
|
+
icon="redo"
|
|
639
|
+
label="Redo"
|
|
640
|
+
onClick={history.redo}
|
|
641
|
+
disabled={!history.canRedo}
|
|
642
|
+
/>
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
595
645
|
{!isPlaying && (
|
|
596
646
|
<div style={ZOOM_CONTROLS_STYLE}>
|
|
597
647
|
<IconButton
|
|
@@ -949,7 +999,20 @@ function useSelectionGesture(args) {
|
|
|
949
999
|
current.marqueeRef.current = null;
|
|
950
1000
|
dragRef.current = null;
|
|
951
1001
|
}, []);
|
|
952
|
-
|
|
1002
|
+
// Abandon an in-flight select/move/marquee drag (a second finger has started a
|
|
1003
|
+
// zoom/pan gesture). Clears the pending long-press and marquee so nothing is
|
|
1004
|
+
// left half-applied; a partial actor-move stays where it landed (undoable).
|
|
1005
|
+
const cancel = useCallback(() => {
|
|
1006
|
+
const drag = dragRef.current;
|
|
1007
|
+
if (!drag) return;
|
|
1008
|
+
if (drag.longPressTimer !== null) {
|
|
1009
|
+
window.clearTimeout(drag.longPressTimer);
|
|
1010
|
+
drag.longPressTimer = null;
|
|
1011
|
+
}
|
|
1012
|
+
argsRef.current.marqueeRef.current = null;
|
|
1013
|
+
dragRef.current = null;
|
|
1014
|
+
}, []);
|
|
1015
|
+
return { onPointerDown, onPointerMove, onPointerUp, cancel };
|
|
953
1016
|
}
|
|
954
1017
|
function getSnapSettings(sceneData) {
|
|
955
1018
|
const rawSize = sceneData.editor?.gridSize;
|
|
@@ -1200,6 +1263,11 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1200
1263
|
}, []);
|
|
1201
1264
|
const isSpacePanning = useCallback(() => spaceDownRef.current, []);
|
|
1202
1265
|
const isActive = useCallback(() => Boolean(dragRef.current), []);
|
|
1266
|
+
// Drop an in-flight pan drag without applying anything more (used when a
|
|
1267
|
+
// two-finger gesture takes over from a one-finger pan).
|
|
1268
|
+
const cancel = useCallback(() => {
|
|
1269
|
+
dragRef.current = null;
|
|
1270
|
+
}, []);
|
|
1203
1271
|
// Button-driven zoom about the canvas center, and a reset-to-fit that restores
|
|
1204
1272
|
// the initial camera (zoom 1 = the card exactly fills the viewport).
|
|
1205
1273
|
const zoomAtCenter = useCallback(
|
|
@@ -1220,8 +1288,11 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1220
1288
|
onPointerUp,
|
|
1221
1289
|
isSpacePanning,
|
|
1222
1290
|
isActive,
|
|
1291
|
+
cancel,
|
|
1223
1292
|
zoomAtCenter,
|
|
1224
1293
|
resetView,
|
|
1294
|
+
zoomByFactorAtPointer,
|
|
1295
|
+
panByScreenDelta,
|
|
1225
1296
|
};
|
|
1226
1297
|
}
|
|
1227
1298
|
function handleShiftPointerDown(drag, actor, current) {
|
|
@@ -66,6 +66,10 @@ export function useEditHistory(text, onChange, path) {
|
|
|
66
66
|
// { key, time } of the most recent coalescable commit. Undo/redo/
|
|
67
67
|
// recordSnapshot null this out so a later commit never coalesces across them.
|
|
68
68
|
const coalesceRef = useRef({ key: null, time: 0 });
|
|
69
|
+
// Redo stack that the most recent recordSnapshot cleared, so cancelSnapshot can
|
|
70
|
+
// put it back if that gesture is aborted (e.g. a second finger starts a
|
|
71
|
+
// pinch/tap over an in-progress stroke) instead of leaving redo destroyed.
|
|
72
|
+
const lastSnapshotRedoRef = useRef(null);
|
|
69
73
|
function commit(nextText, { coalesceKey } = {}) {
|
|
70
74
|
if (nextText === text) return;
|
|
71
75
|
const now = Date.now();
|
|
@@ -85,11 +89,24 @@ export function useEditHistory(text, onChange, path) {
|
|
|
85
89
|
}
|
|
86
90
|
function recordSnapshot() {
|
|
87
91
|
coalesceRef.current = { key: null, time: 0 };
|
|
92
|
+
lastSnapshotRedoRef.current = historyRef.current.redo;
|
|
88
93
|
setHistory((current) => ({
|
|
89
94
|
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
90
95
|
redo: [],
|
|
91
96
|
}));
|
|
92
97
|
}
|
|
98
|
+
// Reverse the most recent recordSnapshot: drop the snapshot it pushed onto undo
|
|
99
|
+
// and restore the redo stack it cleared. For aborting a stroke that recorded but
|
|
100
|
+
// must leave no trace (its file text is reverted separately). Safe to call when
|
|
101
|
+
// nothing was recorded — it just no-ops the redo restore.
|
|
102
|
+
function cancelSnapshot() {
|
|
103
|
+
const savedRedo = lastSnapshotRedoRef.current;
|
|
104
|
+
lastSnapshotRedoRef.current = null;
|
|
105
|
+
setHistory((current) => ({
|
|
106
|
+
undo: current.undo.slice(0, -1),
|
|
107
|
+
redo: savedRedo ?? current.redo,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
93
110
|
function undo() {
|
|
94
111
|
coalesceRef.current = { key: null, time: 0 };
|
|
95
112
|
const current = historyRef.current;
|
|
@@ -121,6 +138,7 @@ export function useEditHistory(text, onChange, path) {
|
|
|
121
138
|
canUndo: trimTrailingNoOps(history.undo, text).length > 0,
|
|
122
139
|
canRedo: trimLeadingNoOps(history.redo, text).length > 0,
|
|
123
140
|
recordSnapshot,
|
|
141
|
+
cancelSnapshot,
|
|
124
142
|
};
|
|
125
143
|
}
|
|
126
144
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { faEye, faEyeSlash, faLink, faStopwatch } from '@fortawesome/free-solid-svg-icons';
|
|
3
3
|
import { colorForKeyV2, paletteLookup, resolveCell, resolveCellGrid } from '../engine/pxart';
|
|
4
|
-
import { Icon, IconButton, styles as ui } from '../engine/ui';
|
|
4
|
+
import { Icon, IconButton, SheetGrabHandle, styles as ui, useCompactViewport, useMobileSheet } from '../engine/ui';
|
|
5
5
|
import { cellKind, forEachGridCell, setupSpriteCanvas } from './pxArtEditorModel';
|
|
6
6
|
import tl from './pxArtTimeline.module.css';
|
|
7
7
|
|
|
@@ -132,15 +132,50 @@ function useResizableTimelineHeight() {
|
|
|
132
132
|
// frame controls, linked-cell affordances, onion-skin toggles, playback, and a
|
|
133
133
|
// tags editor. All mutation flows through `actions` (wired in PxArtEditor).
|
|
134
134
|
export function PxArtTimeline({ sprite, selection, playing, onion, actions }) {
|
|
135
|
-
|
|
135
|
+
// Hooks run unconditionally (order-stable); only the render branches on viewport.
|
|
136
|
+
const compact = useCompactViewport();
|
|
137
|
+
const resizable = useResizableTimelineHeight();
|
|
138
|
+
// Default to the collapsed peek so the canvas starts with the most room; a fresh
|
|
139
|
+
// storage key so an earlier expanded height from testing doesn't override that.
|
|
140
|
+
const sheet = useMobileSheet({
|
|
141
|
+
baseClassName: tl.timelineDrawer,
|
|
142
|
+
storageKey: 'pxart-drawer',
|
|
143
|
+
defaultPeek: true,
|
|
144
|
+
});
|
|
145
|
+
const grid = (
|
|
146
|
+
<div className={tl.scroll}>
|
|
147
|
+
<TimelineGrid sprite={sprite} selection={selection} actions={actions} />
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
if (compact) {
|
|
151
|
+
// Mobile: a collapsible bottom drawer. The peek height shows the grab handle +
|
|
152
|
+
// the CURRENT layer; pull the handle up to reveal the full layers x frames grid.
|
|
153
|
+
// The height feeds the in-flow .editorLeft column, so collapsing hands the freed
|
|
154
|
+
// space straight to the canvas.
|
|
155
|
+
return (
|
|
156
|
+
<section {...sheet.rootProps}>
|
|
157
|
+
<div {...sheet.grabProps}>
|
|
158
|
+
<SheetGrabHandle />
|
|
159
|
+
</div>
|
|
160
|
+
<TimelineBar
|
|
161
|
+
sprite={sprite}
|
|
162
|
+
selection={selection}
|
|
163
|
+
playing={playing}
|
|
164
|
+
onion={onion}
|
|
165
|
+
actions={actions}
|
|
166
|
+
/>
|
|
167
|
+
{grid}
|
|
168
|
+
</section>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
136
171
|
return (
|
|
137
|
-
<section ref={sectionRef} className={tl.timeline} style={{ height: `${height}px` }}>
|
|
172
|
+
<section ref={resizable.sectionRef} className={tl.timeline} style={{ height: `${resizable.height}px` }}>
|
|
138
173
|
<div
|
|
139
|
-
className={dragging ? tl.resizeHandle + ' ' + tl.resizeHandleActive : tl.resizeHandle}
|
|
174
|
+
className={resizable.dragging ? tl.resizeHandle + ' ' + tl.resizeHandleActive : tl.resizeHandle}
|
|
140
175
|
role="separator"
|
|
141
176
|
aria-orientation="horizontal"
|
|
142
177
|
aria-label="Resize timeline"
|
|
143
|
-
onPointerDown={startDrag}
|
|
178
|
+
onPointerDown={resizable.startDrag}
|
|
144
179
|
/>
|
|
145
180
|
<TimelineBar
|
|
146
181
|
sprite={sprite}
|
|
@@ -149,9 +184,7 @@ export function PxArtTimeline({ sprite, selection, playing, onion, actions }) {
|
|
|
149
184
|
onion={onion}
|
|
150
185
|
actions={actions}
|
|
151
186
|
/>
|
|
152
|
-
|
|
153
|
-
<TimelineGrid sprite={sprite} selection={selection} actions={actions} />
|
|
154
|
-
</div>
|
|
187
|
+
{grid}
|
|
155
188
|
{SHOW_TAGS ? <TagsPanel sprite={sprite} actions={actions} /> : null}
|
|
156
189
|
</section>
|
|
157
190
|
);
|
|
@@ -137,6 +137,24 @@
|
|
|
137
137
|
overflow: auto;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/* Mobile: the timeline is a collapsible in-flow drawer. Its height is driven by
|
|
141
|
+
useMobileSheet's --sheet-h and it's a flex:0 child of .editorLeft, so collapsing
|
|
142
|
+
it (drag the grab handle down / tap) hands the freed height to the canvas. */
|
|
143
|
+
.timelineDrawer {
|
|
144
|
+
position: relative;
|
|
145
|
+
flex: 0 0 auto;
|
|
146
|
+
height: var(--sheet-h, 40vh);
|
|
147
|
+
min-height: 0;
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
overflow: hidden;
|
|
151
|
+
border-top: 1px solid var(--castle-inspector-divider);
|
|
152
|
+
background: var(--castle-workspace-bg);
|
|
153
|
+
color: var(--castle-inspector-text);
|
|
154
|
+
font-size: 13px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
140
158
|
.grid {
|
|
141
159
|
display: inline-flex;
|
|
142
160
|
flex-direction: column;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
// A multi-finger contact resolves to a TAP (undo/redo) only if the whole contact
|
|
4
|
+
// is this brief and never moved past the slop; longer or larger is a drag.
|
|
5
|
+
const TAP_MAX_MS = 450;
|
|
6
|
+
// Pinch/pan must move this many px (spread or centroid) before it's treated as a
|
|
7
|
+
// drag. Under it the two fingers are still a candidate tap and nothing is applied
|
|
8
|
+
// — this is what keeps a two-finger TAP (undo) cleanly apart from a pinch.
|
|
9
|
+
const GESTURE_SLOP = 10;
|
|
10
|
+
|
|
11
|
+
// Shared touch gestures for the canvas editors (scene + pxart):
|
|
12
|
+
// one finger -> the editor's own tool (draw / select), untouched here
|
|
13
|
+
// two-finger drag -> pinch-zoom about the centroid + pan (onGestureMove)
|
|
14
|
+
// two-finger TAP -> undo (onUndo)
|
|
15
|
+
// three-finger TAP -> redo (onRedo) [Procreate convention]
|
|
16
|
+
//
|
|
17
|
+
// Tap resolution waits for EVERY finger to lift and then keys off the PEAK finger
|
|
18
|
+
// count reached during the contact — so it doesn't matter whether the fingers land
|
|
19
|
+
// or lift together (an early first-lift used to resolve a three-finger tap as two).
|
|
20
|
+
//
|
|
21
|
+
// A second finger aborts the in-flight one-finger action (onGestureStart) so a
|
|
22
|
+
// gesture never strands a half-stroke. Gesture touches are stopPropagation'd so
|
|
23
|
+
// they never reach the editor's own pointer handlers. onGestureMove emits one
|
|
24
|
+
// step: { factor, cx, cy, dx, dy } — a zoom factor about the pinch centroid plus
|
|
25
|
+
// the centroid's screen-space movement — which each editor applies its own way.
|
|
26
|
+
// The target must have `touch-action: none` for the pinch/pan to beat the browser;
|
|
27
|
+
// the taps work without it (a tap doesn't move, so the browser never scrolls).
|
|
28
|
+
export function useTwoFingerZoomPan({
|
|
29
|
+
targetRef,
|
|
30
|
+
enabled = true,
|
|
31
|
+
onGestureStart,
|
|
32
|
+
onGestureMove,
|
|
33
|
+
onGestureEnd,
|
|
34
|
+
onUndo,
|
|
35
|
+
onRedo,
|
|
36
|
+
}) {
|
|
37
|
+
// Callbacks in a ref so the listeners attach once and never need re-binding.
|
|
38
|
+
const cb = useRef(null);
|
|
39
|
+
cb.current = { onGestureStart, onGestureMove, onGestureEnd, onUndo, onRedo };
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!enabled) return undefined;
|
|
42
|
+
const el = targetRef.current;
|
|
43
|
+
if (!el) return undefined;
|
|
44
|
+
const pts = new Map(); // pointerId -> { x, y }
|
|
45
|
+
// Per touch-sequence state (a sequence = first finger down … all fingers up):
|
|
46
|
+
let maxFingers = 0; // peak simultaneous fingers — decides undo (2) vs redo (3)
|
|
47
|
+
let multiStart = 0; // timeStamp the contact first reached two fingers
|
|
48
|
+
let startDist = 0;
|
|
49
|
+
let startCx = 0;
|
|
50
|
+
let startCy = 0;
|
|
51
|
+
let committed = false; // moved past slop → a pinch/pan drag, not a tap
|
|
52
|
+
let lastDist = 0;
|
|
53
|
+
let lastCx = 0;
|
|
54
|
+
let lastCy = 0;
|
|
55
|
+
const isTouch = (event) => event.pointerType === 'touch';
|
|
56
|
+
const twoFinger = () => {
|
|
57
|
+
const [a, b] = [...pts.values()];
|
|
58
|
+
return {
|
|
59
|
+
cx: (a.x + b.x) / 2,
|
|
60
|
+
cy: (a.y + b.y) / 2,
|
|
61
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
const onDown = (event) => {
|
|
65
|
+
if (!isTouch(event)) return;
|
|
66
|
+
pts.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
67
|
+
if (pts.size > maxFingers) maxFingers = pts.size;
|
|
68
|
+
if (pts.size === 2) {
|
|
69
|
+
const m = twoFinger();
|
|
70
|
+
multiStart = event.timeStamp;
|
|
71
|
+
startDist = lastDist = m.dist;
|
|
72
|
+
startCx = lastCx = m.cx;
|
|
73
|
+
startCy = lastCy = m.cy;
|
|
74
|
+
committed = false;
|
|
75
|
+
cb.current.onGestureStart?.(); // abort any in-flight one-finger action
|
|
76
|
+
}
|
|
77
|
+
if (pts.size >= 2) event.stopPropagation();
|
|
78
|
+
};
|
|
79
|
+
const onMove = (event) => {
|
|
80
|
+
if (!isTouch(event) || !pts.has(event.pointerId)) return;
|
|
81
|
+
pts.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
82
|
+
if (pts.size < 2) return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
event.stopPropagation();
|
|
85
|
+
if (pts.size !== 2) return; // zoom/pan is a two-finger interaction only
|
|
86
|
+
const m = twoFinger();
|
|
87
|
+
if (!committed) {
|
|
88
|
+
const spread = Math.abs(m.dist - startDist);
|
|
89
|
+
const slid = Math.hypot(m.cx - startCx, m.cy - startCy);
|
|
90
|
+
if (spread <= GESTURE_SLOP && slid <= GESTURE_SLOP) return; // still a candidate tap
|
|
91
|
+
committed = true;
|
|
92
|
+
// Reset the baseline to here so the first applied step doesn't jump by the
|
|
93
|
+
// whole slop distance.
|
|
94
|
+
lastDist = m.dist;
|
|
95
|
+
lastCx = m.cx;
|
|
96
|
+
lastCy = m.cy;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const factor = lastDist > 0 && m.dist > 0 ? m.dist / lastDist : 1;
|
|
100
|
+
cb.current.onGestureMove?.({
|
|
101
|
+
factor,
|
|
102
|
+
cx: m.cx,
|
|
103
|
+
cy: m.cy,
|
|
104
|
+
dx: m.cx - lastCx,
|
|
105
|
+
dy: m.cy - lastCy,
|
|
106
|
+
});
|
|
107
|
+
lastDist = m.dist;
|
|
108
|
+
lastCx = m.cx;
|
|
109
|
+
lastCy = m.cy;
|
|
110
|
+
};
|
|
111
|
+
const onUp = (event) => {
|
|
112
|
+
if (pts.size >= 2 || committed) event.stopPropagation();
|
|
113
|
+
if (!pts.delete(event.pointerId)) return;
|
|
114
|
+
if (committed && pts.size < 2) cb.current.onGestureEnd?.();
|
|
115
|
+
if (pts.size > 0) return;
|
|
116
|
+
// Every finger is up — resolve the whole contact. A still (never a drag),
|
|
117
|
+
// brief multi-finger tap is undo at peak 2, redo at peak 3. Keying off the
|
|
118
|
+
// peak count makes it immune to the order fingers landed or lifted.
|
|
119
|
+
if (!committed && maxFingers >= 2 && event.timeStamp - multiStart < TAP_MAX_MS) {
|
|
120
|
+
if (maxFingers === 2) cb.current.onUndo?.();
|
|
121
|
+
else if (maxFingers === 3) cb.current.onRedo?.();
|
|
122
|
+
}
|
|
123
|
+
maxFingers = 0;
|
|
124
|
+
committed = false;
|
|
125
|
+
multiStart = 0;
|
|
126
|
+
};
|
|
127
|
+
// CAPTURE phase, not bubble: the artboard canvas captures the first finger
|
|
128
|
+
// (startTool → setPointerCapture) and consumes touches, so a bubble-phase
|
|
129
|
+
// listener misses fingers landing on/near it — three-finger taps rarely
|
|
130
|
+
// registered there. Capturing means we see every pointer BEFORE any descendant
|
|
131
|
+
// can swallow it, so the finger count is reliable wherever the taps land; and
|
|
132
|
+
// stopPropagation on the 2nd/3rd finger then keeps them out of the canvas too.
|
|
133
|
+
const opts = { passive: false, capture: true };
|
|
134
|
+
el.addEventListener('pointerdown', onDown, opts);
|
|
135
|
+
el.addEventListener('pointermove', onMove, opts);
|
|
136
|
+
el.addEventListener('pointerup', onUp, opts);
|
|
137
|
+
el.addEventListener('pointercancel', onUp, opts);
|
|
138
|
+
return () => {
|
|
139
|
+
el.removeEventListener('pointerdown', onDown, opts);
|
|
140
|
+
el.removeEventListener('pointermove', onMove, opts);
|
|
141
|
+
el.removeEventListener('pointerup', onUp, opts);
|
|
142
|
+
el.removeEventListener('pointercancel', onUp, opts);
|
|
143
|
+
};
|
|
144
|
+
}, [targetRef, enabled]);
|
|
145
|
+
}
|