@reekon-tools/boldr-utils 1.6.19 → 1.6.23
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/annotation/canvas/AnnotationCanvasInner.d.ts +3 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +107 -19
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +3 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +55 -14
- package/dist/annotation/canvas/Tool.d.ts +2 -0
- package/dist/annotation/canvas/measurementGeometry.js +4 -3
- package/dist/annotation/canvas/stampLayout.d.ts +3 -5
- package/dist/annotation/canvas/stampLayout.js +37 -55
- package/dist/annotation/canvas/tools/measurementTool.js +8 -2
- package/dist/annotation/canvas/tools/selectTool.js +79 -10
- package/dist/annotation/canvas/tools/textEditing.d.ts +4 -0
- package/dist/annotation/canvas/tools/textEditing.js +36 -0
- package/dist/annotation/canvas/tools/textTool.js +3 -26
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +9 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +49 -6
- package/dist/types/annotation.d.ts +1 -0
- package/package.json +1 -1
|
@@ -24,6 +24,9 @@ export interface AnnotationCanvasInnerProps {
|
|
|
24
24
|
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
25
25
|
onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
|
|
26
26
|
onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
|
|
27
|
+
onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
|
|
28
|
+
resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
|
|
29
|
+
onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
|
|
27
30
|
stampFontSource?: unknown;
|
|
28
31
|
stampValueFontSize?: number;
|
|
29
32
|
stampLabelFontSize?: number;
|
|
@@ -3,7 +3,7 @@ import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, } from 'react';
|
|
4
4
|
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
5
5
|
import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
|
|
6
|
-
import { buildRemoveMeasurementOps } from './measurementGeometry.js';
|
|
6
|
+
import { buildRemoveMeasurementOps, hitPlacedMeasurement, } from './measurementGeometry.js';
|
|
7
7
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
8
8
|
import { stampTileSize } from './stampLayout.js';
|
|
9
9
|
// Screen-px radius of a measurement-annotation endpoint handle (matches the
|
|
@@ -12,6 +12,13 @@ const HANDLE_PX = 7;
|
|
|
12
12
|
// Screen-px stroke width of the handle's colored ring (matches native
|
|
13
13
|
// HANDLE_RING_PX); the white-disc + ring keeps the knob legible on any line.
|
|
14
14
|
const HANDLE_RING_PX = 2;
|
|
15
|
+
// Press-and-hold timing/tolerance. The DOM has no native long-press, so it's
|
|
16
|
+
// derived from a hold timer: pressing without moving past the slop for this
|
|
17
|
+
// long fires the tool's onLongPress; movement or an early release cancels it.
|
|
18
|
+
// 500ms / RN's default delayLongPress so web matches native + the stamp
|
|
19
|
+
// overlay's press target below.
|
|
20
|
+
const LONG_PRESS_MS = 500;
|
|
21
|
+
const LONG_PRESS_SLOP_PX = 10;
|
|
15
22
|
const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
|
|
16
23
|
export const AnnotationCanvasInner = (props) => {
|
|
17
24
|
const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
|
|
@@ -39,7 +46,19 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
39
46
|
const containerRef = useRef(null);
|
|
40
47
|
const panGestureRef = useRef(null);
|
|
41
48
|
const spaceDownRef = useRef(false);
|
|
49
|
+
// Armed hold timer + its origin, for the derived long-press (see handlers).
|
|
50
|
+
const longPressTimerRef = useRef(null);
|
|
51
|
+
const longPressRef = useRef(null);
|
|
42
52
|
const activeTool = tools.find((t) => t.id === activeToolId) ?? null;
|
|
53
|
+
const clearLongPress = useCallback(() => {
|
|
54
|
+
if (longPressTimerRef.current != null) {
|
|
55
|
+
clearTimeout(longPressTimerRef.current);
|
|
56
|
+
longPressTimerRef.current = null;
|
|
57
|
+
}
|
|
58
|
+
longPressRef.current = null;
|
|
59
|
+
}, []);
|
|
60
|
+
// Drop any armed hold timer if the canvas unmounts mid-press.
|
|
61
|
+
useEffect(() => clearLongPress, [clearLongPress]);
|
|
43
62
|
const toCanvasPointer = useCallback((event) => {
|
|
44
63
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
45
64
|
const screen = {
|
|
@@ -82,8 +101,31 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
82
101
|
return;
|
|
83
102
|
}
|
|
84
103
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
const canvasEvent = toCanvasPointer(event);
|
|
105
|
+
state.dispatchPointerDown(canvasEvent);
|
|
106
|
+
// Arm the hold timer only when the active tool acts on a long-press (the
|
|
107
|
+
// select tool, to re-open the text editor). The pointer keeps driving the
|
|
108
|
+
// tool underneath; if the hold fires before any movement past the slop,
|
|
109
|
+
// dispatchLongPress runs with the (unmoved) down point. Movement or
|
|
110
|
+
// pointer up/cancel clears it below.
|
|
111
|
+
if (activeTool?.onLongPress && event.button === 0) {
|
|
112
|
+
clearLongPress();
|
|
113
|
+
const { screen, pointerId } = canvasEvent;
|
|
114
|
+
longPressRef.current = { pointerId, start: screen };
|
|
115
|
+
longPressTimerRef.current = setTimeout(() => {
|
|
116
|
+
longPressTimerRef.current = null;
|
|
117
|
+
const lp = longPressRef.current;
|
|
118
|
+
if (!lp)
|
|
119
|
+
return;
|
|
120
|
+
longPressRef.current = null;
|
|
121
|
+
state.dispatchLongPress({
|
|
122
|
+
pointerId: lp.pointerId,
|
|
123
|
+
screen: lp.start,
|
|
124
|
+
world: state.ctx.viewport.screenToWorld(lp.start),
|
|
125
|
+
});
|
|
126
|
+
}, LONG_PRESS_MS);
|
|
127
|
+
}
|
|
128
|
+
}, [state, toCanvasPointer, isPanTriggerDown, activeTool, clearLongPress]);
|
|
87
129
|
const handlePointerMove = useCallback((event) => {
|
|
88
130
|
const pan = panGestureRef.current;
|
|
89
131
|
if (pan && event.pointerId === pan.pointerId) {
|
|
@@ -102,20 +144,33 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
102
144
|
};
|
|
103
145
|
return;
|
|
104
146
|
}
|
|
147
|
+
// Moving past the slop turns the press into a drag — cancel the hold so a
|
|
148
|
+
// drag never also fires a long-press.
|
|
149
|
+
const lp = longPressRef.current;
|
|
150
|
+
if (lp && event.pointerId === lp.pointerId) {
|
|
151
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
152
|
+
const dx = event.clientX - (rect?.left ?? 0) - lp.start.x;
|
|
153
|
+
const dy = event.clientY - (rect?.top ?? 0) - lp.start.y;
|
|
154
|
+
if (dx * dx + dy * dy > LONG_PRESS_SLOP_PX * LONG_PRESS_SLOP_PX) {
|
|
155
|
+
clearLongPress();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
105
158
|
state.dispatchPointerMove(toCanvasPointer(event));
|
|
106
|
-
}, [state, toCanvasPointer]);
|
|
159
|
+
}, [state, toCanvasPointer, clearLongPress]);
|
|
107
160
|
const handlePointerUp = useCallback((event) => {
|
|
161
|
+
clearLongPress();
|
|
108
162
|
const pan = panGestureRef.current;
|
|
109
163
|
if (pan && event.pointerId === pan.pointerId) {
|
|
110
164
|
panGestureRef.current = null;
|
|
111
165
|
return;
|
|
112
166
|
}
|
|
113
167
|
state.dispatchPointerUp(toCanvasPointer(event));
|
|
114
|
-
}, [state, toCanvasPointer]);
|
|
168
|
+
}, [state, toCanvasPointer, clearLongPress]);
|
|
115
169
|
const handlePointerCancel = useCallback(() => {
|
|
170
|
+
clearLongPress();
|
|
116
171
|
panGestureRef.current = null;
|
|
117
172
|
state.dispatchPointerCancel();
|
|
118
|
-
}, [state]);
|
|
173
|
+
}, [state, clearLongPress]);
|
|
119
174
|
const handleWheel = useCallback((event) => {
|
|
120
175
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
121
176
|
const focal = {
|
|
@@ -189,8 +244,28 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
189
244
|
...style,
|
|
190
245
|
};
|
|
191
246
|
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
192
|
-
const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
|
|
193
|
-
|
|
247
|
+
const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, onMeasurementStampDoubleTap, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
|
|
248
|
+
// Double-click a stamp → fire the consumer callback (e.g. open its value
|
|
249
|
+
// editor). Runs the SAME hit-test the select tool uses (top-of-z-order), on
|
|
250
|
+
// the gesture container, so the stamp overlay stays pointer-events:none and
|
|
251
|
+
// drag-to-move / single-tap select are unaffected. dblclick is a distinct
|
|
252
|
+
// browser event that doesn't suppress the preceding pointerdown/up select.
|
|
253
|
+
const handleDoubleClick = useCallback((event) => {
|
|
254
|
+
if (!onMeasurementStampDoubleTap)
|
|
255
|
+
return;
|
|
256
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
257
|
+
const screen = {
|
|
258
|
+
x: event.clientX - (rect?.left ?? 0),
|
|
259
|
+
y: event.clientY - (rect?.top ?? 0),
|
|
260
|
+
};
|
|
261
|
+
const world = state.ctx.viewport.screenToWorld(screen);
|
|
262
|
+
const placed = [...state.effectiveCanvas.placedMeasurements]
|
|
263
|
+
.reverse()
|
|
264
|
+
.find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale));
|
|
265
|
+
if (placed)
|
|
266
|
+
onMeasurementStampDoubleTap(placed);
|
|
267
|
+
}, [onMeasurementStampDoubleTap, state]);
|
|
268
|
+
return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onDoubleClick: handleDoubleClick, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
|
|
194
269
|
width,
|
|
195
270
|
height,
|
|
196
271
|
effectiveCanvas: state.effectiveCanvas,
|
|
@@ -224,7 +299,10 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
224
299
|
const isSelected = selection?.ids.includes(placed.id) ?? false;
|
|
225
300
|
const measurement = placed.measurementId
|
|
226
301
|
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
227
|
-
: null;
|
|
302
|
+
: (resolveStampMeasurement?.(placed) ?? null);
|
|
303
|
+
// Corner-pinned, tile-proportional remove target (see the style
|
|
304
|
+
// comment below) so it can't blanket a small tile and eat its grab.
|
|
305
|
+
const removeSize = Math.min(40, size * 0.4);
|
|
228
306
|
return (_jsxs("div", { style: {
|
|
229
307
|
position: 'absolute',
|
|
230
308
|
left: 0,
|
|
@@ -242,16 +320,27 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
242
320
|
? () => onMeasurementStampLongPress(placed)
|
|
243
321
|
: undefined })), isSelected && measurement && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
|
|
244
322
|
e.stopPropagation();
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
323
|
+
const defaultRemove = () => {
|
|
324
|
+
const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
|
|
325
|
+
state.ctx.commit({ ops });
|
|
326
|
+
if (!keepSelection)
|
|
327
|
+
state.ctx.setSelection(null);
|
|
328
|
+
};
|
|
329
|
+
if (onMeasurementStampRemove) {
|
|
330
|
+
onMeasurementStampRemove(placed, defaultRemove);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
defaultRemove();
|
|
249
334
|
}, style: {
|
|
335
|
+
// Sized as a fraction of the tile and corner-pinned so it
|
|
336
|
+
// never covers the center. A fixed 40px target blanketed
|
|
337
|
+
// small tiles (the tile-scale slider shrinks them), eating
|
|
338
|
+
// the center grab so a selected tile couldn't be dragged.
|
|
250
339
|
position: 'absolute',
|
|
251
|
-
top:
|
|
252
|
-
right:
|
|
253
|
-
width:
|
|
254
|
-
height:
|
|
340
|
+
top: 0,
|
|
341
|
+
right: 0,
|
|
342
|
+
width: removeSize,
|
|
343
|
+
height: removeSize,
|
|
255
344
|
cursor: 'pointer',
|
|
256
345
|
pointerEvents: 'auto',
|
|
257
346
|
} }))] }, placed.id));
|
|
@@ -261,8 +350,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
261
350
|
// long-press, so it's derived: pointerdown arms a timer; if it fires before
|
|
262
351
|
// the pointer lifts (or leaves/cancels), the long-press callback runs and the
|
|
263
352
|
// trailing click is swallowed. Mirrors the native overlay's TouchableOpacity
|
|
264
|
-
// onPress/onLongPress semantics (
|
|
265
|
-
const LONG_PRESS_MS = 500;
|
|
353
|
+
// onPress/onLongPress semantics (LONG_PRESS_MS, RN's default delayLongPress).
|
|
266
354
|
const StampPressTarget = ({ onPress, onLongPress, }) => {
|
|
267
355
|
const timerRef = useRef(null);
|
|
268
356
|
const longPressFiredRef = useRef(false);
|
|
@@ -25,6 +25,9 @@ export interface AnnotationCanvasInnerProps {
|
|
|
25
25
|
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
26
26
|
onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
|
|
27
27
|
onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
|
|
28
|
+
onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
|
|
29
|
+
resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
|
|
30
|
+
onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
|
|
28
31
|
stampFontSource?: unknown;
|
|
29
32
|
stampValueFontSize?: number;
|
|
30
33
|
stampLabelFontSize?: number;
|
|
@@ -12,6 +12,12 @@ import { buildShapeFromDrag } from './tools/shapeTool.js';
|
|
|
12
12
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
13
13
|
let strokeCounter = 0;
|
|
14
14
|
const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
|
|
15
|
+
// Press-and-hold timing/tolerance for the long-press gesture (matches the
|
|
16
|
+
// measurement-stamp overlay's TouchableOpacity feel — 500ms / RN default).
|
|
17
|
+
// Holding still this long without moving past the slop fires onLongPress;
|
|
18
|
+
// moving first lets the drag win instead.
|
|
19
|
+
const LONG_PRESS_MS = 500;
|
|
20
|
+
const LONG_PRESS_SLOP_PX = 10;
|
|
15
21
|
// Screen-px radius of a measurement-annotation endpoint handle dot. Divided by
|
|
16
22
|
// the live zoom so the handle is a constant on-screen size.
|
|
17
23
|
const HANDLE_RADIUS_PX = 7;
|
|
@@ -155,6 +161,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
155
161
|
const shapeDraw = state.activeTool?.shapeDraw ?? null;
|
|
156
162
|
const panViewport = !!state.activeTool?.panViewport;
|
|
157
163
|
const dragSelection = state.activeTool?.dragSelection ?? null;
|
|
164
|
+
const longPressEnabled = !!state.activeTool?.onLongPress;
|
|
158
165
|
// In-flight shape rubber-band (line/arrow/rect/triangle/circle tools),
|
|
159
166
|
// owned by the UI thread — the shape twin of `livePoints`. The drag worklet
|
|
160
167
|
// tracks the start/current world points; derived paths below render the
|
|
@@ -479,6 +486,20 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
479
486
|
inFlightRef.current = null;
|
|
480
487
|
}
|
|
481
488
|
});
|
|
489
|
+
// Press-and-hold (one finger, no movement) → dispatch a long-press to the
|
|
490
|
+
// active tool (the select tool re-opens the text editor for a placed text
|
|
491
|
+
// shape). Races against the one-finger drag: holding still activates this
|
|
492
|
+
// at LONG_PRESS_MS, moving first activates the drag/pan instead (this one's
|
|
493
|
+
// maxDistance cancels it). A quick tap is shorter than the hold, so the tap
|
|
494
|
+
// gesture still owns selection.
|
|
495
|
+
const longPress = Gesture.LongPress()
|
|
496
|
+
.minDuration(LONG_PRESS_MS)
|
|
497
|
+
.maxDistance(LONG_PRESS_SLOP_PX)
|
|
498
|
+
.runOnJS(true)
|
|
499
|
+
.onStart((e) => {
|
|
500
|
+
const id = pointerIdRef.current++;
|
|
501
|
+
stateRef.current.dispatchLongPress(buildEvent(id, { x: e.x, y: e.y }));
|
|
502
|
+
});
|
|
482
503
|
const tap = Gesture.Tap()
|
|
483
504
|
.maxDuration(250)
|
|
484
505
|
.runOnJS(true)
|
|
@@ -752,9 +773,13 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
752
773
|
const world = st.ctx.viewport.screenToWorld(screen);
|
|
753
774
|
const zoomNow = st.ctx.viewport.state.zoom;
|
|
754
775
|
// Endpoint handles show only on the selected annotation, so check the
|
|
755
|
-
// current selection's handles before the general hit-test
|
|
776
|
+
// current selection's handles before the general hit-test — UNLESS the
|
|
777
|
+
// grab is on that element's tile, which must stay draggable (the tile
|
|
778
|
+
// is the move/slide affordance and wins over a handle sitting under it,
|
|
779
|
+
// so a selected tile isn't stuck until you deselect it).
|
|
756
780
|
const selId = st.ctx.selection?.ids[0];
|
|
757
|
-
if (selId
|
|
781
|
+
if (selId &&
|
|
782
|
+
!cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale)) {
|
|
758
783
|
const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
|
|
759
784
|
if (handle) {
|
|
760
785
|
const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
|
|
@@ -996,7 +1021,9 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
996
1021
|
: dragSelection
|
|
997
1022
|
? buildSelectDragPan(dragSelection)
|
|
998
1023
|
: toolPan;
|
|
999
|
-
|
|
1024
|
+
// Long-press only joins the race when the active tool acts on it, so it
|
|
1025
|
+
// never pre-empts a one-finger drag/draw on tools that ignore holds.
|
|
1026
|
+
return Gesture.Race(tap, ...(longPressEnabled ? [longPress] : []), Gesture.Simultaneous(viewportPan, pinch), oneFinger);
|
|
1000
1027
|
}, [
|
|
1001
1028
|
zoom,
|
|
1002
1029
|
panX,
|
|
@@ -1011,6 +1038,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1011
1038
|
shapeDraw,
|
|
1012
1039
|
panViewport,
|
|
1013
1040
|
dragSelection,
|
|
1041
|
+
longPressEnabled,
|
|
1014
1042
|
dragX,
|
|
1015
1043
|
dragY,
|
|
1016
1044
|
dragEnded,
|
|
@@ -1021,7 +1049,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1021
1049
|
]);
|
|
1022
1050
|
const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
|
|
1023
1051
|
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
1024
|
-
const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
|
|
1052
|
+
const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
|
|
1025
1053
|
return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
|
|
1026
1054
|
width,
|
|
1027
1055
|
height,
|
|
@@ -1089,16 +1117,29 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1089
1117
|
customPreview,
|
|
1090
1118
|
}) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
|
|
1091
1119
|
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
1092
|
-
: null, selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1120
|
+
: (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
|
|
1121
|
+
const defaultRemove = () => {
|
|
1122
|
+
const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
|
|
1123
|
+
state.ctx.commit({ ops });
|
|
1124
|
+
if (!keepSelection)
|
|
1125
|
+
state.ctx.setSelection(null);
|
|
1126
|
+
};
|
|
1127
|
+
if (onMeasurementStampRemove) {
|
|
1128
|
+
onMeasurementStampRemove(placed, defaultRemove);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
defaultRemove();
|
|
1097
1132
|
} }, placed.id))) }))] }));
|
|
1098
1133
|
};
|
|
1099
1134
|
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
|
|
1100
1135
|
const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
|
|
1101
1136
|
const half = size / 2;
|
|
1137
|
+
// Remove-"X" touch target, sized as a fraction of the tile and corner-pinned
|
|
1138
|
+
// so it never reaches the center. A FIXED 36px+hitSlop target blanketed small
|
|
1139
|
+
// tiles (the tile-scale slider can shrink them to ~38px), swallowing the
|
|
1140
|
+
// center grab and making a selected tile impossible to drag (it had to be
|
|
1141
|
+
// deselected first). Capped so it doesn't grow past the old size on big tiles.
|
|
1142
|
+
const removeSize = Math.min(36, size * 0.4);
|
|
1102
1143
|
const anchorX = placed.anchor.x;
|
|
1103
1144
|
const anchorY = placed.anchor.y;
|
|
1104
1145
|
// doc → screen each frame, on the UI thread. Position is a translate
|
|
@@ -1166,11 +1207,11 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
|
|
|
1166
1207
|
selected,
|
|
1167
1208
|
size,
|
|
1168
1209
|
zoom: zoomSnapshot,
|
|
1169
|
-
}) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement",
|
|
1210
|
+
}) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
|
|
1170
1211
|
position: 'absolute',
|
|
1171
|
-
top:
|
|
1172
|
-
right:
|
|
1173
|
-
width:
|
|
1174
|
-
height:
|
|
1212
|
+
top: 0,
|
|
1213
|
+
right: 0,
|
|
1214
|
+
width: removeSize,
|
|
1215
|
+
height: removeSize,
|
|
1175
1216
|
} }))] }));
|
|
1176
1217
|
};
|
|
@@ -68,6 +68,7 @@ export interface DragSelectionConfig {
|
|
|
68
68
|
fixed: Vec2;
|
|
69
69
|
} | null;
|
|
70
70
|
buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
|
|
71
|
+
isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): boolean;
|
|
71
72
|
hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
|
|
72
73
|
buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
|
|
73
74
|
}
|
|
@@ -83,6 +84,7 @@ export interface Tool {
|
|
|
83
84
|
onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
84
85
|
onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
85
86
|
onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
87
|
+
onLongPress?(event: CanvasPointerEvent, ctx: ToolContext): void;
|
|
86
88
|
onCancel?(state: ToolState, ctx: ToolContext): void;
|
|
87
89
|
onDeactivate?(ctx: ToolContext): void;
|
|
88
90
|
renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
|
|
@@ -92,9 +92,10 @@ export const oppositeRectCorner = (corner) => {
|
|
|
92
92
|
const STAMP_HIT_PADDING = 6;
|
|
93
93
|
// Screen-space grab tolerance (px) for a measurement-annotation line or rect
|
|
94
94
|
// border, converted to doc space via zoom (the body is a thin world-space
|
|
95
|
-
// stroke).
|
|
96
|
-
//
|
|
97
|
-
|
|
95
|
+
// stroke). Deliberately wider than a fingertip so a thin line isn't fiddly to
|
|
96
|
+
// grab on touch, and kept in sync with the select tool's SHAPE_GRAB_PX so
|
|
97
|
+
// tap-select and drag-grab agree on what counts as "on the measurement".
|
|
98
|
+
const LINE_GRAB_PX = 32;
|
|
98
99
|
const segmentDistSq = (p, a, b) => {
|
|
99
100
|
const abx = b.x - a.x;
|
|
100
101
|
const aby = b.y - a.y;
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { PlacedMeasurementRef } from '../../types/annotation.js';
|
|
2
2
|
export declare const STAMP_TILE_SIZE = 96;
|
|
3
|
-
export declare const STAMP_INPUT_TILE_SIZE =
|
|
3
|
+
export declare const STAMP_INPUT_TILE_SIZE = 44;
|
|
4
4
|
export declare const DEFAULT_TILE_SCALE = 1;
|
|
5
5
|
export declare const TILE_SCALE_MIN = 0.4;
|
|
6
6
|
export declare const TILE_SCALE_MAX = 2;
|
|
7
7
|
export declare const clampTileScale: (v: number) => number;
|
|
8
|
-
export declare const
|
|
9
|
-
export declare const
|
|
10
|
-
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
|
|
11
|
-
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
|
|
8
|
+
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
|
|
9
|
+
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
export const STAMP_TILE_SIZE = 96;
|
|
12
12
|
// Edge length for an UNASSOCIATED stamp — a measurement annotation with no
|
|
13
13
|
// measurement picked yet, which renders as a compact "+" input placeholder
|
|
14
|
-
// rather than a full readable tile.
|
|
15
|
-
// inputs read as lightweight tap targets
|
|
16
|
-
//
|
|
17
|
-
|
|
14
|
+
// rather than a full readable tile. Kept noticeably smaller than
|
|
15
|
+
// STAMP_TILE_SIZE so empty inputs read as lightweight tap targets that don't
|
|
16
|
+
// crowd the drawing; associated tiles keep STAMP_TILE_SIZE so existing saved
|
|
17
|
+
// annotations are visually unchanged. Still comfortably tappable once the
|
|
18
|
+
// viewport/tile-scale multipliers (min 1) are folded in. The single knob for #6.
|
|
19
|
+
export const STAMP_INPUT_TILE_SIZE = 44;
|
|
18
20
|
// Document-wide tile scale factor (AnnotationCanvasState.tileScaleFactor): one
|
|
19
21
|
// knob that shrinks/grows EVERY measurement tile on the canvas at once, on top
|
|
20
22
|
// of each tile's own `scale`. Lets a user pull tiles down on a dense drawing
|
|
@@ -28,60 +30,40 @@ export const TILE_SCALE_MAX = 2;
|
|
|
28
30
|
// Clamp a tile-scale-factor candidate to the supported range. The single guard
|
|
29
31
|
// for the value before it lands in the document (slider input, restored docs).
|
|
30
32
|
export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
|
|
31
|
-
// ---
|
|
32
|
-
// A
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// = min(canvasW, docW * canvasH / docH)
|
|
50
|
-
// This — not the raw canvas size — governs the tile's fraction of the drawing,
|
|
51
|
-
// so the same document at the same canvas aspect yields the same value on web
|
|
52
|
-
// and native. Falls back to canvasW when doc dimensions are unknown.
|
|
53
|
-
export const renderedDocWidthAtFit = (canvasW, canvasH, docW, docH) => {
|
|
54
|
-
if (!(docW > 0) || !(docH > 0) || !(canvasW > 0) || !(canvasH > 0)) {
|
|
55
|
-
return canvasW > 0 ? canvasW : TILE_VIEWPORT_REFERENCE_PX;
|
|
56
|
-
}
|
|
57
|
-
return Math.min(canvasW, (docW * canvasH) / docH);
|
|
58
|
-
};
|
|
59
|
-
// Multiplier folded into the tile footprint so tiles are a consistent fraction
|
|
60
|
-
// of the rendered drawing on any canvas. Zoom-INDEPENDENT (a function of canvas
|
|
61
|
-
// + doc dimensions only), so the tile still never changes size as the user
|
|
62
|
-
// pinches/wheels. Defaults to 1 (callers without canvas dimensions are
|
|
63
|
-
// unaffected — e.g. legacy tests).
|
|
64
|
-
export const viewportTileScale = (canvasW, canvasH, docW, docH) => {
|
|
65
|
-
const s = renderedDocWidthAtFit(canvasW, canvasH, docW, docH) /
|
|
66
|
-
TILE_VIEWPORT_REFERENCE_PX;
|
|
67
|
-
return s < TILE_VIEWPORT_SCALE_MIN
|
|
68
|
-
? TILE_VIEWPORT_SCALE_MIN
|
|
69
|
-
: s > TILE_VIEWPORT_SCALE_MAX
|
|
70
|
-
? TILE_VIEWPORT_SCALE_MAX
|
|
71
|
-
: s;
|
|
72
|
-
};
|
|
33
|
+
// --- Tile sizing is independent of canvas size ------------------------------
|
|
34
|
+
// A measurement tile's footprint is base × per-tile `scale` × the document-wide
|
|
35
|
+
// `tileScaleFactor` (below) — and NOTHING tied to the canvas's pixel size, so
|
|
36
|
+
// the same document shows the same tiles at any canvas/window size.
|
|
37
|
+
//
|
|
38
|
+
// Earlier builds multiplied the footprint by a per-canvas `viewportTileScale`
|
|
39
|
+
// derived from how large the document rendered when fit to the canvas, to keep a
|
|
40
|
+
// tile a constant fraction of the drawing across devices. But that made a tile's
|
|
41
|
+
// size change whenever the canvas was resized (e.g. dragging a desktop pane) —
|
|
42
|
+
// the same document read with different-sized tiles at different window sizes.
|
|
43
|
+
// That auto-multiplier is retired in favor of the user-driven "Tile size"
|
|
44
|
+
// slider (the document-wide `tileScaleFactor`), which is explicit, persisted,
|
|
45
|
+
// and consistent everywhere. Mobile is unchanged — its old factor was already
|
|
46
|
+
// pinned at 1, calibrated to a phone's width.
|
|
47
|
+
//
|
|
48
|
+
// `stampTileSize` keeps its `viewportScale` parameter (default 1) only so the
|
|
49
|
+
// render overlay, hit-test, and tools that thread it stay call-compatible; it is
|
|
50
|
+
// now always 1.
|
|
73
51
|
// A placed measurement is an unassociated input until a measurement reference
|
|
74
|
-
// is attached (id or path)
|
|
75
|
-
|
|
52
|
+
// is attached (id or path) OR it is bound to a form column (`columnId`). Such
|
|
53
|
+
// stamps use STAMP_INPUT_TILE_SIZE. A column-bound tile is a full editable
|
|
54
|
+
// input *card* (it shows the column's label + value editor), so it takes the
|
|
55
|
+
// full STAMP_TILE_SIZE like an associated tile — only the truly blank "+"
|
|
56
|
+
// placeholder uses the compact input size.
|
|
57
|
+
export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath && !m.columnId;
|
|
76
58
|
// Screen-space edge length for a placed stamp: the compact input size while
|
|
77
59
|
// unassociated, full size once a measurement is attached, then scaled by the
|
|
78
|
-
// per-stamp `scale
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
60
|
+
// per-stamp `scale` and the document-wide `tileScaleFactor`. The ONE source of
|
|
61
|
+
// truth for tile footprint — render overlay, hit-test, and slide-grab
|
|
62
|
+
// classification all call this so the drawn tile and its touch box always agree.
|
|
63
|
+
// `tileScaleFactor` is the document-wide knob (default 1, from
|
|
64
|
+
// `AnnotationCanvasState.tileScaleFactor`, driven by the "Tile size" slider).
|
|
65
|
+
// `viewportScale` is retained for call compatibility and is always 1 — the old
|
|
66
|
+
// per-canvas multiplier was retired (see the note above). Independent of zoom.
|
|
85
67
|
export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
|
|
86
68
|
(m.scale ?? 1) *
|
|
87
69
|
tileScaleFactor *
|
|
@@ -53,10 +53,16 @@ export const createMeasurementTool = (options = {}) => {
|
|
|
53
53
|
const selectToolId = options.selectToolId ?? 'select';
|
|
54
54
|
const place = (ctx, measurement) => {
|
|
55
55
|
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
56
|
-
ctx.setSelection({ ids: [measurement.id] });
|
|
57
56
|
options.onPlaced?.(measurement);
|
|
58
|
-
|
|
57
|
+
// Selecting the new annotation and handing back to select only makes sense
|
|
58
|
+
// when we actually switch tools. With autoSwitchToSelect off the tool stays
|
|
59
|
+
// active and behaves like the shape tools — commit and keep drawing, with
|
|
60
|
+
// nothing selected — so placing an empty input doesn't kick you out of
|
|
61
|
+
// drawing mode (and the keypad/pill doesn't pop for the blank tile).
|
|
62
|
+
if (autoSwitchToSelect) {
|
|
63
|
+
ctx.setSelection({ ids: [measurement.id] });
|
|
59
64
|
options.onAutoSwitch?.(selectToolId);
|
|
65
|
+
}
|
|
60
66
|
};
|
|
61
67
|
// Bare stamp: tap-to-place, no rubber-band.
|
|
62
68
|
if (placement === 'none') {
|
|
@@ -2,7 +2,20 @@ import { stampTileSize } from '../stampLayout.js';
|
|
|
2
2
|
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
|
|
3
3
|
import { hitShapeOutline } from '../shapeGeometry.js';
|
|
4
4
|
import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
|
|
5
|
+
import { editTextShape } from './textEditing.js';
|
|
5
6
|
const HIT_PADDING = 6;
|
|
7
|
+
// Whether an element id refers to a text shape (findHit reports text, lines,
|
|
8
|
+
// rects and ellipses all as kind 'shape'; only text supports tap-to-edit).
|
|
9
|
+
const isTextShape = (doc, id) => doc.shapes.some((s) => s.id === id && s.kind === 'text');
|
|
10
|
+
// Re-tap-to-edit for text. The pointer-down records a text shape's id here only
|
|
11
|
+
// when that shape was *already* selected before this gesture; a release with no
|
|
12
|
+
// movement then re-opens its editor. Held at module scope (not in ToolState)
|
|
13
|
+
// because the native tap gesture synthesizes pointer-down and pointer-up in a
|
|
14
|
+
// single React tick — a ToolState set on down isn't visible to up, but this is.
|
|
15
|
+
// Only one select gesture is ever in flight, so a shared cell is safe. A first
|
|
16
|
+
// tap (shape not yet selected) leaves this null, so it only selects; the second
|
|
17
|
+
// tap edits. Cleared on drag, release, and cancel.
|
|
18
|
+
let pendingTextEditId = null;
|
|
6
19
|
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
7
20
|
// override via `hitTest` for more precision later.
|
|
8
21
|
const hitStroke = (stroke, p) => {
|
|
@@ -16,8 +29,14 @@ const hitStroke = (stroke, p) => {
|
|
|
16
29
|
return false;
|
|
17
30
|
};
|
|
18
31
|
// Screen-space grab tolerance (px) added around a geometric shape's outline
|
|
19
|
-
// (line/arrow/rect/ellipse/polygon), converted to doc space via zoom.
|
|
20
|
-
|
|
32
|
+
// (line/arrow/rect/ellipse/polygon), converted to doc space via zoom. A thin
|
|
33
|
+
// line gives almost nothing to aim at, so the grab corridor is deliberately
|
|
34
|
+
// wider than a fingertip — landing anywhere near the ink grabs it. Kept in sync
|
|
35
|
+
// with measurementGeometry's LINE_GRAB_PX so tap-select and drag-grab agree on
|
|
36
|
+
// what counts as "on the line". (The endpoint-resize handles use their own,
|
|
37
|
+
// tighter HANDLE_GRAB_PX and are checked first on the selected element, so a
|
|
38
|
+
// wider body grab never swallows them.)
|
|
39
|
+
const SHAPE_GRAB_PX = 32;
|
|
21
40
|
// Screen-px radius of the center snap detent when sliding a tile along its line.
|
|
22
41
|
// Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
|
|
23
42
|
// slide worklet inlines the same value — keep them in sync.
|
|
@@ -124,6 +143,23 @@ const translatePatch = (elementKind, id, doc, delta) => {
|
|
|
124
143
|
}
|
|
125
144
|
return { op: 'updateStroke', id, patch: { points } };
|
|
126
145
|
};
|
|
146
|
+
// Whether a world point lands on a measurement's tile (its screen-constant
|
|
147
|
+
// anchor box, converted back to doc space via zoom — the same footprint
|
|
148
|
+
// classifyGrab uses). The tile is the move/slide affordance, so a grab here
|
|
149
|
+
// must win over the endpoint/corner/resize handles that appear once an element
|
|
150
|
+
// is selected; without this, a handle sitting under the tile (a rectangle
|
|
151
|
+
// tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
|
|
152
|
+
// and the tile becomes unmovable until deselected.
|
|
153
|
+
const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
|
|
154
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
155
|
+
if (!m)
|
|
156
|
+
return false;
|
|
157
|
+
const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
|
|
158
|
+
HIT_PADDING) /
|
|
159
|
+
zoom;
|
|
160
|
+
return (Math.abs(world.x - m.anchor.x) <= half &&
|
|
161
|
+
Math.abs(world.y - m.anchor.y) <= half);
|
|
162
|
+
};
|
|
127
163
|
// --- Measurement-annotation grab logic (shared by the native UI-thread drag
|
|
128
164
|
// via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
|
|
129
165
|
// Grabbing the tile of a line annotation slides it along the line; everything
|
|
@@ -348,6 +384,7 @@ export const createSelectTool = () => ({
|
|
|
348
384
|
return op ? { ops: [op] } : null;
|
|
349
385
|
},
|
|
350
386
|
classifyMeasurementGrab: classifyGrab,
|
|
387
|
+
isSelectedTileGrab: isOnMeasurementTile,
|
|
351
388
|
buildSlidePatch: slidePatch,
|
|
352
389
|
hitTestHandle: findHandleHit,
|
|
353
390
|
buildEndpointPatch: endpointPatch,
|
|
@@ -364,9 +401,17 @@ export const createSelectTool = () => ({
|
|
|
364
401
|
onPointerDown(event, ctx) {
|
|
365
402
|
const { world } = event;
|
|
366
403
|
const zoom = ctx.viewport.state.zoom;
|
|
367
|
-
//
|
|
404
|
+
// Reset the re-tap-to-edit latch; only a body grab of an already-selected
|
|
405
|
+
// text shape (below) re-arms it. Grabbing a handle or empty canvas leaves
|
|
406
|
+
// it cleared, so neither can leak an edit into the next release.
|
|
407
|
+
pendingTextEditId = null;
|
|
408
|
+
// Endpoint/resize handles show only on the selected element — check first,
|
|
409
|
+
// UNLESS the grab is on that element's tile: the tile is the move/slide
|
|
410
|
+
// affordance and must win over a handle sitting under it, so a selected
|
|
411
|
+
// tile stays draggable (otherwise it can only be moved after deselecting).
|
|
368
412
|
const selId = ctx.selection?.ids[0];
|
|
369
|
-
if (selId
|
|
413
|
+
if (selId &&
|
|
414
|
+
!isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
|
|
370
415
|
const handle = findHandleHit(ctx.document, selId, world, zoom);
|
|
371
416
|
if (handle) {
|
|
372
417
|
ctx.setSelection({ ids: [selId] });
|
|
@@ -422,6 +467,15 @@ export const createSelectTool = () => ({
|
|
|
422
467
|
ctx.setSelection(null);
|
|
423
468
|
return { kind: 'idle' };
|
|
424
469
|
}
|
|
470
|
+
// Re-tapping an already-selected text shape (without dragging) opens its
|
|
471
|
+
// editor on release — see onPointerUp. `selId` is the selection from before
|
|
472
|
+
// this down, so a first tap only selects; the second tap edits.
|
|
473
|
+
pendingTextEditId =
|
|
474
|
+
hit.kind === 'shape' &&
|
|
475
|
+
hit.id === selId &&
|
|
476
|
+
isTextShape(ctx.document, hit.id)
|
|
477
|
+
? hit.id
|
|
478
|
+
: null;
|
|
425
479
|
ctx.setSelection({ ids: [hit.id] });
|
|
426
480
|
const mode = hit.kind === 'measurement' &&
|
|
427
481
|
classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
|
|
@@ -445,22 +499,37 @@ export const createSelectTool = () => ({
|
|
|
445
499
|
x: event.world.x - s.start.x,
|
|
446
500
|
y: event.world.y - s.start.y,
|
|
447
501
|
};
|
|
502
|
+
// Any real movement turns this into a drag, not a re-tap — disarm the edit.
|
|
503
|
+
if (delta.x !== 0 || delta.y !== 0)
|
|
504
|
+
pendingTextEditId = null;
|
|
448
505
|
const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
|
|
449
506
|
if (patch)
|
|
450
507
|
ctx.preview(patch);
|
|
451
508
|
return { ...s, delta };
|
|
452
509
|
},
|
|
453
510
|
onPointerUp(_event, ctx, state) {
|
|
511
|
+
const editId = pendingTextEditId;
|
|
512
|
+
pendingTextEditId = null;
|
|
454
513
|
const s = state;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
514
|
+
// A moved selection commits its drag and is never a tap-to-edit.
|
|
515
|
+
if (s?.kind === 'dragging' && (s.delta.x !== 0 || s.delta.y !== 0)) {
|
|
516
|
+
const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
|
|
517
|
+
if (patch)
|
|
518
|
+
ctx.commit(patch);
|
|
458
519
|
return;
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
520
|
+
}
|
|
521
|
+
// No movement: re-tapping an already-selected text shape re-opens its
|
|
522
|
+
// editor (the same edit flow as tapping it with the text tool, via the
|
|
523
|
+
// shared editTextShape). `editId` was latched on the down, so this survives
|
|
524
|
+
// the native tap's synchronous down+up where `state` cannot.
|
|
525
|
+
if (editId) {
|
|
526
|
+
const shape = ctx.document.shapes.find((sh) => sh.id === editId);
|
|
527
|
+
if (shape && shape.kind === 'text')
|
|
528
|
+
editTextShape(ctx, shape);
|
|
529
|
+
}
|
|
462
530
|
},
|
|
463
531
|
onCancel(_state, ctx) {
|
|
532
|
+
pendingTextEditId = null;
|
|
464
533
|
ctx.preview({ ops: [] });
|
|
465
534
|
},
|
|
466
535
|
hitTest(element, p) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AnnotationCanvasState, AnnotationShape, Vec2 } from '../../../types/annotation.js';
|
|
2
|
+
import type { ToolContext } from '../Tool.js';
|
|
3
|
+
export declare const findTextShapeAt: (doc: AnnotationCanvasState, world: Vec2) => AnnotationShape | null;
|
|
4
|
+
export declare const editTextShape: (ctx: ToolContext, shape: AnnotationShape, onDone?: () => void) => void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { hitTestTextShape } from '../textGeometry.js';
|
|
2
|
+
// Topmost text shape under a world point (z-order, top first), or null. Shared
|
|
3
|
+
// by the text tool (tap-to-edit) and the select tool (tap-a-selected-shape-to-
|
|
4
|
+
// edit) so a press resolves to the same element from either tool.
|
|
5
|
+
export const findTextShapeAt = (doc, world) => {
|
|
6
|
+
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
7
|
+
const s = doc.shapes[i];
|
|
8
|
+
if (s.kind === 'text' && hitTestTextShape(s, world))
|
|
9
|
+
return s;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
};
|
|
13
|
+
// Re-open the consumer's text input pre-filled with an existing text shape's
|
|
14
|
+
// content and commit the edit: cancelling (null) leaves it untouched, clearing
|
|
15
|
+
// the text deletes the shape, and changed text updates it. The shape is left
|
|
16
|
+
// selected (cleared on delete). `onDone` runs only after a non-cancel,
|
|
17
|
+
// non-delete resolution — the text tool uses it to switch back to select.
|
|
18
|
+
// One source of truth for editing placed text from either tool.
|
|
19
|
+
export const editTextShape = (ctx, shape, onDone) => {
|
|
20
|
+
void ctx.requestTextInput({ initialText: shape.text }).then((text) => {
|
|
21
|
+
if (text === null)
|
|
22
|
+
return;
|
|
23
|
+
if (text === '') {
|
|
24
|
+
ctx.commit({ ops: [{ op: 'removeShape', id: shape.id }] });
|
|
25
|
+
ctx.setSelection(null);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (text !== shape.text) {
|
|
29
|
+
ctx.commit({
|
|
30
|
+
ops: [{ op: 'updateShape', id: shape.id, patch: { text } }],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
ctx.setSelection({ ids: [shape.id] });
|
|
34
|
+
onDone?.();
|
|
35
|
+
});
|
|
36
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
-
import { DEFAULT_TEXT_FONT_SIZE
|
|
2
|
+
import { DEFAULT_TEXT_FONT_SIZE } from '../textGeometry.js';
|
|
3
|
+
import { editTextShape, findTextShapeAt } from './textEditing.js';
|
|
3
4
|
let counter = 0;
|
|
4
5
|
const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
6
|
// Screen-px a press may travel and still count as a tap. Beyond this the
|
|
@@ -7,15 +8,6 @@ const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)
|
|
|
7
8
|
// NOT open the text sheet — the cause of the stray "weird popup" on screen.
|
|
8
9
|
const TAP_SLOP_PX = 10;
|
|
9
10
|
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
10
|
-
// Topmost text shape under a world point, for tap-to-edit.
|
|
11
|
-
const findTextShapeAt = (doc, world) => {
|
|
12
|
-
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
13
|
-
const s = doc.shapes[i];
|
|
14
|
-
if (s.kind === 'text' && hitTestTextShape(s, world))
|
|
15
|
-
return s;
|
|
16
|
-
}
|
|
17
|
-
return null;
|
|
18
|
-
};
|
|
19
11
|
// Tap-to-type. Tapping empty canvas opens the consumer's text input and
|
|
20
12
|
// commits a new text shape at the tap point (top-left anchored); tapping an
|
|
21
13
|
// existing text shape re-opens the input pre-filled to edit it (clearing the
|
|
@@ -51,22 +43,7 @@ export const createTextTool = (options = {}) => {
|
|
|
51
43
|
}
|
|
52
44
|
const existing = findTextShapeAt(ctx.document, event.world);
|
|
53
45
|
if (existing) {
|
|
54
|
-
|
|
55
|
-
.requestTextInput({ initialText: existing.text })
|
|
56
|
-
.then((text) => {
|
|
57
|
-
if (text === null)
|
|
58
|
-
return;
|
|
59
|
-
if (text === '') {
|
|
60
|
-
ctx.commit({ ops: [{ op: 'removeShape', id: existing.id }] });
|
|
61
|
-
ctx.setSelection(null);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (text !== existing.text) {
|
|
65
|
-
ctx.commit({
|
|
66
|
-
ops: [{ op: 'updateShape', id: existing.id, patch: { text } }],
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
ctx.setSelection({ ids: [existing.id] });
|
|
46
|
+
editTextShape(ctx, existing, () => {
|
|
70
47
|
if (autoSwitchToSelect)
|
|
71
48
|
options.onAutoSwitch?.(selectToolId);
|
|
72
49
|
});
|
|
@@ -16,6 +16,14 @@ export interface AnnotationCanvasHandle {
|
|
|
16
16
|
}): void;
|
|
17
17
|
setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
|
|
18
18
|
associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
|
|
19
|
+
bindColumn(id: AnnotationElementId, binding: {
|
|
20
|
+
groupId: string;
|
|
21
|
+
columnId: string;
|
|
22
|
+
}): void;
|
|
23
|
+
placeColumnTileAtCenter(binding: {
|
|
24
|
+
groupId: string;
|
|
25
|
+
columnId: string;
|
|
26
|
+
}): AnnotationElementId;
|
|
19
27
|
deleteSelected(): void;
|
|
20
28
|
}
|
|
21
29
|
export interface UseAnnotationCanvasStateProps {
|
|
@@ -56,6 +64,7 @@ export interface AnnotationCanvasStateApi {
|
|
|
56
64
|
dispatchPointerMove(event: CanvasPointerEvent): void;
|
|
57
65
|
dispatchPointerUp(event: CanvasPointerEvent): void;
|
|
58
66
|
dispatchPointerCancel(): void;
|
|
67
|
+
dispatchLongPress(event: CanvasPointerEvent): void;
|
|
59
68
|
pan(deltaScreen: Vec2): void;
|
|
60
69
|
zoom(focalScreen: Vec2, nextZoom: number): void;
|
|
61
70
|
setViewport(next: ViewportState): void;
|
|
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
3
|
import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
4
4
|
import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
5
|
-
import { viewportTileScale } from './stampLayout.js';
|
|
6
5
|
// Platform-agnostic state machine for the annotation canvas. Web and native
|
|
7
6
|
// inners share this hook; each wraps it with platform-specific event
|
|
8
7
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
@@ -22,11 +21,13 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
22
21
|
return map;
|
|
23
22
|
}, [measurements]);
|
|
24
23
|
const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the
|
|
29
|
-
|
|
24
|
+
// Tiles are sized independently of the canvas: their footprint is base ×
|
|
25
|
+
// per-tile scale × the document-wide `tileScaleFactor` ("Tile size" slider),
|
|
26
|
+
// never the canvas pixel size — so resizing the pane no longer rescales tiles.
|
|
27
|
+
// Retained as a (constant 1) value because the overlays (drawn size) and tools
|
|
28
|
+
// (hit box) thread it; keeping them in lockstep at 1 means both still agree.
|
|
29
|
+
// (See stampLayout.ts for why the old per-canvas multiplier was retired.)
|
|
30
|
+
const tileViewportScale = 1;
|
|
30
31
|
const ctx = useMemo(() => ({
|
|
31
32
|
document: canvas,
|
|
32
33
|
selection,
|
|
@@ -121,6 +122,14 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
121
122
|
activePointerIdRef.current = null;
|
|
122
123
|
setToolState(undefined);
|
|
123
124
|
}, [activeTool, ctx, toolState]);
|
|
125
|
+
const dispatchLongPress = useCallback((event) => {
|
|
126
|
+
if (!activeTool)
|
|
127
|
+
return;
|
|
128
|
+
// Fire-and-forget: onLongPress is a discrete action (it opens the text
|
|
129
|
+
// editor), so it neither reads nor writes the gesture's tool state — the
|
|
130
|
+
// in-flight drag/select state on web stays intact underneath it.
|
|
131
|
+
activeTool.onLongPress?.(event, ctx);
|
|
132
|
+
}, [activeTool, ctx]);
|
|
124
133
|
const dispatchPointerCancel = useCallback(() => {
|
|
125
134
|
// Clear FIRST, then let the tool react: state updates batch, so a tool
|
|
126
135
|
// whose onCancel re-emits a preview (the polygon tool keeps its placed
|
|
@@ -309,6 +318,39 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
309
318
|
],
|
|
310
319
|
});
|
|
311
320
|
},
|
|
321
|
+
bindColumn(id, binding) {
|
|
322
|
+
const c = ctxRef.current;
|
|
323
|
+
c.commit({
|
|
324
|
+
ops: [
|
|
325
|
+
{
|
|
326
|
+
op: 'updateMeasurement',
|
|
327
|
+
id,
|
|
328
|
+
patch: { groupId: binding.groupId, columnId: binding.columnId },
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
placeColumnTileAtCenter(binding) {
|
|
334
|
+
const c = ctxRef.current;
|
|
335
|
+
const anchor = c.viewport.screenToWorld({
|
|
336
|
+
x: width / 2,
|
|
337
|
+
y: height / 2,
|
|
338
|
+
});
|
|
339
|
+
const placed = {
|
|
340
|
+
id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
|
|
341
|
+
layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
342
|
+
groupId: binding.groupId,
|
|
343
|
+
columnId: binding.columnId,
|
|
344
|
+
anchor,
|
|
345
|
+
showLabel: true,
|
|
346
|
+
showValue: true,
|
|
347
|
+
createdAt: Date.now(),
|
|
348
|
+
};
|
|
349
|
+
c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
|
|
350
|
+
// Select it so the consumer can immediately move it / open its editor.
|
|
351
|
+
c.setSelection({ ids: [placed.id] });
|
|
352
|
+
return placed.id;
|
|
353
|
+
},
|
|
312
354
|
deleteSelected() {
|
|
313
355
|
const c = ctxRef.current;
|
|
314
356
|
const ids = c.selection?.ids;
|
|
@@ -376,6 +418,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
376
418
|
dispatchPointerMove,
|
|
377
419
|
dispatchPointerUp,
|
|
378
420
|
dispatchPointerCancel,
|
|
421
|
+
dispatchLongPress,
|
|
379
422
|
pan,
|
|
380
423
|
zoom,
|
|
381
424
|
setViewport,
|