@reekon-tools/boldr-utils 1.6.18 → 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.
Files changed (86) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.js +79 -12
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +100 -13
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +5 -1
  4. package/dist/annotation/canvas/AnnotationCanvasSkia.js +53 -4
  5. package/dist/annotation/canvas/Tool.d.ts +7 -2
  6. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -1
  7. package/dist/annotation/canvas/measurementGeometry.js +7 -5
  8. package/dist/annotation/canvas/stampLayout.d.ts +8 -2
  9. package/dist/annotation/canvas/stampLayout.js +72 -9
  10. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  11. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  12. package/dist/annotation/canvas/tools/measurementTool.js +8 -2
  13. package/dist/annotation/canvas/tools/panTool.js +1 -1
  14. package/dist/annotation/canvas/tools/selectTool.js +116 -13
  15. package/dist/annotation/canvas/tools/textEditing.d.ts +4 -0
  16. package/dist/annotation/canvas/tools/textEditing.js +36 -0
  17. package/dist/annotation/canvas/tools/textTool.js +3 -26
  18. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -0
  19. package/dist/annotation/canvas/useAnnotationCanvasState.js +18 -0
  20. package/dist/exports.d.ts +1 -0
  21. package/dist/exports.js +1 -0
  22. package/dist/types/annotation.d.ts +4 -0
  23. package/dist/types/annotation.js +6 -0
  24. package/dist/types/firestore.d.ts +53 -0
  25. package/dist/types/firestore.js +49 -0
  26. package/package.json +1 -1
  27. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  28. package/dist/canvas/AnnotationCanvas.js +0 -10
  29. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  30. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  31. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  32. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  33. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  34. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  35. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  36. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  37. package/dist/canvas/Tool.d.ts +0 -38
  38. package/dist/canvas/Tool.js +0 -1
  39. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  40. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  41. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  42. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  43. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  44. package/dist/canvas/elements/ShapeElement.js +0 -62
  45. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  46. package/dist/canvas/elements/StrokeElement.js +0 -18
  47. package/dist/canvas/measurementPicker.d.ts +0 -10
  48. package/dist/canvas/measurementPicker.js +0 -1
  49. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  50. package/dist/canvas/measurementStampOverlay.js +0 -1
  51. package/dist/canvas/pointerAdapter.d.ts +0 -3
  52. package/dist/canvas/pointerAdapter.js +0 -19
  53. package/dist/canvas/stampLayout.d.ts +0 -5
  54. package/dist/canvas/stampLayout.js +0 -14
  55. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  56. package/dist/canvas/tools/measurementStampTool.js +0 -37
  57. package/dist/canvas/tools/panTool.d.ts +0 -5
  58. package/dist/canvas/tools/panTool.js +0 -25
  59. package/dist/canvas/tools/penTool.d.ts +0 -13
  60. package/dist/canvas/tools/penTool.js +0 -68
  61. package/dist/canvas/tools/selectTool.d.ts +0 -2
  62. package/dist/canvas/tools/selectTool.js +0 -182
  63. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  64. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  65. package/dist/canvas/viewport.d.ts +0 -16
  66. package/dist/canvas/viewport.js +0 -54
  67. package/dist/data/AnnotationDataContext.d.ts +0 -8
  68. package/dist/data/AnnotationDataContext.js +0 -11
  69. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  70. package/dist/data/AnnotationDataProvider.js +0 -4
  71. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  72. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  73. package/dist/data/canvasPersistence.d.ts +0 -3
  74. package/dist/data/canvasPersistence.js +0 -26
  75. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  76. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  77. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  78. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  79. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  80. package/dist/data/hooks/useAnnotationList.js +0 -26
  81. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  82. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  83. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  84. package/dist/hooks/useParseMeasurement.js +0 -14
  85. package/dist/utils/evaluateFormula.d.ts +0 -20
  86. package/dist/utils/evaluateFormula.js +0 -31
