@reekon-tools/boldr-utils 1.6.13 → 1.6.15

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.
Files changed (37) hide show
  1. package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  3. package/dist/annotation/canvas/AnnotationCanvasInner.js +51 -13
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +370 -57
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
  7. package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
  8. package/dist/annotation/canvas/Tool.d.ts +10 -0
  9. package/dist/annotation/canvas/elements/ShapeElement.js +115 -38
  10. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
  11. package/dist/annotation/canvas/measurementGeometry.js +61 -2
  12. package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
  13. package/dist/annotation/canvas/shapeGeometry.js +116 -0
  14. package/dist/annotation/canvas/stampLayout.d.ts +4 -0
  15. package/dist/annotation/canvas/stampLayout.js +25 -9
  16. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  17. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  18. package/dist/annotation/canvas/tools/measurementTool.d.ts +15 -0
  19. package/dist/annotation/canvas/tools/measurementTool.js +133 -0
  20. package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
  21. package/dist/annotation/canvas/tools/panTool.js +38 -5
  22. package/dist/annotation/canvas/tools/penTool.js +5 -1
  23. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  24. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  25. package/dist/annotation/canvas/tools/selectTool.js +37 -76
  26. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  27. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  28. package/dist/annotation/canvas/tools/textTool.d.ts +3 -1
  29. package/dist/annotation/canvas/tools/textTool.js +28 -3
  30. package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
  31. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
  32. package/dist/exports.d.ts +8 -4
  33. package/dist/exports.js +7 -3
  34. package/dist/formulas/calculateFormula.js +1 -3
  35. package/dist/types/annotation.d.ts +4 -0
  36. package/dist/types/firestore.d.ts +4 -0
  37. package/package.json +1 -1
