@reekon-tools/boldr-utils 1.6.12 → 1.6.14
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/AnnotationCanvas.native.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +5 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.js +58 -6
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +5 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +514 -59
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +31 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
- package/dist/annotation/canvas/Tool.d.ts +27 -0
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
- package/dist/annotation/canvas/elements/ShapeElement.js +68 -9
- package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
- package/dist/annotation/canvas/measurementGeometry.d.ts +21 -0
- package/dist/annotation/canvas/measurementGeometry.js +98 -3
- package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
- package/dist/annotation/canvas/shapeGeometry.js +116 -0
- package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
- package/dist/annotation/canvas/strokeGeometry.js +8 -0
- package/dist/annotation/canvas/textGeometry.d.ts +24 -0
- package/dist/annotation/canvas/textGeometry.js +110 -0
- package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/panTool.js +38 -5
- package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/penTool.js +8 -2
- package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
- package/dist/annotation/canvas/tools/polygonTool.js +162 -0
- package/dist/annotation/canvas/tools/selectTool.js +148 -51
- package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
- package/dist/annotation/canvas/tools/shapeTool.js +111 -0
- package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/textTool.js +78 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
- package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -6
- package/dist/annotation/data/coalescedRunner.d.ts +1 -0
- package/dist/annotation/data/coalescedRunner.js +48 -0
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +118 -38
- package/dist/exports.d.ts +9 -4
- package/dist/exports.js +8 -3
- package/dist/formulas/calculateFormula.js +1 -3
- package/dist/types/annotation.d.ts +9 -0
- package/dist/types/firestore.d.ts +4 -0
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { STAMP_TILE_SIZE } from '../stampLayout.js';
|
|
2
|
-
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, } from '../measurementGeometry.js';
|
|
2
|
+
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
|
|
3
|
+
import { hitShapeOutline } from '../shapeGeometry.js';
|
|
4
|
+
import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
|
|
3
5
|
const HIT_PADDING = 6;
|
|
4
6
|
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
5
7
|
// override via `hitTest` for more precision later.
|
|
@@ -13,9 +15,9 @@ const hitStroke = (stroke, p) => {
|
|
|
13
15
|
}
|
|
14
16
|
return false;
|
|
15
17
|
};
|
|
16
|
-
// Screen-space grab tolerance (px)
|
|
17
|
-
// to doc space via zoom
|
|
18
|
-
const
|
|
18
|
+
// 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
|
+
const SHAPE_GRAB_PX = 12;
|
|
19
21
|
// Screen-px radius of the center snap detent when sliding a tile along its line.
|
|
20
22
|
// Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
|
|
21
23
|
// slide worklet inlines the same value — keep them in sync.
|
|
@@ -23,27 +25,6 @@ const SLIDE_SNAP_PX = 12;
|
|
|
23
25
|
// Screen-px grab radius for a line-annotation endpoint handle (converted to doc
|
|
24
26
|
// space via zoom). A bit larger than the drawn handle so it's easy to grab.
|
|
25
27
|
const HANDLE_GRAB_PX = 22;
|
|
26
|
-
const hitMeasurement = (m, p, zoom = 1) => {
|
|
27
|
-
// The stamp renders as a constant *screen*-size square centered on the
|
|
28
|
-
// anchor, so its doc-space footprint shrinks as you zoom in. Convert the
|
|
29
|
-
// screen-space half-extent (+ padding) back to doc space via the zoom so
|
|
30
|
-
// the hit box always matches what's drawn.
|
|
31
|
-
const scale = m.scale ?? 1;
|
|
32
|
-
const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
|
|
33
|
-
const dx = Math.abs(p.x - m.anchor.x);
|
|
34
|
-
const dy = Math.abs(p.y - m.anchor.y);
|
|
35
|
-
if (dx <= half && dy <= half)
|
|
36
|
-
return true;
|
|
37
|
-
// Measurement annotation: also grab anywhere along the line body.
|
|
38
|
-
if (m.line) {
|
|
39
|
-
const r = LINE_GRAB_PX / zoom;
|
|
40
|
-
if (segmentDistanceSq(p.x, p.y, m.line.a.x, m.line.a.y, m.line.b.x, m.line.b.y) <=
|
|
41
|
-
r * r) {
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return false;
|
|
46
|
-
};
|
|
47
28
|
const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
48
29
|
const abx = bx - ax;
|
|
49
30
|
const aby = by - ay;
|
|
@@ -60,34 +41,32 @@ const findHit = (doc, world, zoom) => {
|
|
|
60
41
|
// Hit-test in z-order (top first): measurements > shapes > strokes.
|
|
61
42
|
for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
|
|
62
43
|
const m = doc.placedMeasurements[i];
|
|
63
|
-
if (
|
|
44
|
+
if (hitPlacedMeasurement(m, world, zoom)) {
|
|
64
45
|
return { id: m.id, kind: 'measurement' };
|
|
46
|
+
}
|
|
65
47
|
}
|
|
66
48
|
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
67
|
-
// Default shape hit test: bounding box of the shape's points + padding.
|
|
68
49
|
const s = doc.shapes[i];
|
|
69
|
-
|
|
70
|
-
|
|
50
|
+
if (s.kind === 'text') {
|
|
51
|
+
// Text shapes store only their top-left anchor, so their hit box comes
|
|
52
|
+
// from the estimated text bounds.
|
|
53
|
+
const b = textShapeBounds(s);
|
|
54
|
+
if (!b)
|
|
55
|
+
continue;
|
|
56
|
+
if (world.x >= b.minX - HIT_PADDING &&
|
|
57
|
+
world.x <= b.maxX + HIT_PADDING &&
|
|
58
|
+
world.y >= b.minY - HIT_PADDING &&
|
|
59
|
+
world.y <= b.maxY + HIT_PADDING) {
|
|
60
|
+
return { id: s.id, kind: 'shape' };
|
|
61
|
+
}
|
|
71
62
|
continue;
|
|
72
|
-
let minX = pts[0].x;
|
|
73
|
-
let maxX = pts[0].x;
|
|
74
|
-
let minY = pts[0].y;
|
|
75
|
-
let maxY = pts[0].y;
|
|
76
|
-
for (let j = 1; j < pts.length; j++) {
|
|
77
|
-
const p = pts[j];
|
|
78
|
-
if (p.x < minX)
|
|
79
|
-
minX = p.x;
|
|
80
|
-
if (p.x > maxX)
|
|
81
|
-
maxX = p.x;
|
|
82
|
-
if (p.y < minY)
|
|
83
|
-
minY = p.y;
|
|
84
|
-
if (p.y > maxY)
|
|
85
|
-
maxY = p.y;
|
|
86
63
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
// Geometric shapes hit on their painted outline, not their bounding box —
|
|
65
|
+
// a long diagonal line's box would swallow taps nowhere near the ink, and
|
|
66
|
+
// a shape's interior stays transparent to hits so elements inside remain
|
|
67
|
+
// selectable. Grab radius = half the stroke + a screen-constant pad.
|
|
68
|
+
const tol = (s.style.strokeWidth ?? 2) / 2 + SHAPE_GRAB_PX / zoom;
|
|
69
|
+
if (hitShapeOutline(s, world, tol)) {
|
|
91
70
|
return { id: s.id, kind: 'shape' };
|
|
92
71
|
}
|
|
93
72
|
}
|
|
@@ -111,6 +90,8 @@ const translatePatch = (elementKind, id, doc, delta) => {
|
|
|
111
90
|
const patch = { anchor: tx(m.anchor) };
|
|
112
91
|
if (m.line)
|
|
113
92
|
patch.line = { a: tx(m.line.a), b: tx(m.line.b) };
|
|
93
|
+
if (m.rect)
|
|
94
|
+
patch.rect = { a: tx(m.rect.a), b: tx(m.rect.b) };
|
|
114
95
|
if (m.leader)
|
|
115
96
|
patch.leader = { from: tx(m.leader.from), to: tx(m.leader.to) };
|
|
116
97
|
return { op: 'updateMeasurement', id, patch };
|
|
@@ -174,7 +155,9 @@ const slidePatch = (doc, id, delta, zoom) => {
|
|
|
174
155
|
const snapT = SLIDE_SNAP_PX / zoom / Math.sqrt(lenSq);
|
|
175
156
|
t = snapLinePos(t, snapT);
|
|
176
157
|
const anchor = lerp(m.line.a, m.line.b, t);
|
|
177
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }],
|
|
160
|
+
};
|
|
178
161
|
};
|
|
179
162
|
// Which endpoint handle of a (selected) line annotation is under `world`.
|
|
180
163
|
// Prefers the nearer endpoint when both are within range.
|
|
@@ -207,6 +190,85 @@ const endpointPatch = (doc, id, handle, delta) => {
|
|
|
207
190
|
const anchor = recomputeAnchor(line, 'line', linePosOf(m), m.anchor);
|
|
208
191
|
return { ops: [{ op: 'updateMeasurement', id, patch: { line, anchor } }] };
|
|
209
192
|
};
|
|
193
|
+
// Which corner handle of a (selected) rectangle annotation is under `world`.
|
|
194
|
+
// Prefers the nearest corner when several are within range (small rects).
|
|
195
|
+
const findRectCornerHit = (doc, id, world, zoom) => {
|
|
196
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
197
|
+
if (!m || !m.rect || placementOf(m) !== 'rectangle')
|
|
198
|
+
return null;
|
|
199
|
+
const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
|
|
200
|
+
let best = null;
|
|
201
|
+
for (const corner of ['tl', 'tr', 'bl', 'br']) {
|
|
202
|
+
const p = rectCornerPoint(m.rect, corner);
|
|
203
|
+
const d = (world.x - p.x) ** 2 + (world.y - p.y) ** 2;
|
|
204
|
+
if (d <= r2 && (!best || d < best.d))
|
|
205
|
+
best = { corner, d };
|
|
206
|
+
}
|
|
207
|
+
if (!best)
|
|
208
|
+
return null;
|
|
209
|
+
return {
|
|
210
|
+
corner: best.corner,
|
|
211
|
+
moving: rectCornerPoint(m.rect, best.corner),
|
|
212
|
+
fixed: rectCornerPoint(m.rect, oppositeRectCorner(best.corner)),
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
// Drag one rect corner by a world delta (opposite corner fixed) and re-center
|
|
216
|
+
// the anchor, keeping the tile locked to the rect's center.
|
|
217
|
+
const rectCornerPatch = (doc, id, corner, delta) => {
|
|
218
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
219
|
+
if (!m || !m.rect || placementOf(m) !== 'rectangle')
|
|
220
|
+
return null;
|
|
221
|
+
const moving = rectCornerPoint(m.rect, corner);
|
|
222
|
+
const rect = {
|
|
223
|
+
a: rectCornerPoint(m.rect, oppositeRectCorner(corner)),
|
|
224
|
+
b: { x: moving.x + delta.x, y: moving.y + delta.y },
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
ops: [
|
|
228
|
+
{
|
|
229
|
+
op: 'updateMeasurement',
|
|
230
|
+
id,
|
|
231
|
+
patch: { rect, anchor: rectCenter(rect) },
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
// --- Text-shape resize (corner-scale about the top-left anchor; shared by the
|
|
237
|
+
// native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
|
|
238
|
+
// Resize geometry when the (selected) text shape's corner handle is under
|
|
239
|
+
// `world`, else null. The handle is drawn on the padded selection box's
|
|
240
|
+
// bottom-right corner (see AnnotationCanvasSkia); grab radius matches the
|
|
241
|
+
// measurement endpoint handles.
|
|
242
|
+
const findResizeHandleHit = (doc, id, world, zoom) => {
|
|
243
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
244
|
+
if (!s)
|
|
245
|
+
return null;
|
|
246
|
+
const geom = textResizeGeometry(s);
|
|
247
|
+
if (!geom)
|
|
248
|
+
return null;
|
|
249
|
+
const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
|
|
250
|
+
const dx = world.x - geom.handle.x;
|
|
251
|
+
const dy = world.y - geom.handle.y;
|
|
252
|
+
return dx * dx + dy * dy <= r2 ? geom : null;
|
|
253
|
+
};
|
|
254
|
+
// Scale the text shape's fontSize by the drag (clamped to the geometry's
|
|
255
|
+
// scale range, so it matches the native live preview exactly). The anchor —
|
|
256
|
+
// the scale pivot — is untouched.
|
|
257
|
+
const resizePatch = (doc, id, delta) => {
|
|
258
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
259
|
+
if (!s)
|
|
260
|
+
return null;
|
|
261
|
+
const geom = textResizeGeometry(s);
|
|
262
|
+
if (!geom)
|
|
263
|
+
return null;
|
|
264
|
+
const scale = resizeScaleFromDrag(geom, delta);
|
|
265
|
+
const fontSize = Math.round((s.style.fontSize ?? DEFAULT_TEXT_FONT_SIZE) * scale * 10) / 10;
|
|
266
|
+
return {
|
|
267
|
+
ops: [
|
|
268
|
+
{ op: 'updateShape', id, patch: { style: { ...s.style, fontSize } } },
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
};
|
|
210
272
|
// Patch for the current drag mode (web pointer path), from a world-space delta.
|
|
211
273
|
const dragPatch = (s, doc, delta, zoom) => {
|
|
212
274
|
if (s.mode === 'endpoint' && s.handle) {
|
|
@@ -214,6 +276,11 @@ const dragPatch = (s, doc, delta, zoom) => {
|
|
|
214
276
|
}
|
|
215
277
|
if (s.mode === 'slide')
|
|
216
278
|
return slidePatch(doc, s.id, delta, zoom);
|
|
279
|
+
if (s.mode === 'resize')
|
|
280
|
+
return resizePatch(doc, s.id, delta);
|
|
281
|
+
if (s.mode === 'rect-corner' && s.corner) {
|
|
282
|
+
return rectCornerPatch(doc, s.id, s.corner, delta);
|
|
283
|
+
}
|
|
217
284
|
const op = translatePatch(s.elementKind, s.id, doc, delta);
|
|
218
285
|
return op ? { ops: [op] } : null;
|
|
219
286
|
};
|
|
@@ -234,6 +301,10 @@ export const createSelectTool = () => ({
|
|
|
234
301
|
buildSlidePatch: slidePatch,
|
|
235
302
|
hitTestHandle: findHandleHit,
|
|
236
303
|
buildEndpointPatch: endpointPatch,
|
|
304
|
+
hitTestResizeHandle: findResizeHandleHit,
|
|
305
|
+
buildResizePatch: resizePatch,
|
|
306
|
+
hitTestRectCorner: findRectCornerHit,
|
|
307
|
+
buildRectCornerPatch: rectCornerPatch,
|
|
237
308
|
},
|
|
238
309
|
// Web pointer path. Mirrors the native UI-thread drag using the same shared
|
|
239
310
|
// helpers: an endpoint handle on the selected annotation resizes the line;
|
|
@@ -241,7 +312,7 @@ export const createSelectTool = () => ({
|
|
|
241
312
|
onPointerDown(event, ctx) {
|
|
242
313
|
const { world } = event;
|
|
243
314
|
const zoom = ctx.viewport.state.zoom;
|
|
244
|
-
// Endpoint handles show only on the selected
|
|
315
|
+
// Endpoint/resize handles show only on the selected element — check first.
|
|
245
316
|
const selId = ctx.selection?.ids[0];
|
|
246
317
|
if (selId) {
|
|
247
318
|
const handle = findHandleHit(ctx.document, selId, world, zoom);
|
|
@@ -257,6 +328,29 @@ export const createSelectTool = () => ({
|
|
|
257
328
|
delta: { x: 0, y: 0 },
|
|
258
329
|
};
|
|
259
330
|
}
|
|
331
|
+
if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
|
|
332
|
+
return {
|
|
333
|
+
kind: 'dragging',
|
|
334
|
+
id: selId,
|
|
335
|
+
elementKind: 'shape',
|
|
336
|
+
mode: 'resize',
|
|
337
|
+
start: world,
|
|
338
|
+
delta: { x: 0, y: 0 },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const rectCorner = findRectCornerHit(ctx.document, selId, world, zoom);
|
|
342
|
+
if (rectCorner) {
|
|
343
|
+
ctx.setSelection({ ids: [selId] });
|
|
344
|
+
return {
|
|
345
|
+
kind: 'dragging',
|
|
346
|
+
id: selId,
|
|
347
|
+
elementKind: 'measurement',
|
|
348
|
+
mode: 'rect-corner',
|
|
349
|
+
corner: rectCorner.corner,
|
|
350
|
+
start: world,
|
|
351
|
+
delta: { x: 0, y: 0 },
|
|
352
|
+
};
|
|
353
|
+
}
|
|
260
354
|
}
|
|
261
355
|
const hit = findHit(ctx.document, world, zoom);
|
|
262
356
|
if (!hit) {
|
|
@@ -281,7 +375,10 @@ export const createSelectTool = () => ({
|
|
|
281
375
|
const s = state;
|
|
282
376
|
if (s?.kind !== 'dragging')
|
|
283
377
|
return s;
|
|
284
|
-
const delta = {
|
|
378
|
+
const delta = {
|
|
379
|
+
x: event.world.x - s.start.x,
|
|
380
|
+
y: event.world.y - s.start.y,
|
|
381
|
+
};
|
|
285
382
|
const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
|
|
286
383
|
if (patch)
|
|
287
384
|
ctx.preview(patch);
|
|
@@ -302,7 +399,7 @@ export const createSelectTool = () => ({
|
|
|
302
399
|
},
|
|
303
400
|
hitTest(element, p) {
|
|
304
401
|
if (element.kind === 'measurement')
|
|
305
|
-
return
|
|
402
|
+
return hitPlacedMeasurement(element, p);
|
|
306
403
|
if (element.kind === 'stroke')
|
|
307
404
|
return hitStroke(element, p);
|
|
308
405
|
return false;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AnnotationShape, StrokeCap, Vec2 } from '../../../types/annotation.js';
|
|
2
|
+
import { type ShapeToolKind } from '../shapeGeometry.js';
|
|
3
|
+
import type { Tool } from '../Tool.js';
|
|
4
|
+
export interface ShapeToolOptions {
|
|
5
|
+
kind?: ShapeToolKind;
|
|
6
|
+
id?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
width?: number;
|
|
10
|
+
cap?: StrokeCap;
|
|
11
|
+
dash?: boolean;
|
|
12
|
+
minDragPx?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const buildShapeFromDrag: (opts: {
|
|
15
|
+
kind: ShapeToolKind;
|
|
16
|
+
a: Vec2;
|
|
17
|
+
b: Vec2;
|
|
18
|
+
color: string;
|
|
19
|
+
width: number;
|
|
20
|
+
cap?: StrokeCap;
|
|
21
|
+
dash?: boolean;
|
|
22
|
+
layerId: string;
|
|
23
|
+
id?: string;
|
|
24
|
+
}) => AnnotationShape;
|
|
25
|
+
export declare const createShapeTool: (options?: ShapeToolOptions) => Tool;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { annotationKindFor, shapePointsFromDrag, } from '../shapeGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const DEFAULT_LABELS = {
|
|
6
|
+
line: 'Line',
|
|
7
|
+
rect: 'Rectangle',
|
|
8
|
+
triangle: 'Triangle',
|
|
9
|
+
ellipse: 'Circle',
|
|
10
|
+
};
|
|
11
|
+
// Assemble the committed AnnotationShape for a drag from `a` to `b`. Shared
|
|
12
|
+
// by the web pointer handlers below AND the native UI-thread gesture (which
|
|
13
|
+
// rubber-bands on shared values and calls this once on release) — one source
|
|
14
|
+
// of truth for kind mapping, styling, and the no-undefined-keys rule
|
|
15
|
+
// (Firestore rejects undefined values, so optional style keys are spread in
|
|
16
|
+
// only when set).
|
|
17
|
+
export const buildShapeFromDrag = (opts) => ({
|
|
18
|
+
id: opts.id ?? makeId('shape'),
|
|
19
|
+
layerId: opts.layerId,
|
|
20
|
+
kind: annotationKindFor(opts.kind),
|
|
21
|
+
geometry: { points: shapePointsFromDrag(opts.kind, opts.a, opts.b) },
|
|
22
|
+
style: {
|
|
23
|
+
stroke: opts.color,
|
|
24
|
+
strokeWidth: opts.width,
|
|
25
|
+
...(opts.dash && { dash: true }),
|
|
26
|
+
// Caps only mean something on an open line; 'round' is the implicit
|
|
27
|
+
// default so it stays un-persisted.
|
|
28
|
+
...(opts.kind === 'line' &&
|
|
29
|
+
opts.cap &&
|
|
30
|
+
opts.cap !== 'round' && { cap: opts.cap }),
|
|
31
|
+
},
|
|
32
|
+
createdAt: Date.now(),
|
|
33
|
+
});
|
|
34
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
35
|
+
// Drag-to-draw shape tool (line/arrow/rect/triangle/circle). Press-drag
|
|
36
|
+
// rubber-bands the shape from the press point; release commits it as an
|
|
37
|
+
// AnnotationShape. On native the drag runs on the UI thread via the
|
|
38
|
+
// `shapeDraw` config; the pointer handlers below are the web/parity
|
|
39
|
+
// implementation. Skia-free, like every tool factory.
|
|
40
|
+
export const createShapeTool = (options = {}) => {
|
|
41
|
+
const kind = options.kind ?? 'line';
|
|
42
|
+
const color = options.color ?? '#111827';
|
|
43
|
+
const width = options.width ?? 2;
|
|
44
|
+
const cap = options.cap;
|
|
45
|
+
const dash = options.dash ?? false;
|
|
46
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
47
|
+
return {
|
|
48
|
+
id: options.id ?? kind,
|
|
49
|
+
label: options.label ?? DEFAULT_LABELS[kind],
|
|
50
|
+
cursor: 'crosshair',
|
|
51
|
+
// Drives UI-thread rubber-banding on native (see ShapeDrawConfig).
|
|
52
|
+
shapeDraw: { kind, color, width, ...(cap && { cap }), dash },
|
|
53
|
+
onPointerDown(event, ctx) {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'shape-drawing',
|
|
56
|
+
shape: buildShapeFromDrag({
|
|
57
|
+
kind,
|
|
58
|
+
a: event.world,
|
|
59
|
+
b: event.world,
|
|
60
|
+
color,
|
|
61
|
+
width,
|
|
62
|
+
cap,
|
|
63
|
+
dash,
|
|
64
|
+
layerId: firstLayerId(ctx.document),
|
|
65
|
+
}),
|
|
66
|
+
startWorld: event.world,
|
|
67
|
+
startScreen: event.screen,
|
|
68
|
+
moved: false,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
onPointerMove(event, ctx, state) {
|
|
72
|
+
const s = state;
|
|
73
|
+
if (s?.kind !== 'shape-drawing')
|
|
74
|
+
return s;
|
|
75
|
+
const next = {
|
|
76
|
+
...s,
|
|
77
|
+
moved: true,
|
|
78
|
+
shape: {
|
|
79
|
+
...s.shape,
|
|
80
|
+
geometry: {
|
|
81
|
+
points: shapePointsFromDrag(kind, s.startWorld, event.world),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
ctx.preview({ ops: [{ op: 'addShape', shape: next.shape }] });
|
|
86
|
+
return next;
|
|
87
|
+
},
|
|
88
|
+
onPointerUp(event, ctx, state) {
|
|
89
|
+
const s = state;
|
|
90
|
+
if (s?.kind !== 'shape-drawing')
|
|
91
|
+
return;
|
|
92
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
93
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
94
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
95
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
96
|
+
ctx.preview({ ops: [] });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const shape = {
|
|
100
|
+
...s.shape,
|
|
101
|
+
geometry: {
|
|
102
|
+
points: shapePointsFromDrag(kind, s.startWorld, event.world),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
ctx.commit({ ops: [{ op: 'addShape', shape }] });
|
|
106
|
+
},
|
|
107
|
+
onCancel(_state, ctx) {
|
|
108
|
+
ctx.preview({ ops: [] });
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface TextToolOptions {
|
|
4
|
+
color?: string;
|
|
5
|
+
fontSize?: number;
|
|
6
|
+
dash?: boolean;
|
|
7
|
+
autoSwitchToSelect?: boolean;
|
|
8
|
+
onPlaced?: (shape: AnnotationShape) => void;
|
|
9
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
10
|
+
selectToolId?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const createTextTool: (options?: TextToolOptions) => Tool;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_TEXT_FONT_SIZE, hitTestTextShape } from '../textGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
+
// Topmost text shape under a world point, for tap-to-edit.
|
|
7
|
+
const findTextShapeAt = (doc, world) => {
|
|
8
|
+
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
9
|
+
const s = doc.shapes[i];
|
|
10
|
+
if (s.kind === 'text' && hitTestTextShape(s, world))
|
|
11
|
+
return s;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
// Tap-to-type. Tapping empty canvas opens the consumer's text input and
|
|
16
|
+
// commits a new text shape at the tap point (top-left anchored); tapping an
|
|
17
|
+
// existing text shape re-opens the input pre-filled to edit it (clearing the
|
|
18
|
+
// text deletes the shape). Both paths leave the shape selected and (by
|
|
19
|
+
// default) switch back to select so it can be dragged/resized immediately —
|
|
20
|
+
// the same flow as the measurement stamp tool. Skia-free, like every tool.
|
|
21
|
+
export const createTextTool = (options = {}) => {
|
|
22
|
+
const color = options.color ?? '#111827';
|
|
23
|
+
const fontSize = options.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
24
|
+
const dash = options.dash ?? false;
|
|
25
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
26
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
27
|
+
return {
|
|
28
|
+
id: 'text',
|
|
29
|
+
label: 'Text',
|
|
30
|
+
cursor: 'text',
|
|
31
|
+
onPointerUp(event, ctx) {
|
|
32
|
+
const existing = findTextShapeAt(ctx.document, event.world);
|
|
33
|
+
if (existing) {
|
|
34
|
+
void ctx
|
|
35
|
+
.requestTextInput({ initialText: existing.text })
|
|
36
|
+
.then((text) => {
|
|
37
|
+
if (text === null)
|
|
38
|
+
return;
|
|
39
|
+
if (text === '') {
|
|
40
|
+
ctx.commit({ ops: [{ op: 'removeShape', id: existing.id }] });
|
|
41
|
+
ctx.setSelection(null);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (text !== existing.text) {
|
|
45
|
+
ctx.commit({
|
|
46
|
+
ops: [{ op: 'updateShape', id: existing.id, patch: { text } }],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
ctx.setSelection({ ids: [existing.id] });
|
|
50
|
+
if (autoSwitchToSelect)
|
|
51
|
+
options.onAutoSwitch?.(selectToolId);
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const anchor = event.world;
|
|
56
|
+
void ctx.requestTextInput().then((text) => {
|
|
57
|
+
if (!text)
|
|
58
|
+
return;
|
|
59
|
+
const shape = {
|
|
60
|
+
id: makeId(),
|
|
61
|
+
layerId: firstLayerId(ctx.document),
|
|
62
|
+
kind: 'text',
|
|
63
|
+
geometry: { points: [anchor] },
|
|
64
|
+
// `...(dash && ...)` keeps the key absent (not `false`) for solid
|
|
65
|
+
// text, matching the stroke convention (absent === solid).
|
|
66
|
+
style: { stroke: color, fontSize, ...(dash && { dash: true }) },
|
|
67
|
+
text,
|
|
68
|
+
createdAt: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
ctx.commit({ ops: [{ op: 'addShape', shape }] });
|
|
71
|
+
ctx.setSelection({ ids: [shape.id] });
|
|
72
|
+
options.onPlaced?.(shape);
|
|
73
|
+
if (autoSwitchToSelect)
|
|
74
|
+
options.onAutoSwitch?.(selectToolId);
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Measurement } from '../../types/firestore.js';
|
|
2
2
|
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type Vec2 } from '../../types/annotation.js';
|
|
3
3
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
|
-
import type { CanvasPointerEvent, Tool, ToolContext, ToolState } from './Tool.js';
|
|
4
|
+
import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
|
|
5
5
|
import { type ViewportState } from './viewport.js';
|
|
6
6
|
export interface AnnotationCanvasHandle {
|
|
7
7
|
undo(): void;
|
|
@@ -27,6 +27,7 @@ export interface UseAnnotationCanvasStateProps {
|
|
|
27
27
|
onSelectionChange(selection: Selection | null): void;
|
|
28
28
|
measurements?: Measurement[];
|
|
29
29
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
30
|
+
requestTextInput?: RequestTextInput;
|
|
30
31
|
width: number;
|
|
31
32
|
height: number;
|
|
32
33
|
initialViewport?: ViewportState;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
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
|
-
import { recomputeAnchor, DEFAULT_LINE_POS } from './measurementGeometry.js';
|
|
4
|
+
import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
5
5
|
// Platform-agnostic state machine for the annotation canvas. Web and native
|
|
6
6
|
// inners share this hook; each wraps it with platform-specific event
|
|
7
7
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
8
8
|
export const useAnnotationCanvasState = (props) => {
|
|
9
|
-
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, width, height, initialViewport, imperativeRef, } = props;
|
|
9
|
+
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, imperativeRef, } = props;
|
|
10
10
|
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
11
11
|
const [toolState, setToolState] = useState(undefined);
|
|
12
12
|
const [previewPatch, setPreviewPatch] = useState(null);
|
|
@@ -41,6 +41,11 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
41
41
|
requestPickMeasurement() {
|
|
42
42
|
return pickMeasurement ? pickMeasurement() : Promise.resolve(null);
|
|
43
43
|
},
|
|
44
|
+
requestTextInput(options) {
|
|
45
|
+
return requestTextInput
|
|
46
|
+
? requestTextInput(options)
|
|
47
|
+
: Promise.resolve(null);
|
|
48
|
+
},
|
|
44
49
|
applyPan(deltaScreen) {
|
|
45
50
|
setViewport((v) => panBy(v, deltaScreen));
|
|
46
51
|
},
|
|
@@ -54,11 +59,30 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
54
59
|
onCommit,
|
|
55
60
|
onSelectionChange,
|
|
56
61
|
pickMeasurement,
|
|
62
|
+
requestTextInput,
|
|
57
63
|
]);
|
|
58
64
|
// Live ctx for imperative handle methods (which are created in an effect and
|
|
59
65
|
// would otherwise capture a stale ctx/viewport).
|
|
60
66
|
const ctxRef = useRef(ctx);
|
|
61
67
|
ctxRef.current = ctx;
|
|
68
|
+
// Tool hand-over. When the active tool's identity changes — a real tool
|
|
69
|
+
// switch OR the consumer rebuilding the tools array (e.g. a style change
|
|
70
|
+
// recreates every factory) — give the outgoing instance a chance to wind
|
|
71
|
+
// down multi-gesture work (the polygon tool commits its in-progress
|
|
72
|
+
// vertices), then drop any leftover gesture state/preview so the incoming
|
|
73
|
+
// tool starts clean. Keyed on object identity, not id: a rebuilt instance
|
|
74
|
+
// has fresh closure state, so the old instance must still wind down.
|
|
75
|
+
const prevToolRef = useRef(null);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const prev = prevToolRef.current;
|
|
78
|
+
prevToolRef.current = activeTool;
|
|
79
|
+
if (!prev || prev === activeTool)
|
|
80
|
+
return;
|
|
81
|
+
prev.onDeactivate?.(ctxRef.current);
|
|
82
|
+
activePointerIdRef.current = null;
|
|
83
|
+
setToolState(undefined);
|
|
84
|
+
setPreviewPatch(null);
|
|
85
|
+
}, [activeTool]);
|
|
62
86
|
const dispatchPointerDown = useCallback((event) => {
|
|
63
87
|
if (!activeTool)
|
|
64
88
|
return;
|
|
@@ -90,11 +114,15 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
90
114
|
setToolState(undefined);
|
|
91
115
|
}, [activeTool, ctx, toolState]);
|
|
92
116
|
const dispatchPointerCancel = useCallback(() => {
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
// Clear FIRST, then let the tool react: state updates batch, so a tool
|
|
118
|
+
// whose onCancel re-emits a preview (the polygon tool keeps its placed
|
|
119
|
+
// vertices visible across an interrupting two-finger pan) wins over the
|
|
120
|
+
// clear instead of being clobbered by it.
|
|
95
121
|
activePointerIdRef.current = null;
|
|
96
122
|
setToolState(undefined);
|
|
97
123
|
setPreviewPatch(null);
|
|
124
|
+
if (activeTool)
|
|
125
|
+
activeTool.onCancel?.(toolState, ctx);
|
|
98
126
|
}, [activeTool, ctx, toolState]);
|
|
99
127
|
const pan = useCallback((deltaScreen) => {
|
|
100
128
|
setViewport((v) => panBy(v, deltaScreen));
|
|
@@ -199,8 +227,30 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
199
227
|
const m = c.document.placedMeasurements.find((x) => x.id === id);
|
|
200
228
|
if (!m)
|
|
201
229
|
return;
|
|
202
|
-
if (type === 'rectangle')
|
|
203
|
-
|
|
230
|
+
if (type === 'rectangle') {
|
|
231
|
+
// Reuse the existing rect if the annotation had one; otherwise
|
|
232
|
+
// synthesize a square centered on the anchor (same size clamp as
|
|
233
|
+
// the default line) so the tile doesn't jump.
|
|
234
|
+
const side = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
|
|
235
|
+
const rect = m.rect ?? {
|
|
236
|
+
a: { x: m.anchor.x - side / 2, y: m.anchor.y - side / 2 },
|
|
237
|
+
b: { x: m.anchor.x + side / 2, y: m.anchor.y + side / 2 },
|
|
238
|
+
};
|
|
239
|
+
c.commit({
|
|
240
|
+
ops: [
|
|
241
|
+
{
|
|
242
|
+
op: 'updateMeasurement',
|
|
243
|
+
id,
|
|
244
|
+
patch: {
|
|
245
|
+
placement: 'rectangle',
|
|
246
|
+
rect,
|
|
247
|
+
anchor: rectCenter(rect),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
204
254
|
if (type === 'line') {
|
|
205
255
|
let line = m.line;
|
|
206
256
|
if (!line) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const createCoalescedRunner: (task: () => Promise<void>) => (() => Promise<void>);
|