@@ -11,17 +11,80 @@
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. Smaller than STAMP_TILE_SIZE so empty
15
- // inputs read as lightweight tap targets; associated tiles keep STAMP_TILE_SIZE
16
- // so existing saved annotations are visually unchanged. The single knob for #6.
17
- export const STAMP_INPUT_TILE_SIZE = 56;
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;
20
+ // Document-wide tile scale factor (AnnotationCanvasState.tileScaleFactor): one
21
+ // knob that shrinks/grows EVERY measurement tile on the canvas at once, on top
22
+ // of each tile's own `scale`. Lets a user pull tiles down on a dense drawing
23
+ // where lines crowd together, or bump them up on a sparse one. Like `scale` it
24
+ // is purely a screen-space multiplier — it does NOT change with zoom. Absent ===
25
+ // DEFAULT_TILE_SCALE (visually identical to documents written before the knob
26
+ // existed).
27
+ export const DEFAULT_TILE_SCALE = 1;
28
+ export const TILE_SCALE_MIN = 0.4;
29
+ export const TILE_SCALE_MAX = 2;
30
+ // Clamp a tile-scale-factor candidate to the supported range. The single guard
31
+ // for the value before it lands in the document (slider input, restored docs).
32
+ export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
33
+ // --- Viewport-relative tile sizing ------------------------------------------
34
+ // A bare-pixel tile is the same size on every device, but the SAME document is
35
+ // fit into wildly different canvases (a phone window vs a desktop pane), so the
36
+ // drawing renders much larger on desktop and a fixed-px tile reads as a tiny
37
+ // fraction of it. To keep a tile a CONSISTENT fraction of the drawing across
38
+ // platforms — while staying independent of the user's live zoom — the tile
39
+ // footprint is multiplied by `viewportTileScale`, which tracks how large the
40
+ // document renders when fit to the canvas (NOT the live zoom).
41
+ // Rendered document width (screen px) at which a tile uses its unscaled base
42
+ // size. Calibrated to a large phone's width so phone canvases land at scale 1
43
+ // (mobile visually unchanged); larger canvases scale up proportionally.
44
+ const TILE_VIEWPORT_REFERENCE_PX = 430;
45
+ // Clamp so phone-sized canvases never shrink tiles below base (lower = 1) and
46
+ // very large monitors don't produce runaway tiles (upper = 4).
47
+ const TILE_VIEWPORT_SCALE_MIN = 1;
48
+ const TILE_VIEWPORT_SCALE_MAX = 4;
49
+ // Screen-px width the document occupies when fit to the canvas:
50
+ // docW * fitZoom, fitZoom = min(canvasW/docW, canvasH/docH)
51
+ // = min(canvasW, docW * canvasH / docH)
52
+ // This — not the raw canvas size — governs the tile's fraction of the drawing,
53
+ // so the same document at the same canvas aspect yields the same value on web
54
+ // and native. Falls back to canvasW when doc dimensions are unknown.
55
+ export const renderedDocWidthAtFit = (canvasW, canvasH, docW, docH) => {
56
+ if (!(docW > 0) || !(docH > 0) || !(canvasW > 0) || !(canvasH > 0)) {
57
+ return canvasW > 0 ? canvasW : TILE_VIEWPORT_REFERENCE_PX;
58
+ }
59
+ return Math.min(canvasW, (docW * canvasH) / docH);
60
+ };
61
+ // Multiplier folded into the tile footprint so tiles are a consistent fraction
62
+ // of the rendered drawing on any canvas. Zoom-INDEPENDENT (a function of canvas
63
+ // + doc dimensions only), so the tile still never changes size as the user
64
+ // pinches/wheels. Defaults to 1 (callers without canvas dimensions are
65
+ // unaffected — e.g. legacy tests).
66
+ export const viewportTileScale = (canvasW, canvasH, docW, docH) => {
67
+ const s = renderedDocWidthAtFit(canvasW, canvasH, docW, docH) /
68
+ TILE_VIEWPORT_REFERENCE_PX;
69
+ return s < TILE_VIEWPORT_SCALE_MIN
70
+ ? TILE_VIEWPORT_SCALE_MIN
71
+ : s > TILE_VIEWPORT_SCALE_MAX
72
+ ? TILE_VIEWPORT_SCALE_MAX
73
+ : s;
74
+ };
18
75
  // A placed measurement is an unassociated input until a measurement reference