@@ -0,0 +1,133 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ import { DEFAULT_LINE_POS, recomputeAnchor, rectCenter, } from '../measurementGeometry.js';
3
+ let counter = 0;
4
+ const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
+ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
6
+ // Build the blank measurement annotation for a drag a→b (or a single point for
7
+ // the bare stamp). The payload mirrors useAnnotationCanvasState's
8
+ // placeAnnotationAtCenter so a drawn annotation is identical to a (legacy)
9
+ // center-placed one once committed.
10
+ const buildMeasurement = (opts) => {
11
+ const { id, layerId, placement, linePos, a, b } = opts;
12
+ const base = {
13
+ id,
14
+ layerId,
15
+ showLabel: true,
16
+ showValue: true,
17
+ createdAt: Date.now(),
18
+ };
19
+ if (placement === 'rectangle') {
20
+ const rect = { a, b };
21
+ return { ...base, placement: 'rectangle', rect, anchor: rectCenter(rect) };
22
+ }
23
+ if (placement === 'none') {
24
+ // Bare value tile at the tap point — no line/rect geometry.
25
+ return { ...base, placement: 'none', anchor: b };
26
+ }
27
+ const line = { a, b };
28
+ return {
29
+ ...base,
30
+ placement: 'line',
31
+ line,
32
+ linePos,
33
+ // Tile anchored along the line at linePos; recomputeAnchor keeps it in
34
+ // sync on later edits.
35
+ anchor: recomputeAnchor(line, 'line', linePos, {
36
+ x: (a.x + b.x) / 2,
37
+ y: (a.y + b.y) / 2,
38
+ }),
39
+ };
40
+ };
41
+ // Draw-to-add measurement tool. For 'line'/'rectangle' a press-drag rubber-bands
42
+ // the annotation (a line/rect with a blank value tile) and release commits it;
43
+ // for 'none' a tap drops a bare value tile. Mirrors createShapeTool's
44
+ // interaction so measurements are placed the same way as shapes. The annotation
45
+ // stays blank (no measurement associated) until the user fills it via the tile
46
+ // / keypad. Skia-free; renders its live preview through ctx.preview, so it works
47
+ // on web and (via the generic tool-pan dispatch) on native.
48
+ export const createMeasurementTool = (options = {}) => {
49
+ const placement = options.placement ?? 'line';
50
+ const linePos = options.linePos ?? DEFAULT_LINE_POS;
51
+ const minDragPx = options.minDragPx ?? 4;
52
+ const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
53
+ const selectToolId = options.selectToolId ?? 'select';
54
+ const place = (ctx, measurement) => {
55
+ ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
56
+ ctx.setSelection({ ids: [measurement.id] });
57
+ options.onPlaced?.(measurement);
58
+ if (autoSwitchToSelect)
59
+ options.onAutoSwitch?.(selectToolId);
60
+ };
61
+ // Bare stamp: tap-to-place, no rubber-band.
62
+ if (placement === 'none') {
63
+ return {
64
+ id: options.id ?? 'measure-bare',
65
+ label: options.label ?? 'Measurement stamp',
66
+ cursor: 'copy',
67
+ onPointerUp(event, ctx) {
68
+ place(ctx, buildMeasurement({
69
+ id: makeId(),
70
+ layerId: firstLayerId(ctx.document),
71
+ placement: 'none',
72
+ linePos,
73
+ a: event.world,
74
+ b: event.world,
75
+ }));
76
+ },
77
+ };
78
+ }
79
+ const defaultId = placement === 'rectangle' ? 'measure-rect' : 'measure-line';
80
+ const defaultLabel = placement === 'rectangle' ? 'Measurement rectangle' : 'Measurement line';
81
+ return {
82
+ id: options.id ?? defaultId,
83
+ label: options.label ?? defaultLabel,
84
+ cursor: 'crosshair',
85
+ onPointerDown(event) {
86
+ return {
87
+ kind: 'measurement-drawing',
88
+ id: makeId(),
89
+ startWorld: event.world,
90
+ startScreen: event.screen,
91
+ moved: false,
92
+ };
93
+ },
94
+ onPointerMove(event, ctx, state) {
95
+ const s = state;
96
+ if (s?.kind !== 'measurement-drawing')
97
+ return s;
98
+ const measurement = buildMeasurement({
99
+ id: s.id,
100
+ layerId: firstLayerId(ctx.document),
101
+ placement,
102
+ linePos,
103
+ a: s.startWorld,
104
+ b: event.world,
105
+ });
106
+ ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
107
+ return { ...s, moved: true };
108
+ },
109
+ onPointerUp(event, ctx, state) {
110
+ const s = state;
111
+ if (s?.kind !== 'measurement-drawing')
112
+ return;
113
+ const dx = event.screen.x - s.startScreen.x;
114
+ const dy = event.screen.y - s.startScreen.y;
115
+ if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
116
+ // Accidental tap — discard the rubber-band, commit nothing.
117
+ ctx.preview({ ops: [] });
118
+ return;
119
+ }
120
+ place(ctx, buildMeasurement({
121
+ id: s.id,
122
+ layerId: firstLayerId(ctx.document),
123
+ placement,
124
+ linePos,
125
+ a: s.startWorld,
126
+ b: event.world,
127
+ }));
128
+ },
129
+ onCancel(_state, ctx) {
130
+ ctx.preview({ ops: [] });
131
+ },
132
+ };
133
+ };
@@ -1,5 +1,6 @@
1
1
  import type { Tool } from '../Tool.js';
2
2
  export interface PanToolOptions {
3
3
  cursor?: string;
4
+ selectMeasurementsOnTap?: boolean;
4
5
  }
5
6
  export declare const createPanTool: (options?: PanToolOptions) => Tool;
@@ -1,3 +1,8 @@
1
+ import { hitPlacedMeasurement } from '../measurementGeometry.js';
2
+ // Screen-px movement beyond which a gesture counts as a pan, not a tap. The
3
+ // native canvas never dispatches pans to JS handlers (they run on the UI
4
+ // thread), so this only guards web's down→jitter→up clicks.
5
+ const TAP_SLOP_PX = 6;
1
6
  // Built-in "Hand" tool. When active, any drag pans the viewport. Tools that
