@reekon-tools/boldr-utils 1.6.19 → 1.6.20
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.js +72 -11
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +42 -8
- package/dist/annotation/canvas/Tool.d.ts +2 -0
- package/dist/annotation/canvas/measurementGeometry.js +4 -3
- package/dist/annotation/canvas/stampLayout.d.ts +1 -1
- package/dist/annotation/canvas/stampLayout.js +6 -4
- package/dist/annotation/canvas/tools/measurementTool.js +8 -2
- package/dist/annotation/canvas/tools/selectTool.js +41 -4
- 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 +1 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +9 -0
- package/package.json +1 -1
|
@@ -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 = {
|
|
@@ -225,6 +280,9 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
225
280
|
const measurement = placed.measurementId
|
|
226
281
|
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
227
282
|
: null;
|
|
283
|
+
// Corner-pinned, tile-proportional remove target (see the style
|
|
284
|
+
// comment below) so it can't blanket a small tile and eat its grab.
|
|
285
|
+
const removeSize = Math.min(40, size * 0.4);
|
|
228
286
|
return (_jsxs("div", { style: {
|
|
229
287
|
position: 'absolute',
|
|
230
288
|
left: 0,
|
|
@@ -247,11 +305,15 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
247
305
|
if (!keepSelection)
|
|
248
306
|
state.ctx.setSelection(null);
|
|
249
307
|
}, style: {
|
|
308
|
+
// Sized as a fraction of the tile and corner-pinned so it
|
|
309
|
+
// never covers the center. A fixed 40px target blanketed
|
|
310
|
+
// small tiles (the tile-scale slider shrinks them), eating
|
|
311
|
+
// the center grab so a selected tile couldn't be dragged.
|
|
250
312
|
position: 'absolute',
|
|
251
|
-
top:
|
|
252
|
-
right:
|
|
253
|
-
width:
|
|
254
|
-
height:
|
|
313
|
+
top: 0,
|
|
314
|
+
right: 0,
|
|
315
|
+
width: removeSize,
|
|
316
|
+
height: removeSize,
|
|
255
317
|
cursor: 'pointer',
|
|
256
318
|
pointerEvents: 'auto',
|
|
257
319
|
} }))] }, placed.id));
|
|
@@ -261,8 +323,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
261
323
|
// long-press, so it's derived: pointerdown arms a timer; if it fires before
|
|
262
324
|
// the pointer lifts (or leaves/cancels), the long-press callback runs and the
|
|
263
325
|
// trailing click is swallowed. Mirrors the native overlay's TouchableOpacity
|
|
264
|
-
// onPress/onLongPress semantics (
|
|
265
|
-
const LONG_PRESS_MS = 500;
|
|
326
|
+
// onPress/onLongPress semantics (LONG_PRESS_MS, RN's default delayLongPress).
|
|
266
327
|
const StampPressTarget = ({ onPress, onLongPress, }) => {
|
|
267
328
|
const timerRef = useRef(null);
|
|
268
329
|
const longPressFiredRef = useRef(false);
|
|
@@ -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,
|
|
@@ -1099,6 +1127,12 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1099
1127
|
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
1128
|
const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
|
|
1101
1129
|
const half = size / 2;
|
|
1130
|
+
// Remove-"X" touch target, sized as a fraction of the tile and corner-pinned
|
|
1131
|
+
// so it never reaches the center. A FIXED 36px+hitSlop target blanketed small
|
|
1132
|
+
// tiles (the tile-scale slider can shrink them to ~38px), swallowing the
|
|
1133
|
+
// center grab and making a selected tile impossible to drag (it had to be
|
|
1134
|
+
// deselected first). Capped so it doesn't grow past the old size on big tiles.
|
|
1135
|
+
const removeSize = Math.min(36, size * 0.4);
|
|
1102
1136
|
const anchorX = placed.anchor.x;
|
|
1103
1137
|
const anchorY = placed.anchor.y;
|
|
1104
1138
|
// doc → screen each frame, on the UI thread. Position is a translate
|
|
@@ -1166,11 +1200,11 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
|
|
|
1166
1200
|
selected,
|
|
1167
1201
|
size,
|
|
1168
1202
|
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",
|
|
1203
|
+
}) }), 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
1204
|
position: 'absolute',
|
|
1171
|
-
top:
|
|
1172
|
-
right:
|
|
1173
|
-
width:
|
|
1174
|
-
height:
|
|
1205
|
+
top: 0,
|
|
1206
|
+
right: 0,
|
|
1207
|
+
width: removeSize,
|
|
1208
|
+
height: removeSize,
|
|
1175
1209
|
} }))] }));
|
|
1176
1210
|
};
|
|
@@ -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,6 +1,6 @@
|
|
|
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;
|
|
@@ -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
|
|
@@ -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,6 +2,7 @@ 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, findTextShapeAt } from './textEditing.js';
|
|
5
6
|
const HIT_PADDING = 6;
|
|
6
7
|
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
7
8
|
// override via `hitTest` for more precision later.
|
|
@@ -16,8 +17,14 @@ const hitStroke = (stroke, p) => {
|
|
|
16
17
|
return false;
|
|
17
18
|
};
|
|
18
19
|
// 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
|
-
|
|
20
|
+
// (line/arrow/rect/ellipse/polygon), converted to doc space via zoom. A thin
|
|
21
|
+
// line gives almost nothing to aim at, so the grab corridor is deliberately
|
|
22
|
+
// wider than a fingertip — landing anywhere near the ink grabs it. Kept in sync
|
|
23
|
+
// with measurementGeometry's LINE_GRAB_PX so tap-select and drag-grab agree on
|
|
24
|
+
// what counts as "on the line". (The endpoint-resize handles use their own,
|
|
25
|
+
// tighter HANDLE_GRAB_PX and are checked first on the selected element, so a
|
|
26
|
+
// wider body grab never swallows them.)
|
|
27
|
+
const SHAPE_GRAB_PX = 32;
|
|
21
28
|
// Screen-px radius of the center snap detent when sliding a tile along its line.
|
|
22
29
|
// Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
|
|
23
30
|
// slide worklet inlines the same value — keep them in sync.
|
|
@@ -124,6 +131,23 @@ const translatePatch = (elementKind, id, doc, delta) => {
|
|
|
124
131
|
}
|
|
125
132
|
return { op: 'updateStroke', id, patch: { points } };
|
|
126
133
|
};
|
|
134
|
+
// Whether a world point lands on a measurement's tile (its screen-constant
|
|
135
|
+
// anchor box, converted back to doc space via zoom — the same footprint
|
|
136
|
+
// classifyGrab uses). The tile is the move/slide affordance, so a grab here
|
|
137
|
+
// must win over the endpoint/corner/resize handles that appear once an element
|
|
138
|
+
// is selected; without this, a handle sitting under the tile (a rectangle
|
|
139
|
+
// tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
|
|
140
|
+
// and the tile becomes unmovable until deselected.
|
|
141
|
+
const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
|
|
142
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
143
|
+
if (!m)
|
|
144
|
+
return false;
|
|
145
|
+
const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
|
|
146
|
+
HIT_PADDING) /
|
|
147
|
+
zoom;
|
|
148
|
+
return (Math.abs(world.x - m.anchor.x) <= half &&
|
|
149
|
+
Math.abs(world.y - m.anchor.y) <= half);
|
|
150
|
+
};
|
|
127
151
|
// --- Measurement-annotation grab logic (shared by the native UI-thread drag
|
|
128
152
|
// via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
|
|
129
153
|
// Grabbing the tile of a line annotation slides it along the line; everything
|
|
@@ -348,6 +372,7 @@ export const createSelectTool = () => ({
|
|
|
348
372
|
return op ? { ops: [op] } : null;
|
|
349
373
|
},
|
|
350
374
|
classifyMeasurementGrab: classifyGrab,
|
|
375
|
+
isSelectedTileGrab: isOnMeasurementTile,
|
|
351
376
|
buildSlidePatch: slidePatch,
|
|
352
377
|
hitTestHandle: findHandleHit,
|
|
353
378
|
buildEndpointPatch: endpointPatch,
|
|
@@ -364,9 +389,13 @@ export const createSelectTool = () => ({
|
|
|
364
389
|
onPointerDown(event, ctx) {
|
|
365
390
|
const { world } = event;
|
|
366
391
|
const zoom = ctx.viewport.state.zoom;
|
|
367
|
-
// Endpoint/resize handles show only on the selected element — check first
|
|
392
|
+
// Endpoint/resize handles show only on the selected element — check first,
|
|
393
|
+
// UNLESS the grab is on that element's tile: the tile is the move/slide
|
|
394
|
+
// affordance and must win over a handle sitting under it, so a selected
|
|
395
|
+
// tile stays draggable (otherwise it can only be moved after deselecting).
|
|
368
396
|
const selId = ctx.selection?.ids[0];
|
|
369
|
-
if (selId
|
|
397
|
+
if (selId &&
|
|
398
|
+
!isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
|
|
370
399
|
const handle = findHandleHit(ctx.document, selId, world, zoom);
|
|
371
400
|
if (handle) {
|
|
372
401
|
ctx.setSelection({ ids: [selId] });
|
|
@@ -463,6 +492,14 @@ export const createSelectTool = () => ({
|
|
|
463
492
|
onCancel(_state, ctx) {
|
|
464
493
|
ctx.preview({ ops: [] });
|
|
465
494
|
},
|
|
495
|
+
// Long-pressing a placed text shape re-opens the editor (the same edit flow
|
|
496
|
+
// as tapping it with the text tool, via the shared editTextShape). A hold on
|
|
497
|
+
// any other element — or empty canvas — is ignored.
|
|
498
|
+
onLongPress(event, ctx) {
|
|
499
|
+
const shape = findTextShapeAt(ctx.document, event.world);
|
|
500
|
+
if (shape)
|
|
501
|
+
editTextShape(ctx, shape);
|
|
502
|
+
},
|
|
466
503
|
hitTest(element, p) {
|
|
467
504
|
if (element.kind === 'measurement')
|
|
468
505
|
return hitPlacedMeasurement(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 (long-press-to-edit) so a
|
|
4
|
+
// 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
|
});
|
|
@@ -56,6 +56,7 @@ export interface AnnotationCanvasStateApi {
|
|
|
56
56
|
dispatchPointerMove(event: CanvasPointerEvent): void;
|
|
57
57
|
dispatchPointerUp(event: CanvasPointerEvent): void;
|
|
58
58
|
dispatchPointerCancel(): void;
|
|
59
|
+
dispatchLongPress(event: CanvasPointerEvent): void;
|
|
59
60
|
pan(deltaScreen: Vec2): void;
|
|
60
61
|
zoom(focalScreen: Vec2, nextZoom: number): void;
|
|
61
62
|
setViewport(next: ViewportState): void;
|
|
@@ -121,6 +121,14 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
121
121
|
activePointerIdRef.current = null;
|
|
122
122
|
setToolState(undefined);
|
|
123
123
|
}, [activeTool, ctx, toolState]);
|
|
124
|
+
const dispatchLongPress = useCallback((event) => {
|
|
125
|
+
if (!activeTool)
|
|
126
|
+
return;
|
|
127
|
+
// Fire-and-forget: onLongPress is a discrete action (it opens the text
|
|
128
|
+
// editor), so it neither reads nor writes the gesture's tool state — the
|
|
129
|
+
// in-flight drag/select state on web stays intact underneath it.
|
|
130
|
+
activeTool.onLongPress?.(event, ctx);
|
|
131
|
+
}, [activeTool, ctx]);
|
|
124
132
|
const dispatchPointerCancel = useCallback(() => {
|
|
125
133
|
// Clear FIRST, then let the tool react: state updates batch, so a tool
|
|
126
134
|
// whose onCancel re-emits a preview (the polygon tool keeps its placed
|
|
@@ -376,6 +384,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
376
384
|
dispatchPointerMove,
|
|
377
385
|
dispatchPointerUp,
|
|
378
386
|
dispatchPointerCancel,
|
|
387
|
+
dispatchLongPress,
|
|
379
388
|
pan,
|
|
380
389
|
zoom,
|
|
381
390
|
setViewport,
|