19
76
  // is attached (id or path). Such stamps use STAMP_INPUT_TILE_SIZE.
20
77
  export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
21
78
  // Screen-space edge length for a placed stamp: the compact input size while
22
79
  // unassociated, full size once a measurement is attached, then scaled by the
23
- // per-stamp `scale`. The ONE source of truth for tile footprint — render
24
- // overlay, hit-test, and slide-grab classification all call this so the drawn
25
- // tile and its touch box always agree.
26
- export const stampTileSize = (m) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
27
- (m.scale ?? 1);
80
+ // per-stamp `scale`, the document-wide `tileScaleFactor`, and the
81
+ // `viewportTileScale` (so tiles read at a consistent fraction of the drawing on
82
+ // any canvas). The ONE source of truth for tile footprint — render overlay,
83
+ // hit-test, and slide-grab classification all call this so the drawn tile and
84
+ // its touch box always agree. `tileScaleFactor` is the canvas-level knob
85
+ // (default 1, from `AnnotationCanvasState.tileScaleFactor`); `viewportScale`
86
+ // (default 1) is the per-canvas multiplier from `viewportTileScale`.
87
+ export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
88
+ (m.scale ?? 1) *
89
+ tileScaleFactor *
90
+ viewportScale;
@@ -0,0 +1,12 @@
1
+ import type { PlacedMeasurementRef } from '../../../types/annotation.js';
2
+ import type { Tool } from '../Tool.js';
3
+ export interface MeasurementLineToolOptions {
4
+ id?: string;
5
+ label?: string;
6
+ minDragPx?: number;
7
+ autoSwitchToSelect?: boolean;
8
+ selectToolId?: string;
9
+ onAutoSwitch?: (toToolId: string) => void;
10
+ onPlaced?: (measurement: PlacedMeasurementRef) => void;
11
+ }
12
+ export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
@@ -0,0 +1,95 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ import { DEFAULT_LINE_POS, recomputeAnchor } 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 line-measurement annotation for a drag from `a` to `b`: a
7
+ // 2-point line with a value tile magnetized to its center (linePos 0.5). The
8
+ // payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
9
+ // line and a (legacy) center-placed one are byte-identical once committed.
10
+ const buildLineMeasurement = (opts) => {
11
+ const line = { a: opts.a, b: opts.b };
12
+ return {
13
+ id: opts.id,
14
+ layerId: opts.layerId,
15
+ placement: 'line',
16
+ line,
17
+ linePos: DEFAULT_LINE_POS,
18
+ // Center of the line; recomputeAnchor keeps this in sync on edits.
19
+ anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
20
+ x: (opts.a.x + opts.b.x) / 2,
21
+ y: (opts.a.y + opts.b.y) / 2,
22
+ }),
23
+ showLabel: true,
24
+ showValue: true,
25
+ createdAt: Date.now(),
26
+ };
27
+ };
28
+ // Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
29
+ // annotation (a line with a blank value tile at its center); release commits
30
+ // it. Mirrors createShapeTool's interaction so "input lines" are placed the
31
+ // same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
32
+ // old center-place affordance. The annotation stays blank (no measurement
33
+ // associated) until the user fills it via the tile / picker. Skia-free, like
34
+ // every tool factory; it renders its live preview through ctx.preview, so it
35
+ // works on web and (via the generic tool-pan dispatch) on native.
36
+ export const createMeasurementLineTool = (options = {}) => {
37
+ const minDragPx = options.minDragPx ?? 4;
38
+ const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
39
+ const selectToolId = options.selectToolId ?? 'select';
40
+ return {
41
+ id: options.id ?? 'measure-line',
42
+ label: options.label ?? 'Measurement line',
43
+ cursor: 'crosshair',
44
+ onPointerDown(event) {
45
+ return {
46
+ kind: 'measurement-line-drawing',
47
+ id: makeId(),
48
+ startWorld: event.world,
49
+ startScreen: event.screen,
50
+ moved: false,
51
+ };
52
+ },
53
+ onPointerMove(event, ctx, state) {
54
+ const s = state;
55
+ if (s?.kind !== 'measurement-line-drawing')
56
+ return s;
57
+ const measurement = buildLineMeasurement({
58
+ id: s.id,
59
+ layerId: firstLayerId(ctx.document),
60
+ a: s.startWorld,
61
+ b: event.world,
62
+ });
63
+ ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
64
+ return { ...s, moved: true };
65
+ },
66
+ onPointerUp(event, ctx, state) {
67
+ const s = state;
68
+ if (s?.kind !== 'measurement-line-drawing')
69
+ return;
70
+ const dx = event.screen.x - s.startScreen.x;
71
+ const dy = event.screen.y - s.startScreen.y;
72
+ if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
73
+ // Accidental tap — discard the rubber-band, commit nothing.
74
+ ctx.preview({ ops: [] });
75
+ return;
76
+ }
77
+ const measurement = buildLineMeasurement({
78
+ id: s.id,
79
+ layerId: firstLayerId(ctx.document),
80
+ a: s.startWorld,
81
+ b: event.world,
82
+ });
83
+ ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
84
+ // Leave it selected (and hand back to select) so the blank line can be
85
+ // filled / moved straight away — the same flow as the text tool.
86
+ ctx.setSelection({ ids: [measurement.id] });
87
+ options.onPlaced?.(measurement);
88
+ if (autoSwitchToSelect)
89
+ options.onAutoSwitch?.(selectToolId);
90
+ },
91
+ onCancel(_state, ctx) {
92
+ ctx.preview({ ops: [] });
93
+ },
94
+ };
95
+ };
@@ -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
- if (autoSwitchToSelect)
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') {
@@ -51,7 +51,7 @@ export const createPanTool = (options = {}) => ({
51
51
  const measurements = ctx.document.placedMeasurements;
52
52
  for (let i = measurements.length - 1; i >= 0; i--) {
53
53
  const m = measurements[i];
54
- if (hitPlacedMeasurement(m, event.world, zoom)) {
54
+ if (hitPlacedMeasurement(m, event.world, zoom, ctx.document.tileScaleFactor, ctx.tileViewportScale)) {
55
55
  ctx.setSelection({ ids: [m.id] });
56
56
  return;
57
57
  }
@@ -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
- const SHAPE_GRAB_PX = 12;
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.
@@ -37,11 +44,11 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
37
44
  const dy = py - cy;
38
45
  return dx * dx + dy * dy;
39
46
  };
40
- const findHit = (doc, world, zoom) => {
47
+ const findHit = (doc, world, zoom, viewportTileScale = 1) => {
41
48
  // Hit-test in z-order (top first): measurements > shapes > strokes.
42
49
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
43
50
  const m = doc.placedMeasurements[i];
44
- if (hitPlacedMeasurement(m, world, zoom)) {
51
+ if (hitPlacedMeasurement(m, world, zoom, doc.tileScaleFactor, viewportTileScale)) {
45
52
  return { id: m.id, kind: 'measurement' };
46
53
  }
47
54
  }
@@ -124,16 +131,37 @@ 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
130
154
  // else (bare stamps, grabs on the line body) is a group move.
131
- const classifyGrab = (doc, id, world, zoom) => {
155
+ const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1) => {
132
156
  const m = doc.placedMeasurements.find((x) => x.id === id);
133
157
  if (!m)
134
158
  return 'move';
135
- // Same footprint the tile is drawn at (smaller for unassociated inputs, #6).
136
- const half = (stampTileSize(m) / 2 + HIT_PADDING) / zoom;
159
+ // Same footprint the tile is drawn at (smaller for unassociated inputs, #6;
160
+ // folds in the document-wide tile scale + viewport scale so slide-grab
161
+ // matches the draw).
162
+ const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
163
+ HIT_PADDING) /
164
+ zoom;
137
165
  const onTile = Math.abs(world.x - m.anchor.x) <= half &&
138
166
  Math.abs(world.y - m.anchor.y) <= half;
139
167
  return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
@@ -190,6 +218,48 @@ const endpointPatch = (doc, id, handle, delta) => {
190
218
  const anchor = recomputeAnchor(line, 'line', linePosOf(m), m.anchor);
191
219
  return { ops: [{ op: 'updateMeasurement', id, patch: { line, anchor } }] };
192
220
  };
221
+ // Which endpoint handle of a (selected) line/arrow SHAPE is under `world`.
222
+ // Shape lines store their endpoints as geometry.points[0]/[1] (unlike
223
+ // measurement lines, which carry them on `line.a/b` alongside a tile). Prefers
224
+ // the nearer endpoint when both are within range.
225
+ const findShapeHandleHit = (doc, id, world, zoom) => {
226
+ const s = doc.shapes.find((x) => x.id === id);
227
+ if (!s || (s.kind !== 'line' && s.kind !== 'arrow'))
228
+ return null;
229
+ const [a, b] = s.geometry.points;
230
+ if (!a || !b)
231
+ return null;
232
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
233
+ const da = (world.x - a.x) ** 2 + (world.y - a.y) ** 2;
234
+ const db = (world.x - b.x) ** 2 + (world.y - b.y) ** 2;
235
+ if (da <= r2 && da <= db)
236
+ return 'a';
237
+ if (db <= r2)
238
+ return 'b';
239
+ return null;
240
+ };
241
+ // Move one endpoint of a line/arrow shape by a world delta (resize/rotate).
242
+ // Rewrites the whole geometry (updateShape merges shallowly at the top level,
243
+ // so `closed` and any other geometry fields must be carried through).
244
+ const shapeEndpointPatch = (doc, id, handle, delta) => {
245
+ const s = doc.shapes.find((x) => x.id === id);
246
+ if (!s || (s.kind !== 'line' && s.kind !== 'arrow'))
247
+ return null;
248
+ const [a, b] = s.geometry.points;
249
+ if (!a || !b)
250
+ return null;
251
+ const na = handle === 'a' ? { x: a.x + delta.x, y: a.y + delta.y } : a;
252
+ const nb = handle === 'b' ? { x: b.x + delta.x, y: b.y + delta.y } : b;
253
+ return {
254
+ ops: [
255
+ {
256
+ op: 'updateShape',
257
+ id,
258
+ patch: { geometry: { ...s.geometry, points: [na, nb] } },
259
+ },
260
+ ],
261
+ };
262
+ };
193
263
  // Which corner handle of a (selected) rectangle annotation is under `world`.
194
264
  // Prefers the nearest corner when several are within range (small rects).
195
265
  const findRectCornerHit = (doc, id, world, zoom) => {
@@ -272,7 +342,11 @@ const resizePatch = (doc, id, delta) => {
272
342
  // Patch for the current drag mode (web pointer path), from a world-space delta.
273
343
  const dragPatch = (s, doc, delta, zoom) => {
274
344
  if (s.mode === 'endpoint' && s.handle) {
275
- return endpointPatch(doc, s.id, s.handle, delta);
345
+ // Shape lines and measurement lines store endpoints differently, so the
346
+ // patch builder is keyed on which kind is being dragged.
347
+ return s.elementKind === 'shape'
348
+ ? shapeEndpointPatch(doc, s.id, s.handle, delta)
349
+ : endpointPatch(doc, s.id, s.handle, delta);
276
350
  }
277
351
  if (s.mode === 'slide')
278
352
  return slidePatch(doc, s.id, delta, zoom);
@@ -292,15 +366,18 @@ export const createSelectTool = () => ({
292
366
  // reusing the same hit-test and translate logic the pointer handlers below
293
367
  // use for web — one source of truth.
294
368
  dragSelection: {
295
- hitTest: (doc, world, zoom) => findHit(doc, world, zoom),
369
+ hitTest: (doc, world, zoom, viewportTileScale) => findHit(doc, world, zoom, viewportTileScale),
296
370
  buildTranslatePatch: (doc, id, kind, delta) => {
297
371
  const op = translatePatch(kind, id, doc, delta);
298
372
  return op ? { ops: [op] } : null;
299
373
  },
300
374
  classifyMeasurementGrab: classifyGrab,
375
+ isSelectedTileGrab: isOnMeasurementTile,
301
376
  buildSlidePatch: slidePatch,
302
377
  hitTestHandle: findHandleHit,
303
378
  buildEndpointPatch: endpointPatch,
379
+ hitTestShapeHandle: findShapeHandleHit,
380
+ buildShapeEndpointPatch: shapeEndpointPatch,
304
381
  hitTestResizeHandle: findResizeHandleHit,
305
382
  buildResizePatch: resizePatch,
306
383
  hitTestRectCorner: findRectCornerHit,
@@ -312,9 +389,13 @@ export const createSelectTool = () => ({
312
389
  onPointerDown(event, ctx) {
313
390
  const { world } = event;
314
391
  const zoom = ctx.viewport.state.zoom;
315
- // 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).
316
396
  const selId = ctx.selection?.ids[0];
317
- if (selId) {
397
+ if (selId &&
398
+ !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
318
399
  const handle = findHandleHit(ctx.document, selId, world, zoom);
319
400
  if (handle) {
320
401
  ctx.setSelection({ ids: [selId] });
@@ -328,6 +409,19 @@ export const createSelectTool = () => ({
328
409
  delta: { x: 0, y: 0 },
329
410
  };
330
411
  }
412
+ const shapeHandle = findShapeHandleHit(ctx.document, selId, world, zoom);
413
+ if (shapeHandle) {
414
+ ctx.setSelection({ ids: [selId] });
415
+ return {
416
+ kind: 'dragging',
417
+ id: selId,
418
+ elementKind: 'shape',
419
+ mode: 'endpoint',
420
+ handle: shapeHandle,
421
+ start: world,
422
+ delta: { x: 0, y: 0 },
423
+ };
424
+ }
331
425
  if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
332
426
  return {
333
427
  kind: 'dragging',
@@ -352,14 +446,15 @@ export const createSelectTool = () => ({
352
446
  };
353
447
  }
354
448
  }
355
- const hit = findHit(ctx.document, world, zoom);
449
+ const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale);
356
450
  if (!hit) {
357
451
  ctx.setSelection(null);
358
452
  return { kind: 'idle' };
359
453
  }
360
454
  ctx.setSelection({ ids: [hit.id] });
361
455
  const mode = hit.kind === 'measurement' &&
362
- classifyGrab(ctx.document, hit.id, world, zoom) === 'slide'
456
+ classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
457
+ 'slide'
363
458
  ? 'slide'
364
459
  : 'move';
365
460
  return {
@@ -397,6 +492,14 @@ export const createSelectTool = () => ({
397
492
  onCancel(_state, ctx) {
398
493
  ctx.preview({ ops: [] });
399
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
+ },
400
503
  hitTest(element, p) {
401
504
  if (element.kind === 'measurement')
402
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, hitTestTextShape } from '../textGeometry.js';
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
- void ctx
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
  });
@@ -49,12 +49,14 @@ export interface AnnotationCanvasStateApi {
49
49
  activeTool: Tool | null;
50
50
  toolState: ToolState;
51
51
  ctx: ToolContext;
52
+ tileViewportScale: number;
52
53
  penDrawingStroke: AnnotationStroke | null;
53
54
  customPreviewState: ToolState;
54
55
  dispatchPointerDown(event: CanvasPointerEvent): void;
55
56
  dispatchPointerMove(event: CanvasPointerEvent): void;
56
57
  dispatchPointerUp(event: CanvasPointerEvent): void;
57
58
  dispatchPointerCancel(): void;
59
+ dispatchLongPress(event: CanvasPointerEvent): void;
58
60
  pan(deltaScreen: Vec2): void;
59
61
  zoom(focalScreen: Vec2, nextZoom: number): void;
60
62
  setViewport(next: ViewportState): void;
@@ -2,6 +2,7 @@ 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';
5
6
  // Platform-agnostic state machine for the annotation canvas. Web and native
6
7
  // inners share this hook; each wraps it with platform-specific event
7
8
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
@@ -21,10 +22,16 @@ export const useAnnotationCanvasState = (props) => {
21
22
  return map;
22
23
  }, [measurements]);
23
24
  const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
25
+ // How large the document renders when fit to this canvas, as a tile-footprint
26
+ // multiplier (zoom-independent). Keeps tiles a consistent fraction of the
27
+ // drawing on a phone vs a desktop pane. Used by the overlays (drawn size) and
28
+ // the tools (hit box) so both agree.
29
+ const tileViewportScale = useMemo(() => viewportTileScale(width, height, canvas.viewport.width, canvas.viewport.height), [width, height, canvas.viewport.width, canvas.viewport.height]);
24
30
  const ctx = useMemo(() => ({
25
31
  document: canvas,
26
32
  selection,
27
33
  viewport: viewportApi,
34
+ tileViewportScale,
28
35
  preview(patch) {
29
36
  setPreviewPatch(patch);
30
37
  },
@@ -56,6 +63,7 @@ export const useAnnotationCanvasState = (props) => {
56
63
  canvas,
57
64
  selection,
58
65
  viewportApi,
66
+ tileViewportScale,
59
67
  onCommit,
60
68
  onSelectionChange,
61
69
  pickMeasurement,
@@ -113,6 +121,14 @@ export const useAnnotationCanvasState = (props) => {
113
121
  activePointerIdRef.current = null;
114
122
  setToolState(undefined);
115
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]);
116
132
  const dispatchPointerCancel = useCallback(() => {
117
133
  // Clear FIRST, then let the tool react: state updates batch, so a tool
118
134
  // whose onCancel re-emits a preview (the polygon tool keeps its placed
@@ -361,12 +377,14 @@ export const useAnnotationCanvasState = (props) => {
361
377
  activeTool,
362
378
  toolState,
363
379
  ctx,
380
+ tileViewportScale,
364
381
  penDrawingStroke,
365
382
  customPreviewState: toolState,
366
383
  dispatchPointerDown,
367
384
  dispatchPointerMove,
368
385
  dispatchPointerUp,
369
386
  dispatchPointerCancel,
387
+ dispatchLongPress,
370
388
  pan,
371
389
  zoom,
372
390
  setViewport,
package/dist/exports.d.ts CHANGED
@@ -21,6 +21,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
21
21
  export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
22
22
  export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
23
23
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
24
+ export { STAMP_TILE_SIZE, STAMP_INPUT_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
24
25
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
25
26
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
26
27
  export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
package/dist/exports.js CHANGED
@@ -21,6 +21,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
21
21
  export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
22
22
  export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
23
23
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
24
+ export { STAMP_TILE_SIZE, STAMP_INPUT_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
24
25
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
25
26
  export { createPenTool, } from './annotation/canvas/tools/penTool.js';
26
27
  export { createSelectTool } from './annotation/canvas/tools/selectTool.js';