2
7
  // want one-off pan triggers (middle-mouse, space-drag) get those via the
3
8
  // AnnotationCanvas `gestures` prop without needing to switch tools.
@@ -6,10 +11,16 @@ export const createPanTool = (options = {}) => ({
6
11
  label: 'Hand',
7
12
  cursor: options.cursor ?? 'grab',
8
13
  // Native pans the viewport on the UI thread (see Tool.panViewport). The
9
- // onPointer* handlers below remain the web/parity implementation.
14
+ // onPointer* handlers below remain the web/parity implementation; taps
15
+ // still reach them on native via the synthesized down+up dispatch.
10
16
  panViewport: true,
11
17
  onPointerDown(event, _ctx, _state) {
12
- return { kind: 'panning', lastScreen: event.screen };
18
+ return {
19
+ kind: 'panning',
20
+ startScreen: event.screen,
21
+ lastScreen: event.screen,
22
+ moved: false,
23
+ };
13
24
  },
14
25
  onPointerMove(event, ctx, state) {
15
26
  const s = state;
@@ -20,9 +31,31 @@ export const createPanTool = (options = {}) => ({
20
31
  y: event.screen.y - s.lastScreen.y,
21
32
  };
22
33
  ctx.applyPan(delta);
23
- return { kind: 'panning', lastScreen: event.screen };
34
+ const dx = event.screen.x - s.startScreen.x;
35
+ const dy = event.screen.y - s.startScreen.y;
36
+ return {
37
+ ...s,
38
+ lastScreen: event.screen,
39
+ moved: s.moved || dx * dx + dy * dy > TAP_SLOP_PX * TAP_SLOP_PX,
40
+ };
24
41
  },
25
- onPointerUp(_event, _ctx, _state) {
26
- // No commit — viewport state is canvas-internal, not persisted.
42
+ onPointerUp(event, ctx, state) {
43
+ // No commit — viewport state is canvas-internal, not persisted. A clean
44
+ // tap optionally selects the measurement under it (see options).
45
+ const s = state;
46
+ if (!options.selectMeasurementsOnTap)
47
+ return;
48
+ if (s?.kind === 'panning' && s.moved)
49
+ return;
50
+ const zoom = ctx.viewport.state.zoom;
51
+ const measurements = ctx.document.placedMeasurements;
52
+ for (let i = measurements.length - 1; i >= 0; i--) {
53
+ const m = measurements[i];
54
+ if (hitPlacedMeasurement(m, event.world, zoom)) {
55
+ ctx.setSelection({ ids: [m.id] });
56
+ return;
57
+ }
58
+ }
59
+ ctx.setSelection(null);
27
60
  },
28
61
  });
@@ -20,7 +20,11 @@ export const createPenTool = (options = {}) => {
20
20
  const dotCap = cap === 'butt' || cap === 'arrow' ? 'round' : cap;
21
21
  return {
22
22
  id: variant,
23
- label: variant === 'pen' ? 'Pen' : variant === 'marker' ? 'Marker' : 'Highlighter',
23
+ label: variant === 'pen'
24
+ ? 'Pen'
25
+ : variant === 'marker'
26
+ ? 'Marker'
27
+ : 'Highlighter',
24
28
  cursor: 'crosshair',
25
29
  // Drives UI-thread drawing on native (see FreehandConfig). The
26
30
  // onPointerDown/Move/Up below remain the web/parity implementation.
@@ -0,0 +1,11 @@
1
+ import type { AnnotationShape } from '../../../types/annotation.js';
2
+ import type { Tool } from '../Tool.js';
3
+ export interface PolygonToolOptions {
4
+ id?: string;
5
+ label?: string;
6
+ color?: string;
7
+ width?: number;
8
+ dash?: boolean;
9
+ onPlaced?: (shape: AnnotationShape) => void;
10
+ }
11
+ export declare const createPolygonTool: (options?: PolygonToolOptions) => Tool;
@@ -0,0 +1,162 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ // Screen-px radius for "tapped an existing vertex": the first vertex closes
3
+ // the polygon, the last vertex finishes it open. Converted to doc space via
4
+ // the live zoom so the target is a constant on-screen size.
5
+ const VERTEX_TAP_PX = 18;
6
+ // Screen-px radius of the placed-vertex dots drawn while building (the first
7
+ // vertex grows once the polygon is closable, to advertise the close target).
8
+ const VERTEX_DOT_PX = 5;
9
+ const CLOSE_TARGET_DOT_PX = 8;
10
+ let counter = 0;
11
+ const makeId = () => `polygon-${Date.now().toString(36)}-${(counter++).toString(36)}`;
12
+ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
13
+ const distSq = (a, b) => {
14
+ const dx = a.x - b.x;
15
+ const dy = a.y - b.y;
16
+ return dx * dx + dy * dy;
17
+ };
18
+ // Tap-to-place polygon tool. Each tap (or drag-release) adds a vertex;
19
+ // tapping the FIRST vertex closes the ring and commits; tapping the LAST
20
+ // vertex again (a double-tap, in practice) commits it as an open polyline.
21
+ // Switching tools mid-build commits the open polyline too (work is never
22
+ // silently discarded) via onDeactivate. In-progress geometry renders through
23
+ // preview patches — the polyline plus a dot per placed vertex — so it needs
24
+ // no platform-specific preview plumbing. Skia-free, like every tool factory.
25
+ export const createPolygonTool = (options = {}) => {
26
+ const color = options.color ?? '#111827';
27
+ const width = options.width ?? 2;
28
+ const dash = options.dash ?? false;
29
+ // In-progress polygon (spans gestures — see PolygonGestureState).
30
+ let vertices = [];
31
+ let shapeId = null;
32
+ const buildShape = (doc, points, closed) => ({
33
+ id: shapeId ?? makeId(),
34
+ layerId: firstLayerId(doc),
35
+ kind: 'polygon',
36
+ // Snapshot the array: `points` is usually the live `vertices` buffer, and
37
+ // downstream memoization (ShapeElement keys its Skia path on
38
+ // geometry.points IDENTITY) must see a new array per emit — handing out
39
+ // the shared reference froze the preview polyline at its first segment.
40
+ geometry: { points: [...points], ...(closed ? {} : { closed: false }) },
41
+ style: { stroke: color, strokeWidth: width, ...(dash && { dash: true }) },
42
+ createdAt: Date.now(),
43
+ });
44
+ // Preview = the open polyline so far (+ the in-drag rubber point) plus a
45
+ // dot per placed vertex. Dots are preview-only ellipse shapes — they ride
46
+ // the same patch and never get committed. Sized in doc units from the live
47
+ // zoom at emit time (close enough; the preview re-emits on every event).
48
+ const previewOps = (doc, zoom, rubber) => {
49
+ if (vertices.length === 0)
50
+ return [];
51
+ const pts = rubber ? [...vertices, rubber] : vertices;
52
+ const ops = [];
53
+ if (pts.length >= 2) {
54
+ ops.push({
55
+ op: 'addShape',
56
+ shape: { ...buildShape(doc, pts, false), id: `${shapeId}-draft` },
57
+ });
58
+ }
59
+ const closable = vertices.length >= 3;
60
+ vertices.forEach((v, i) => {
61
+ const px = i === 0 && closable ? CLOSE_TARGET_DOT_PX : VERTEX_DOT_PX;
62
+ const r = px / zoom;
63
+ ops.push({
64
+ op: 'addShape',
65
+ shape: {
66
+ id: `${shapeId}-v${i}`,
67
+ layerId: firstLayerId(doc),
68
+ kind: 'ellipse',
69
+ geometry: {
70
+ points: [
71
+ { x: v.x - r, y: v.y - r },
72
+ { x: v.x + r, y: v.y + r },
73
+ ],
74
+ },
75
+ style: { fill: color, stroke: '#FFFFFF', strokeWidth: r / 3 },
76
+ createdAt: 0,
77
+ },
78
+ });
79
+ });
80
+ return ops;
81
+ };
82
+ const reset = () => {
83
+ vertices = [];
84
+ shapeId = null;
85
+ };
86
+ const commit = (ctx, closed) => {
87
+ const shape = buildShape(ctx.document, vertices, closed);
88
+ ctx.commit({ ops: [{ op: 'addShape', shape }] });
89
+ reset();
90
+ options.onPlaced?.(shape);
91
+ };
92
+ return {
93
+ id: options.id ?? 'polygon',
94
+ label: options.label ?? 'Polygon',
95
+ cursor: 'crosshair',
96
+ onPointerDown() {
97
+ return { kind: 'polygon-pointing' };
98
+ },
99
+ onPointerMove(event, ctx, state) {
100
+ const s = state;
101
+ if (s?.kind !== 'polygon-pointing')
102
+ return s;
103
+ if (vertices.length === 0)
104
+ return s;
105
+ ctx.preview({
106
+ ops: previewOps(ctx.document, ctx.viewport.state.zoom, event.world),
107
+ });
108
+ return s;
109
+ },
110
+ onPointerUp(event, ctx, state) {
111
+ const s = state;
112
+ if (s?.kind !== 'polygon-pointing')
113
+ return;
114
+ const zoom = ctx.viewport.state.zoom;
115
+ const tol = VERTEX_TAP_PX / zoom;
116
+ const tolSq = tol * tol;
117
+ const p = event.world;
118
+ // Tap on the first vertex → close the ring and commit.
119
+ if (vertices.length >= 3 && distSq(p, vertices[0]) <= tolSq) {
120
+ commit(ctx, true);
121
+ return;
122
+ }
123
+ // Tap on the last vertex (tap-again / double-tap) → finish open.
124
+ if (vertices.length >= 1 &&
125
+ distSq(p, vertices[vertices.length - 1]) <= tolSq) {
126
+ if (vertices.length >= 2) {
127
+ commit(ctx, false);
128
+ }
129
+ else {
130
+ // Re-tapped the only vertex — treat as "never mind".
131
+ reset();
132
+ ctx.preview({ ops: [] });
133
+ }
134
+ return;
135
+ }
136
+ if (!shapeId)
137
+ shapeId = makeId();
138
+ // Immutable append (defense in depth alongside buildShape's snapshot):
139
+ // nothing downstream can ever observe this buffer mutating.
140
+ vertices = [...vertices, { x: p.x, y: p.y }];
141
+ ctx.preview({ ops: previewOps(ctx.document, zoom) });
142
+ },
143
+ // Gesture interrupted (e.g. a second finger landed and the viewport pan
144
+ // took over). Keep the placed vertices and re-establish the preview — the
145
+ // canvas clears the preview patch before calling this.
146
+ onCancel(_state, ctx) {
147
+ if (vertices.length === 0)
148
+ return;
149
+ ctx.preview({ ops: previewOps(ctx.document, ctx.viewport.state.zoom) });
150
+ },
151
+ // Switching tools mid-build: commit what exists as an open polyline (two
152
+ // or more vertices make a real shape; a lone vertex is discarded).
153
+ onDeactivate(ctx) {
154
+ if (vertices.length >= 2) {
155
+ commit(ctx, false);
156
+ }
157
+ else {
158
+ reset();
159
+ }
160
+ },
161
+ };
162
+ };
@@ -1,5 +1,6 @@
1
- import { STAMP_TILE_SIZE } from '../stampLayout.js';
2
- import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, } from '../measurementGeometry.js';
1
+ import { stampTileSize } from '../stampLayout.js';
2
+ import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
+ import { hitShapeOutline } from '../shapeGeometry.js';
3
4
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
4
5
  const HIT_PADDING = 6;
5
6
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
@@ -14,9 +15,9 @@ const hitStroke = (stroke, p) => {
14
15
  }
15
16
  return false;
16
17
  };
17
- // Screen-space grab tolerance (px) for a measurement-annotation line, converted
18
- // to doc space via zoom (the line itself is a thin world-space stroke).
19
- const LINE_GRAB_PX = 12;
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;
20
21
  // Screen-px radius of the center snap detent when sliding a tile along its line.
21
22
  // Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
22
23
  // slide worklet inlines the same value — keep them in sync.
@@ -24,39 +25,6 @@ const SLIDE_SNAP_PX = 12;
24
25
  // Screen-px grab radius for a line-annotation endpoint handle (converted to doc
25
26
  // space via zoom). A bit larger than the drawn handle so it's easy to grab.
26
27
  const HANDLE_GRAB_PX = 22;
27
- const hitMeasurement = (m, p, zoom = 1) => {
28
- // The stamp renders as a constant *screen*-size square centered on the
29
- // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
30
- // screen-space half-extent (+ padding) back to doc space via the zoom so
31
- // the hit box always matches what's drawn.
32
- const scale = m.scale ?? 1;
33
- const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
34
- const dx = Math.abs(p.x - m.anchor.x);
35
- const dy = Math.abs(p.y - m.anchor.y);
36
- if (dx <= half && dy <= half)
37
- return true;
38
- // Measurement annotation: also grab anywhere along the line body.
39
- if (m.line) {
40
- const r = LINE_GRAB_PX / zoom;
41
- if (segmentDistanceSq(p.x, p.y, m.line.a.x, m.line.a.y, m.line.b.x, m.line.b.y) <=
42
- r * r) {
43
- return true;
44
- }
45
- }
46
- // Rectangle annotation: grab anywhere along the border (the interior stays
47
- // transparent to hits so elements behind the rect remain selectable).
48
- if (m.rect && placementOf(m) === 'rectangle') {
49
- const n = normalizeRect(m.rect);
50
- const r2 = (LINE_GRAB_PX / zoom) ** 2;
51
- if (segmentDistanceSq(p.x, p.y, n.minX, n.minY, n.maxX, n.minY) <= r2 ||
52
- segmentDistanceSq(p.x, p.y, n.maxX, n.minY, n.maxX, n.maxY) <= r2 ||
53
- segmentDistanceSq(p.x, p.y, n.maxX, n.maxY, n.minX, n.maxY) <= r2 ||
54
- segmentDistanceSq(p.x, p.y, n.minX, n.maxY, n.minX, n.minY) <= r2) {
55
- return true;
56
- }
57
- }
58
- return false;
59
- };
60
28
  const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
61
29
  const abx = bx - ax;
62
30
  const aby = by - ay;
@@ -73,48 +41,32 @@ const findHit = (doc, world, zoom) => {
73
41
  // Hit-test in z-order (top first): measurements > shapes > strokes.
74
42
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
75
43
  const m = doc.placedMeasurements[i];
76
- if (hitMeasurement(m, world, zoom))
44
+ if (hitPlacedMeasurement(m, world, zoom)) {
77
45
  return { id: m.id, kind: 'measurement' };
46
+ }
78
47
  }
79
48
  for (let i = doc.shapes.length - 1; i >= 0; i--) {
80
- // Default shape hit test: bounding box of the shape's points + padding.
81
- // Text shapes store only their top-left anchor, so their box comes from
82
- // the estimated text bounds instead.
83
49
  const s = doc.shapes[i];
84
- let minX;
85
- let maxX;
86
- let minY;
87
- let maxY;
88
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.
89
53
  const b = textShapeBounds(s);
90
54
  if (!b)
91
55
  continue;
92
- ({ minX, maxX, minY, maxY } = b);
93
- }
94
- else {
95
- const pts = s.geometry.points;
96
- if (pts.length === 0)
97
- continue;
98
- minX = pts[0].x;
99
- maxX = pts[0].x;
100
- minY = pts[0].y;
101
- maxY = pts[0].y;
102
- for (let j = 1; j < pts.length; j++) {
103
- const p = pts[j];
104
- if (p.x < minX)
105
- minX = p.x;
106
- if (p.x > maxX)
107
- maxX = p.x;
108
- if (p.y < minY)
109
- minY = p.y;
110
- if (p.y > maxY)
111
- maxY = p.y;
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' };
112
61
  }
62
+ continue;
113
63
  }
114
- if (world.x >= minX - HIT_PADDING &&
115
- world.x <= maxX + HIT_PADDING &&
116
- world.y >= minY - HIT_PADDING &&
117
- world.y <= maxY + HIT_PADDING) {
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)) {
118
70
  return { id: s.id, kind: 'shape' };
119
71
  }
120
72
  }
