@pluot/react 0.1.16 → 0.1.17
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/index.js +994 -100
- package/dist-tsc/BrushOverlay.d.ts +31 -0
- package/dist-tsc/BrushOverlay.d.ts.map +1 -0
- package/dist-tsc/BrushOverlay.js +91 -0
- package/dist-tsc/Pluot.d.ts +2 -1
- package/dist-tsc/Pluot.d.ts.map +1 -1
- package/dist-tsc/Pluot.js +59 -22
- package/dist-tsc/Tooltip.d.ts +2 -1
- package/dist-tsc/Tooltip.d.ts.map +1 -1
- package/dist-tsc/Tooltip.js +15 -1
- package/dist-tsc/brush.d.ts +155 -0
- package/dist-tsc/brush.d.ts.map +1 -0
- package/dist-tsc/brush.js +312 -0
- package/dist-tsc/brush.test.d.ts +2 -0
- package/dist-tsc/brush.test.d.ts.map +1 -0
- package/dist-tsc/brush.test.js +487 -0
- package/dist-tsc/index.d.ts +3 -1
- package/dist-tsc/index.d.ts.map +1 -1
- package/dist-tsc/index.js +1 -0
- package/dist-tsc/types.d.ts +255 -0
- package/dist-tsc/types.d.ts.map +1 -0
- package/dist-tsc/types.js +11 -0
- package/dist-tsc/use-brush.d.ts +53 -0
- package/dist-tsc/use-brush.d.ts.map +1 -0
- package/dist-tsc/use-brush.js +360 -0
- package/package.json +5 -3
- package/src/BrushOverlay.tsx +254 -0
- package/src/{Pluot.jsx → Pluot.tsx} +132 -46
- package/src/{Tooltip.jsx → Tooltip.tsx} +19 -3
- package/src/brush.test.ts +590 -0
- package/src/brush.ts +435 -0
- package/src/index.ts +24 -0
- package/src/types.ts +378 -0
- package/src/use-brush.ts +501 -0
- package/src/index.js +0 -2
package/src/use-brush.ts
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { throttle } from "lodash-es";
|
|
3
|
+
import type { AspectRatioMode, AspectRatioAlignmentMode, CameraMatrix } from "@pluot/core";
|
|
4
|
+
import {
|
|
5
|
+
clampToBrushRegion,
|
|
6
|
+
getBrushGeometry,
|
|
7
|
+
getClearButtonCenter,
|
|
8
|
+
getEdgeDragCorners,
|
|
9
|
+
getVerticesBoundingBox,
|
|
10
|
+
isDegenerateBrush,
|
|
11
|
+
isPointInBrush,
|
|
12
|
+
rectVerticesFromCorners,
|
|
13
|
+
reprojectBrushState,
|
|
14
|
+
vertexFromPixels,
|
|
15
|
+
type BrushEdge,
|
|
16
|
+
type BrushGeometry,
|
|
17
|
+
} from "./brush.js";
|
|
18
|
+
import { NO_BRUSH } from "./types.js";
|
|
19
|
+
import type { BrushState, BrushVertex, PluotProps } from "./types.js";
|
|
20
|
+
|
|
21
|
+
/** Cursor movement (in pixels) that cancels a pending long-click, since the user is panning instead. */
|
|
22
|
+
const LONG_CLICK_CANCEL_PX = 4;
|
|
23
|
+
|
|
24
|
+
/** Caps how many vertices a lasso drag can produce, per the `brushMode: "Polygon"` contract. */
|
|
25
|
+
const POLYGON_VERTEX_THROTTLE_MS = 40;
|
|
26
|
+
|
|
27
|
+
/** Radius (in pixels) of the hover target around the clear button. */
|
|
28
|
+
export const CLEAR_BUTTON_RADIUS_PX = 9;
|
|
29
|
+
|
|
30
|
+
/** What the overlay needs to draw the long-click progress indicator. */
|
|
31
|
+
export type BrushPressProgress = {
|
|
32
|
+
xPixels: number;
|
|
33
|
+
yPixels: number;
|
|
34
|
+
/** 0 to 1, reaching 1 exactly when `brushDelay` elapses. */
|
|
35
|
+
fraction: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type UseBrushParams = Pick<PluotProps,
|
|
39
|
+
| "brushUnitsModeX" | "brushUnitsModeY"
|
|
40
|
+
| "brushMarginTop" | "brushMarginRight" | "brushMarginBottom" | "brushMarginLeft"
|
|
41
|
+
| "enableBrushCreate" | "enableBrushEdit" | "enableBrushClear"
|
|
42
|
+
| "brushDelay" | "maybeBrushDelay" | "persistBrush" | "brushMode"
|
|
43
|
+
| "brush" | "onBrush" | "onBrushEnd" | "onBrushClear"
|
|
44
|
+
> & {
|
|
45
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
marginTop: number;
|
|
49
|
+
marginRight: number;
|
|
50
|
+
marginBottom: number;
|
|
51
|
+
marginLeft: number;
|
|
52
|
+
aspectRatioMode: AspectRatioMode;
|
|
53
|
+
aspectRatioAlignmentMode: AspectRatioAlignmentMode;
|
|
54
|
+
cameraMatrix: CameraMatrix;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type UseBrushResult = {
|
|
58
|
+
/** The brush to draw, already reprojected into the current geometry. */
|
|
59
|
+
brushState: BrushState | undefined;
|
|
60
|
+
geometry: BrushGeometry;
|
|
61
|
+
/** Must be attached to the overlay SVG, so its handles are excluded from brush creation. */
|
|
62
|
+
overlayRef: RefObject<SVGSVGElement | null>;
|
|
63
|
+
pressProgress: BrushPressProgress | null;
|
|
64
|
+
/** Whether the clear button should be shown (hovering the brush, `enableBrushClear`). */
|
|
65
|
+
isBrushHovered: boolean;
|
|
66
|
+
/** True while a create/edit drag is in flight, so the camera can stand down. */
|
|
67
|
+
isBrushingRef: RefObject<boolean>;
|
|
68
|
+
/** Set when a brush drag just ended, so the ensuing `click` does not also pick. */
|
|
69
|
+
shouldSuppressClickRef: RefObject<boolean>;
|
|
70
|
+
onVertexMouseDown: (vertexIndex: number, event: React.MouseEvent) => void;
|
|
71
|
+
onEdgeMouseDown: (edge: BrushEdge, event: React.MouseEvent) => void;
|
|
72
|
+
onClearClick: (event: React.MouseEvent) => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Tracks the in-flight drag. Kept in a ref, since it does not affect rendering on its own. */
|
|
76
|
+
type ActiveInteraction =
|
|
77
|
+
| { kind: "Create"; anchorX: number; anchorY: number }
|
|
78
|
+
// For a Rect, `fixedX`/`fixedY` is the diagonally opposite corner, which stays put.
|
|
79
|
+
| { kind: "EditVertex"; vertexIndex: number; fixedX: number; fixedY: number }
|
|
80
|
+
// Dragging a side: the cursor drives only `axis`, so the perpendicular extent
|
|
81
|
+
// is carried over from `movingX`/`movingY` and the opposite side stays put.
|
|
82
|
+
| ({ kind: "EditEdge" } & ReturnType<typeof getEdgeDragCorners>);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Implements the brush interactions: long-click to create a rect/lasso, drag a
|
|
86
|
+
* vertex to edit, and click the clear button to cancel.
|
|
87
|
+
*
|
|
88
|
+
* Brushing is controlled when `brush` is a `BrushState` or `undefined`, and
|
|
89
|
+
* uncontrolled when `brush` is `null` (mirroring `cameraMatrix`/`setCameraMatrix`).
|
|
90
|
+
* When controlled, nothing is stored here: updates are emitted via `onBrush`/
|
|
91
|
+
* `onBrushEnd` and the parent is expected to feed them back through `brush`.
|
|
92
|
+
*/
|
|
93
|
+
export function useBrush(params: UseBrushParams): UseBrushResult {
|
|
94
|
+
const {
|
|
95
|
+
containerRef,
|
|
96
|
+
width, height,
|
|
97
|
+
marginTop, marginRight, marginBottom, marginLeft,
|
|
98
|
+
aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
|
|
99
|
+
brushUnitsModeX = "Data",
|
|
100
|
+
brushUnitsModeY = "Data",
|
|
101
|
+
brushMarginTop,
|
|
102
|
+
brushMarginRight,
|
|
103
|
+
brushMarginBottom,
|
|
104
|
+
brushMarginLeft,
|
|
105
|
+
enableBrushCreate = false,
|
|
106
|
+
enableBrushEdit = false,
|
|
107
|
+
enableBrushClear = false,
|
|
108
|
+
brushDelay = 1500,
|
|
109
|
+
maybeBrushDelay = 250,
|
|
110
|
+
persistBrush = false,
|
|
111
|
+
brushMode = "Rect",
|
|
112
|
+
brush: controlledBrush,
|
|
113
|
+
onBrush,
|
|
114
|
+
onBrushEnd,
|
|
115
|
+
onBrushClear,
|
|
116
|
+
} = params;
|
|
117
|
+
|
|
118
|
+
// `null` (or an omitted prop) means uncontrolled; a BrushState or `NO_BRUSH`
|
|
119
|
+
// means controlled, with `NO_BRUSH` standing for "controlled, nothing brushed".
|
|
120
|
+
const isControlledBrush = controlledBrush !== null && controlledBrush !== undefined;
|
|
121
|
+
const [uncontrolledBrush, setUncontrolledBrush] = useState<BrushState | undefined>(undefined);
|
|
122
|
+
const rawBrush: BrushState | undefined = isControlledBrush
|
|
123
|
+
? (controlledBrush === NO_BRUSH ? undefined : controlledBrush)
|
|
124
|
+
: uncontrolledBrush;
|
|
125
|
+
|
|
126
|
+
// A parent may switch between controlled and uncontrolled at runtime. Whatever
|
|
127
|
+
// was stored during an earlier uncontrolled phase is not the current selection,
|
|
128
|
+
// so drop it rather than let it resurface if the brush ever goes back.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (isControlledBrush) {
|
|
131
|
+
setUncontrolledBrush(undefined);
|
|
132
|
+
}
|
|
133
|
+
}, [isControlledBrush]);
|
|
134
|
+
|
|
135
|
+
const [pressProgress, setPressProgress] = useState<BrushPressProgress | null>(null);
|
|
136
|
+
const [isBrushHovered, setIsBrushHovered] = useState(false);
|
|
137
|
+
|
|
138
|
+
const isBrushingRef = useRef(false);
|
|
139
|
+
const shouldSuppressClickRef = useRef(false);
|
|
140
|
+
// The overlay SVG, so that presses on its handles can be told apart from
|
|
141
|
+
// presses on the plot itself.
|
|
142
|
+
const overlayRef = useRef<SVGSVGElement | null>(null);
|
|
143
|
+
const interactionRef = useRef<ActiveInteraction | null>(null);
|
|
144
|
+
// The in-progress brush, so that incremental updates (e.g. appending lasso
|
|
145
|
+
// vertices) do not depend on a controlled parent having fed state back yet.
|
|
146
|
+
const draftRef = useRef<BrushState | null>(null);
|
|
147
|
+
// Bookkeeping for the pending long-click, before any brush exists.
|
|
148
|
+
const pendingPressRef = useRef<{ x: number, y: number, startTime: number, rafId: number } | null>(null);
|
|
149
|
+
|
|
150
|
+
const geometry = useMemo(() => getBrushGeometry({
|
|
151
|
+
width, height,
|
|
152
|
+
marginTop, marginRight, marginBottom, marginLeft,
|
|
153
|
+
brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
|
|
154
|
+
brushUnitsModeX, brushUnitsModeY,
|
|
155
|
+
aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
|
|
156
|
+
}), [
|
|
157
|
+
width, height, marginTop, marginRight, marginBottom, marginLeft,
|
|
158
|
+
brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
|
|
159
|
+
brushUnitsModeX, brushUnitsModeY,
|
|
160
|
+
aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
// Re-derive the pixel positions under the current geometry, so that a brush
|
|
164
|
+
// with a "Data" units mode follows the camera as the user zooms/pans.
|
|
165
|
+
const brushState = useMemo(
|
|
166
|
+
() => (rawBrush ? reprojectBrushState(rawBrush, geometry, brushUnitsModeX, brushUnitsModeY) : undefined),
|
|
167
|
+
[rawBrush, geometry, brushUnitsModeX, brushUnitsModeY],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Convert a mouse event to a position relative to the top-left of the
|
|
171
|
+
// container, which is the coordinate space of the brush overlay SVG.
|
|
172
|
+
// `offsetX`/`offsetY` are unusable here because a drag may travel over
|
|
173
|
+
// several descendants (or leave the container entirely).
|
|
174
|
+
const getContainerCoords = useCallback((event: MouseEvent | React.MouseEvent): [number, number] => {
|
|
175
|
+
const containerEl = containerRef.current;
|
|
176
|
+
if (!containerEl) {
|
|
177
|
+
return [0, 0];
|
|
178
|
+
}
|
|
179
|
+
const rect = containerEl.getBoundingClientRect();
|
|
180
|
+
return [event.clientX - rect.left, event.clientY - rect.top];
|
|
181
|
+
}, [containerRef]);
|
|
182
|
+
|
|
183
|
+
// Push a brush update out: internally when uncontrolled, and to the parent in
|
|
184
|
+
// both cases. Until the Rust-side `Brushable` trait lands there is nothing to
|
|
185
|
+
// snap to, so the snapped state is the state and the `BrushResult` is unused.
|
|
186
|
+
const emitBrush = useEffectEvent((nextBrush: BrushState, isEnd: boolean) => {
|
|
187
|
+
// The draft is always advanced, since the rest of the drag builds on it...
|
|
188
|
+
draftRef.current = nextBrush;
|
|
189
|
+
// ...but a brush that spans nothing is not a selection. Committing one would
|
|
190
|
+
// strand a stray dot on screen (four coincident vertex handles) that the user
|
|
191
|
+
// then has to clear, so hold it back until the drag gives it some extent.
|
|
192
|
+
if (isDegenerateBrush(nextBrush)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!isControlledBrush) {
|
|
196
|
+
setUncontrolledBrush(nextBrush);
|
|
197
|
+
}
|
|
198
|
+
if (isEnd) {
|
|
199
|
+
onBrushEnd?.(nextBrush, nextBrush);
|
|
200
|
+
// `persistBrush` only applies when uncontrolled; when controlled, the brush
|
|
201
|
+
// persists for exactly as long as the parent keeps passing it.
|
|
202
|
+
if (!isControlledBrush && !persistBrush) {
|
|
203
|
+
setUncontrolledBrush(undefined);
|
|
204
|
+
draftRef.current = null;
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
onBrush?.(nextBrush, nextBrush);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const clearBrush = useEffectEvent(() => {
|
|
212
|
+
const cleared = draftRef.current ?? rawBrush;
|
|
213
|
+
draftRef.current = null;
|
|
214
|
+
if (!isControlledBrush) {
|
|
215
|
+
setUncontrolledBrush(undefined);
|
|
216
|
+
}
|
|
217
|
+
setIsBrushHovered(false);
|
|
218
|
+
if (cleared) {
|
|
219
|
+
onBrushClear?.(cleared);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// --- Drawing a new brush ---
|
|
224
|
+
|
|
225
|
+
const startBrush = useEffectEvent((xPixels: number, yPixels: number) => {
|
|
226
|
+
const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
|
|
227
|
+
isBrushingRef.current = true;
|
|
228
|
+
interactionRef.current = { kind: "Create", anchorX: x, anchorY: y };
|
|
229
|
+
const vertices: BrushVertex[] = brushMode === "Polygon"
|
|
230
|
+
? [vertexFromPixels(x, y, geometry)]
|
|
231
|
+
: rectVerticesFromCorners(x, y, x, y, geometry, brushMode);
|
|
232
|
+
emitBrush({ status: "Drawing", shape: brushMode, vertices }, false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const appendPolygonVertex = useEffectEvent((xPixels: number, yPixels: number) => {
|
|
236
|
+
const draft = draftRef.current;
|
|
237
|
+
if (!draft || draft.shape !== "Polygon") {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
|
|
241
|
+
emitBrush({ ...draft, vertices: [...draft.vertices, vertexFromPixels(x, y, geometry)] }, false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// The lasso samples the cursor on a timer rather than on every mousemove, to
|
|
245
|
+
// keep the vertex count bounded regardless of how slowly the user drags.
|
|
246
|
+
const throttledAppendPolygonVertex = useMemo(
|
|
247
|
+
() => throttle(appendPolygonVertex, POLYGON_VERTEX_THROTTLE_MS, { leading: true, trailing: true }),
|
|
248
|
+
[],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const updateRect = useEffectEvent((xPixels: number, yPixels: number) => {
|
|
252
|
+
const interaction = interactionRef.current;
|
|
253
|
+
const draft = draftRef.current;
|
|
254
|
+
if (!interaction || !draft || draft.shape === "Polygon") {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
|
|
258
|
+
|
|
259
|
+
// Every rect-like drag reduces to a fixed corner plus a moving one. While
|
|
260
|
+
// creating, the fixed corner is the press point; while dragging a corner, it
|
|
261
|
+
// is the corner diagonally opposite; while dragging a side, it is a corner of
|
|
262
|
+
// the opposite side, and the cursor drives only one axis of the moving corner.
|
|
263
|
+
const fixedX = interaction.kind === "Create" ? interaction.anchorX : interaction.fixedX;
|
|
264
|
+
const fixedY = interaction.kind === "Create" ? interaction.anchorY : interaction.fixedY;
|
|
265
|
+
const movingX = interaction.kind === "EditEdge" && interaction.axis === "Y" ? interaction.movingX : x;
|
|
266
|
+
const movingY = interaction.kind === "EditEdge" && interaction.axis === "X" ? interaction.movingY : y;
|
|
267
|
+
|
|
268
|
+
emitBrush({
|
|
269
|
+
...draft,
|
|
270
|
+
// For RangeX/RangeY this discards the cross-axis drag, so dragging any
|
|
271
|
+
// corner only ever moves the selected edge.
|
|
272
|
+
vertices: rectVerticesFromCorners(fixedX, fixedY, movingX, movingY, geometry, draft.shape),
|
|
273
|
+
}, false);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const movePolygonVertex = useEffectEvent((vertexIndex: number, xPixels: number, yPixels: number) => {
|
|
277
|
+
const draft = draftRef.current;
|
|
278
|
+
if (!draft) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
|
|
282
|
+
const vertices = draft.vertices.map(
|
|
283
|
+
(vertex, i) => (i === vertexIndex ? vertexFromPixels(x, y, geometry) : vertex),
|
|
284
|
+
);
|
|
285
|
+
emitBrush({ ...draft, vertices }, false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const endBrush = useEffectEvent(() => {
|
|
289
|
+
throttledAppendPolygonVertex.cancel();
|
|
290
|
+
const draft = draftRef.current;
|
|
291
|
+
interactionRef.current = null;
|
|
292
|
+
isBrushingRef.current = false;
|
|
293
|
+
if (!draft) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
shouldSuppressClickRef.current = true;
|
|
297
|
+
emitBrush({ ...draft, status: "Complete" }, true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// --- Long-click detection ---
|
|
301
|
+
|
|
302
|
+
const cancelPendingPress = useCallback(() => {
|
|
303
|
+
if (pendingPressRef.current) {
|
|
304
|
+
cancelAnimationFrame(pendingPressRef.current.rafId);
|
|
305
|
+
pendingPressRef.current = null;
|
|
306
|
+
setPressProgress(null);
|
|
307
|
+
}
|
|
308
|
+
}, []);
|
|
309
|
+
|
|
310
|
+
// Runs once per frame while the button is held, showing the filling wedge from
|
|
311
|
+
// `maybeBrushDelay` onwards and handing off to `startBrush` at `brushDelay`.
|
|
312
|
+
const tickPendingPress = useEffectEvent(() => {
|
|
313
|
+
const pending = pendingPressRef.current;
|
|
314
|
+
if (!pending) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const elapsed = performance.now() - pending.startTime;
|
|
318
|
+
if (elapsed >= brushDelay) {
|
|
319
|
+
const { x, y } = pending;
|
|
320
|
+
cancelPendingPress();
|
|
321
|
+
startBrush(x, y);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
setPressProgress(elapsed >= maybeBrushDelay
|
|
325
|
+
? { xPixels: pending.x, yPixels: pending.y, fraction: elapsed / brushDelay }
|
|
326
|
+
: null);
|
|
327
|
+
pending.rafId = requestAnimationFrame(tickPendingPress);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const mouseDownHandler = useEffectEvent((event: MouseEvent) => {
|
|
331
|
+
// Only a primary-button press inside the brushable region can start a brush.
|
|
332
|
+
if (!enableBrushCreate || event.button !== 0 || interactionRef.current) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Presses on the overlay's own controls (the vertex handles and the clear
|
|
336
|
+
// button) are not attempts to draw a new brush. Their React handlers cannot
|
|
337
|
+
// prevent this: React dispatches from its root, by which point this native
|
|
338
|
+
// listener on an ancestor has already run, so `stopPropagation` is too late.
|
|
339
|
+
if (event.target instanceof Node && overlayRef.current?.contains(event.target)) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const [x, y] = getContainerCoords(event);
|
|
343
|
+
if (x < geometry.brushLeft || x > geometry.brushRight || y < geometry.brushTop || y > geometry.brushBottom) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
cancelPendingPress();
|
|
347
|
+
pendingPressRef.current = { x, y, startTime: performance.now(), rafId: requestAnimationFrame(tickPendingPress) };
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const mouseMoveHandler = useEffectEvent((event: MouseEvent) => {
|
|
351
|
+
// This listener is on the window, so bail out before reading the container's
|
|
352
|
+
// layout when there is nothing brush-related to track.
|
|
353
|
+
const isTrackingHover = enableBrushClear && brushState !== undefined;
|
|
354
|
+
if (!pendingPressRef.current && !interactionRef.current && !isTrackingHover) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const [x, y] = getContainerCoords(event);
|
|
358
|
+
|
|
359
|
+
// Moving before the long-click completes means the user is panning, not brushing.
|
|
360
|
+
const pending = pendingPressRef.current;
|
|
361
|
+
if (pending && Math.hypot(x - pending.x, y - pending.y) > LONG_CLICK_CANCEL_PX) {
|
|
362
|
+
cancelPendingPress();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const interaction = interactionRef.current;
|
|
366
|
+
if (interaction) {
|
|
367
|
+
if (interaction.kind === "Create") {
|
|
368
|
+
if (brushMode === "Polygon") {
|
|
369
|
+
throttledAppendPolygonVertex(x, y);
|
|
370
|
+
} else {
|
|
371
|
+
updateRect(x, y);
|
|
372
|
+
}
|
|
373
|
+
} else if (interaction.kind === "EditVertex" && draftRef.current?.shape === "Polygon") {
|
|
374
|
+
movePolygonVertex(interaction.vertexIndex, x, y);
|
|
375
|
+
} else {
|
|
376
|
+
updateRect(x, y);
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Not dragging: track whether the pointer is over the brush, to decide
|
|
382
|
+
// whether to reveal the clear button.
|
|
383
|
+
if (isTrackingHover && brushState) {
|
|
384
|
+
const clearButtonCenter = getClearButtonCenter(brushState.vertices, CLEAR_BUTTON_RADIUS_PX, geometry);
|
|
385
|
+
// The button sits outside the brush, so it needs its own hover target;
|
|
386
|
+
// otherwise it would vanish as soon as the pointer moved towards it.
|
|
387
|
+
const isOverClearButton = clearButtonCenter !== null
|
|
388
|
+
&& Math.hypot(x - clearButtonCenter[0], y - clearButtonCenter[1]) <= CLEAR_BUTTON_RADIUS_PX * 2;
|
|
389
|
+
setIsBrushHovered(isOverClearButton || isPointInBrush(x, y, brushState.vertices));
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const mouseUpHandler = useEffectEvent(() => {
|
|
394
|
+
cancelPendingPress();
|
|
395
|
+
if (interactionRef.current) {
|
|
396
|
+
endBrush();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const mouseLeaveHandler = useEffectEvent(() => {
|
|
401
|
+
// Only the hover affordance is reset here; an in-flight drag continues,
|
|
402
|
+
// since its listeners are on the window.
|
|
403
|
+
if (!interactionRef.current) {
|
|
404
|
+
cancelPendingPress();
|
|
405
|
+
setIsBrushHovered(false);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
const containerEl = containerRef.current;
|
|
411
|
+
if (!containerEl) {
|
|
412
|
+
return () => {};
|
|
413
|
+
}
|
|
414
|
+
containerEl.addEventListener("mousedown", mouseDownHandler);
|
|
415
|
+
containerEl.addEventListener("mouseleave", mouseLeaveHandler);
|
|
416
|
+
// Move/up go on the window so a drag that leaves the container still
|
|
417
|
+
// updates (clamped to the brushable region) and still terminates.
|
|
418
|
+
window.addEventListener("mousemove", mouseMoveHandler);
|
|
419
|
+
window.addEventListener("mouseup", mouseUpHandler);
|
|
420
|
+
return () => {
|
|
421
|
+
containerEl.removeEventListener("mousedown", mouseDownHandler);
|
|
422
|
+
containerEl.removeEventListener("mouseleave", mouseLeaveHandler);
|
|
423
|
+
window.removeEventListener("mousemove", mouseMoveHandler);
|
|
424
|
+
window.removeEventListener("mouseup", mouseUpHandler);
|
|
425
|
+
};
|
|
426
|
+
}, [containerRef]);
|
|
427
|
+
|
|
428
|
+
useEffect(() => () => {
|
|
429
|
+
cancelPendingPress();
|
|
430
|
+
throttledAppendPolygonVertex.cancel();
|
|
431
|
+
}, [cancelPendingPress, throttledAppendPolygonVertex]);
|
|
432
|
+
|
|
433
|
+
// --- Handlers for the overlay's own SVG elements ---
|
|
434
|
+
|
|
435
|
+
const vertexMouseDown = useEffectEvent((vertexIndex: number, event: React.MouseEvent) => {
|
|
436
|
+
if (!enableBrushEdit || !brushState || event.button !== 0) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// `mouseDownHandler` already ignored this press; preventDefault only stops
|
|
440
|
+
// the browser's own text-selection/drag behaviour.
|
|
441
|
+
event.preventDefault();
|
|
442
|
+
cancelPendingPress();
|
|
443
|
+
draftRef.current = brushState;
|
|
444
|
+
isBrushingRef.current = true;
|
|
445
|
+
const oppositeVertex = brushState.vertices[(vertexIndex + 2) % 4];
|
|
446
|
+
interactionRef.current = {
|
|
447
|
+
kind: "EditVertex",
|
|
448
|
+
vertexIndex,
|
|
449
|
+
// Only meaningful for a Rect, where the opposite corner is the pivot.
|
|
450
|
+
fixedX: oppositeVertex?.x_pixels ?? 0,
|
|
451
|
+
fixedY: oppositeVertex?.y_pixels ?? 0,
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const edgeMouseDown = useEffectEvent((edge: BrushEdge, event: React.MouseEvent) => {
|
|
456
|
+
if (!enableBrushEdit || !brushState || event.button !== 0) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const boundingBox = getVerticesBoundingBox(brushState.vertices);
|
|
460
|
+
if (!boundingBox) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// See `vertexMouseDown`: preventDefault only stops text selection here.
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
cancelPendingPress();
|
|
466
|
+
draftRef.current = brushState;
|
|
467
|
+
isBrushingRef.current = true;
|
|
468
|
+
interactionRef.current = { kind: "EditEdge", ...getEdgeDragCorners(edge, boundingBox) };
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const clearClick = useEffectEvent((event: React.MouseEvent) => {
|
|
472
|
+
// The overlay is a sibling of the camera element rather than an ancestor,
|
|
473
|
+
// so this click never reaches the picking handler and needs no suppression.
|
|
474
|
+
event.preventDefault();
|
|
475
|
+
clearBrush();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Wrap in plain callbacks, since useEffectEvent functions may not be handed to children.
|
|
479
|
+
const onVertexMouseDown = useCallback(
|
|
480
|
+
(vertexIndex: number, event: React.MouseEvent) => vertexMouseDown(vertexIndex, event),
|
|
481
|
+
[],
|
|
482
|
+
);
|
|
483
|
+
const onEdgeMouseDown = useCallback(
|
|
484
|
+
(edge: BrushEdge, event: React.MouseEvent) => edgeMouseDown(edge, event),
|
|
485
|
+
[],
|
|
486
|
+
);
|
|
487
|
+
const onClearClick = useCallback((event: React.MouseEvent) => clearClick(event), []);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
brushState,
|
|
491
|
+
geometry,
|
|
492
|
+
overlayRef,
|
|
493
|
+
pressProgress,
|
|
494
|
+
isBrushHovered: isBrushHovered && enableBrushClear,
|
|
495
|
+
isBrushingRef,
|
|
496
|
+
shouldSuppressClickRef,
|
|
497
|
+
onVertexMouseDown,
|
|
498
|
+
onEdgeMouseDown,
|
|
499
|
+
onClearClick,
|
|
500
|
+
};
|
|
501
|
+
}
|
package/src/index.js
DELETED