@@ -180,8 +132,8 @@ const classifyGrab = (doc, id, world, zoom) => {
180
132
  const m = doc.placedMeasurements.find((x) => x.id === id);
181
133
  if (!m)
182
134
  return 'move';
183
- const scale = m.scale ?? 1;
184
- const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
135
+ // Same footprint the tile is drawn at (smaller for unassociated inputs, #6).
136
+ const half = (stampTileSize(m) / 2 + HIT_PADDING) / zoom;
185
137
  const onTile = Math.abs(world.x - m.anchor.x) <= half &&
186
138
  Math.abs(world.y - m.anchor.y) <= half;
187
139
  return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
@@ -203,7 +155,9 @@ const slidePatch = (doc, id, delta, zoom) => {
203
155
  const snapT = SLIDE_SNAP_PX / zoom / Math.sqrt(lenSq);
204
156
  t = snapLinePos(t, snapT);
205
157
  const anchor = lerp(m.line.a, m.line.b, t);
206
- return { ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }] };
158
+ return {
159
+ ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }],
160
+ };
207
161
  };
208
162
  // Which endpoint handle of a (selected) line annotation is under `world`.
209
163
  // Prefers the nearer endpoint when both are within range.
@@ -271,7 +225,11 @@ const rectCornerPatch = (doc, id, corner, delta) => {
271
225
  };
272
226
  return {
273
227
  ops: [
274
- { op: 'updateMeasurement', id, patch: { rect, anchor: rectCenter(rect) } },
228
+ {
229
+ op: 'updateMeasurement',
230
+ id,
231
+ patch: { rect, anchor: rectCenter(rect) },
232
+ },
275
233
  ],
276
234
  };
277
235
  };
@@ -417,7 +375,10 @@ export const createSelectTool = () => ({
417
375
  const s = state;
418
376
  if (s?.kind !== 'dragging')
419
377
  return s;
420
- const delta = { x: event.world.x - s.start.x, y: event.world.y - s.start.y };
378
+ const delta = {
379
+ x: event.world.x - s.start.x,
380
+ y: event.world.y - s.start.y,
381
+ };
421
382
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
422
383
  if (patch)
423
384
  ctx.preview(patch);
@@ -438,7 +399,7 @@ export const createSelectTool = () => ({
438
399
  },
439
400
  hitTest(element, p) {
440
401
  if (element.kind === 'measurement')
441
- return hitMeasurement(element, p);
402
+ return hitPlacedMeasurement(element, p);
442
403
  if (element.kind === 'stroke')
443
404
  return hitStroke(element, p);
444
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;