@reekon-tools/boldr-utils 1.6.20 → 1.6.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,9 @@ export interface AnnotationCanvasInnerProps {
24
24
  renderMeasurementStamp?: RenderMeasurementStamp;
25
25
  onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
26
26
  onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
27
+ onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
28
+ resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
29
+ onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
27
30
  stampFontSource?: unknown;
28
31
  stampValueFontSize?: number;
29
32
  stampLabelFontSize?: number;
@@ -3,7 +3,7 @@ import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useMemo, useRef, } from 'react';
4
4
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
5
  import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
6
- import { buildRemoveMeasurementOps } from './measurementGeometry.js';
6
+ import { buildRemoveMeasurementOps, hitPlacedMeasurement, } from './measurementGeometry.js';
7
7
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
8
8
  import { stampTileSize } from './stampLayout.js';
9
9
  // Screen-px radius of a measurement-annotation endpoint handle (matches the
@@ -244,8 +244,28 @@ export const AnnotationCanvasInner = (props) => {
244
244
  ...style,
245
245
  };
246
246
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
247
- const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
248
- return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
247
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, onMeasurementStampDoubleTap, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
248
+ // Double-click a stamp fire the consumer callback (e.g. open its value
249
+ // editor). Runs the SAME hit-test the select tool uses (top-of-z-order), on
250
+ // the gesture container, so the stamp overlay stays pointer-events:none and
251
+ // drag-to-move / single-tap select are unaffected. dblclick is a distinct
252
+ // browser event that doesn't suppress the preceding pointerdown/up select.
253
+ const handleDoubleClick = useCallback((event) => {
254
+ if (!onMeasurementStampDoubleTap)
255
+ return;
256
+ const rect = containerRef.current?.getBoundingClientRect();
257
+ const screen = {
258
+ x: event.clientX - (rect?.left ?? 0),
259
+ y: event.clientY - (rect?.top ?? 0),
260
+ };
261
+ const world = state.ctx.viewport.screenToWorld(screen);
262
+ const placed = [...state.effectiveCanvas.placedMeasurements]
263
+ .reverse()
264
+ .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale));
265
+ if (placed)
266
+ onMeasurementStampDoubleTap(placed);
267
+ }, [onMeasurementStampDoubleTap, state]);
268
+ return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onDoubleClick: handleDoubleClick, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
249
269
  width,
250
270
  height,
251
271
  effectiveCanvas: state.effectiveCanvas,
@@ -279,7 +299,7 @@ export const AnnotationCanvasInner = (props) => {
279
299
  const isSelected = selection?.ids.includes(placed.id) ?? false;
280
300
  const measurement = placed.measurementId
281
301
  ? (state.measurementsById.get(placed.measurementId) ?? null)
282
- : null;
302
+ : (resolveStampMeasurement?.(placed) ?? null);
283
303
  // Corner-pinned, tile-proportional remove target (see the style
284
304
  // comment below) so it can't blanket a small tile and eat its grab.
285
305
  const removeSize = Math.min(40, size * 0.4);
@@ -300,10 +320,17 @@ export const AnnotationCanvasInner = (props) => {
300
320
  ? () => onMeasurementStampLongPress(placed)
301
321
  : undefined })), isSelected && measurement && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
302
322
  e.stopPropagation();
303
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
304
- state.ctx.commit({ ops });
305
- if (!keepSelection)
306
- state.ctx.setSelection(null);
323
+ const defaultRemove = () => {
324
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
325
+ state.ctx.commit({ ops });
326
+ if (!keepSelection)
327
+ state.ctx.setSelection(null);
328
+ };
329
+ if (onMeasurementStampRemove) {
330
+ onMeasurementStampRemove(placed, defaultRemove);
331
+ return;
332
+ }
333
+ defaultRemove();
307
334
  }, style: {
308
335
  // Sized as a fraction of the tile and corner-pinned so it
309
336
  // never covers the center. A fixed 40px target blanketed
@@ -25,6 +25,9 @@ export interface AnnotationCanvasInnerProps {
25
25
  renderMeasurementStamp?: RenderMeasurementStamp;
26
26
  onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
27
27
  onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
28
+ onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
29
+ resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
30
+ onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
28
31
  stampFontSource?: unknown;
29
32
  stampValueFontSize?: number;
30
33
  stampLabelFontSize?: number;
@@ -1049,7 +1049,7 @@ export const AnnotationCanvasInner = (props) => {
1049
1049
  ]);
1050
1050
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
1051
1051
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
1052
- const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
1052
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
1053
1053
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
1054
1054
  width,
1055
1055
  height,
@@ -1117,11 +1117,18 @@ export const AnnotationCanvasInner = (props) => {
1117
1117
  customPreview,
1118
1118
  }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1119
1119
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1120
- : null, selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1121
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1122
- state.ctx.commit({ ops });
1123
- if (!keepSelection)
1124
- state.ctx.setSelection(null);
1120
+ : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1121
+ const defaultRemove = () => {
1122
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1123
+ state.ctx.commit({ ops });
1124
+ if (!keepSelection)
1125
+ state.ctx.setSelection(null);
1126
+ };
1127
+ if (onMeasurementStampRemove) {
1128
+ onMeasurementStampRemove(placed, defaultRemove);
1129
+ return;
1130
+ }
1131
+ defaultRemove();
1125
1132
  } }, placed.id))) }))] }));
1126
1133
  };
1127
1134
  const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
@@ -5,7 +5,5 @@ export declare const DEFAULT_TILE_SCALE = 1;
5
5
  export declare const TILE_SCALE_MIN = 0.4;
6
6
  export declare const TILE_SCALE_MAX = 2;
7
7
  export declare const clampTileScale: (v: number) => number;
8
- export declare const renderedDocWidthAtFit: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
9
- export declare const viewportTileScale: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
10
- export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
11
- export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
8
+ export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
9
+ export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
@@ -30,60 +30,40 @@ export const TILE_SCALE_MAX = 2;
30
30
  // Clamp a tile-scale-factor candidate to the supported range. The single guard
31
31
  // for the value before it lands in the document (slider input, restored docs).
32
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
- };
33
+ // --- Tile sizing is independent of canvas size ------------------------------
34
+ // A measurement tile's footprint is base × per-tile `scale` × the document-wide
35
+ // `tileScaleFactor` (below) and NOTHING tied to the canvas's pixel size, so
36
+ // the same document shows the same tiles at any canvas/window size.
37
+ //
38
+ // Earlier builds multiplied the footprint by a per-canvas `viewportTileScale`
39
+ // derived from how large the document rendered when fit to the canvas, to keep a
40
+ // tile a constant fraction of the drawing across devices. But that made a tile's
41
+ // size change whenever the canvas was resized (e.g. dragging a desktop pane)
42
+ // the same document read with different-sized tiles at different window sizes.
43
+ // That auto-multiplier is retired in favor of the user-driven "Tile size"
44
+ // slider (the document-wide `tileScaleFactor`), which is explicit, persisted,
45
+ // and consistent everywhere. Mobile is unchanged its old factor was already
46
+ // pinned at 1, calibrated to a phone's width.
47
+ //
48
+ // `stampTileSize` keeps its `viewportScale` parameter (default 1) only so the
49
+ // render overlay, hit-test, and tools that thread it stay call-compatible; it is
50
+ // now always 1.
75
51
  // A placed measurement is an unassociated input until a measurement reference
76
- // is attached (id or path). Such stamps use STAMP_INPUT_TILE_SIZE.
77
- export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
52
+ // is attached (id or path) OR it is bound to a form column (`columnId`). Such
53
+ // stamps use STAMP_INPUT_TILE_SIZE. A column-bound tile is a full editable
54
+ // input *card* (it shows the column's label + value editor), so it takes the
55
+ // full STAMP_TILE_SIZE like an associated tile — only the truly blank "+"
56
+ // placeholder uses the compact input size.
57
+ export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath && !m.columnId;
78
58
  // Screen-space edge length for a placed stamp: the compact input size while
79
59
  // unassociated, full size once a measurement is attached, then scaled by the
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`.
60
+ // per-stamp `scale` and the document-wide `tileScaleFactor`. The ONE source of
61
+ // truth for tile footprint render overlay, hit-test, and slide-grab
62
+ // classification all call this so the drawn tile and its touch box always agree.
63
+ // `tileScaleFactor` is the document-wide knob (default 1, from
64
+ // `AnnotationCanvasState.tileScaleFactor`, driven by the "Tile size" slider).
65
+ // `viewportScale` is retained for call compatibility and is always 1 the old
66
+ // per-canvas multiplier was retired (see the note above). Independent of zoom.
87
67
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
88
68
  (m.scale ?? 1) *
89
69
  tileScaleFactor *
@@ -2,8 +2,20 @@ import { stampTileSize } from '../stampLayout.js';
2
2
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
3
  import { hitShapeOutline } from '../shapeGeometry.js';
4
4
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
5
- import { editTextShape, findTextShapeAt } from './textEditing.js';
5
+ import { editTextShape } from './textEditing.js';
6
6
  const HIT_PADDING = 6;
7
+ // Whether an element id refers to a text shape (findHit reports text, lines,
8
+ // rects and ellipses all as kind 'shape'; only text supports tap-to-edit).
9
+ const isTextShape = (doc, id) => doc.shapes.some((s) => s.id === id && s.kind === 'text');
10
+ // Re-tap-to-edit for text. The pointer-down records a text shape's id here only
11
+ // when that shape was *already* selected before this gesture; a release with no
12
+ // movement then re-opens its editor. Held at module scope (not in ToolState)
13
+ // because the native tap gesture synthesizes pointer-down and pointer-up in a
14
+ // single React tick — a ToolState set on down isn't visible to up, but this is.
15
+ // Only one select gesture is ever in flight, so a shared cell is safe. A first
16
+ // tap (shape not yet selected) leaves this null, so it only selects; the second
17
+ // tap edits. Cleared on drag, release, and cancel.
18
+ let pendingTextEditId = null;
7
19
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
8
20
  // override via `hitTest` for more precision later.
9
21
  const hitStroke = (stroke, p) => {
@@ -389,6 +401,10 @@ export const createSelectTool = () => ({
389
401
  onPointerDown(event, ctx) {
390
402
  const { world } = event;
391
403
  const zoom = ctx.viewport.state.zoom;
404
+ // Reset the re-tap-to-edit latch; only a body grab of an already-selected
405
+ // text shape (below) re-arms it. Grabbing a handle or empty canvas leaves
406
+ // it cleared, so neither can leak an edit into the next release.
407
+ pendingTextEditId = null;
392
408
  // Endpoint/resize handles show only on the selected element — check first,
393
409
  // UNLESS the grab is on that element's tile: the tile is the move/slide
394
410
  // affordance and must win over a handle sitting under it, so a selected
@@ -451,6 +467,15 @@ export const createSelectTool = () => ({
451
467
  ctx.setSelection(null);
452
468
  return { kind: 'idle' };
453
469
  }
470
+ // Re-tapping an already-selected text shape (without dragging) opens its
471
+ // editor on release — see onPointerUp. `selId` is the selection from before
472
+ // this down, so a first tap only selects; the second tap edits.
473
+ pendingTextEditId =
474
+ hit.kind === 'shape' &&
475
+ hit.id === selId &&
476
+ isTextShape(ctx.document, hit.id)
477
+ ? hit.id
478
+ : null;
454
479
  ctx.setSelection({ ids: [hit.id] });
455
480
  const mode = hit.kind === 'measurement' &&
456
481
  classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
@@ -474,32 +499,39 @@ export const createSelectTool = () => ({
474
499
  x: event.world.x - s.start.x,
475
500
  y: event.world.y - s.start.y,
476
501
  };
502
+ // Any real movement turns this into a drag, not a re-tap — disarm the edit.
503
+ if (delta.x !== 0 || delta.y !== 0)
504
+ pendingTextEditId = null;
477
505
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
478
506
  if (patch)
479
507
  ctx.preview(patch);
480
508
  return { ...s, delta };
481
509
  },
482
510
  onPointerUp(_event, ctx, state) {
511
+ const editId = pendingTextEditId;
512
+ pendingTextEditId = null;
483
513
  const s = state;
484
- if (s?.kind !== 'dragging')
485
- return;
486
- if (s.delta.x === 0 && s.delta.y === 0)
514
+ // A moved selection commits its drag and is never a tap-to-edit.
515
+ if (s?.kind === 'dragging' && (s.delta.x !== 0 || s.delta.y !== 0)) {
516
+ const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
517
+ if (patch)
518
+ ctx.commit(patch);
487
519
  return;
488
- const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
489
- if (patch)
490
- ctx.commit(patch);
520
+ }
521
+ // No movement: re-tapping an already-selected text shape re-opens its
522
+ // editor (the same edit flow as tapping it with the text tool, via the
523
+ // shared editTextShape). `editId` was latched on the down, so this survives
524
+ // the native tap's synchronous down+up where `state` cannot.
525
+ if (editId) {
526
+ const shape = ctx.document.shapes.find((sh) => sh.id === editId);
527
+ if (shape && shape.kind === 'text')
528
+ editTextShape(ctx, shape);
529
+ }
491
530
  },
492
531
  onCancel(_state, ctx) {
532
+ pendingTextEditId = null;
493
533
  ctx.preview({ ops: [] });
494
534
  },
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
- },
503
535
  hitTest(element, p) {
504
536
  if (element.kind === 'measurement')
505
537
  return hitPlacedMeasurement(element, p);
@@ -1,7 +1,7 @@
1
1
  import { hitTestTextShape } from '../textGeometry.js';
2
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.
3
+ // by the text tool (tap-to-edit) and the select tool (tap-a-selected-shape-to-
4
+ // edit) so a press resolves to the same element from either tool.
5
5
  export const findTextShapeAt = (doc, world) => {
6
6
  for (let i = doc.shapes.length - 1; i >= 0; i--) {
7
7
  const s = doc.shapes[i];
@@ -16,6 +16,14 @@ export interface AnnotationCanvasHandle {
16
16
  }): void;
17
17
  setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
18
18
  associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
19
+ bindColumn(id: AnnotationElementId, binding: {
20
+ groupId: string;
21
+ columnId: string;
22
+ }): void;
23
+ placeColumnTileAtCenter(binding: {
24
+ groupId: string;
25
+ columnId: string;
26
+ }): AnnotationElementId;
19
27
  deleteSelected(): void;
20
28
  }
21
29
  export interface UseAnnotationCanvasStateProps {
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
3
  import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
4
  import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
- import { viewportTileScale } from './stampLayout.js';
6
5
  // Platform-agnostic state machine for the annotation canvas. Web and native
7
6
  // inners share this hook; each wraps it with platform-specific event
8
7
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
@@ -22,11 +21,13 @@ export const useAnnotationCanvasState = (props) => {
22
21
  return map;
23
22
  }, [measurements]);
24
23
  const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
25
- // 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
+ // Tiles are sized independently of the canvas: their footprint is base ×
25
+ // per-tile scale × the document-wide `tileScaleFactor` ("Tile size" slider),
26
+ // never the canvas pixel size so resizing the pane no longer rescales tiles.
27
+ // Retained as a (constant 1) value because the overlays (drawn size) and tools
28
+ // (hit box) thread it; keeping them in lockstep at 1 means both still agree.
29
+ // (See stampLayout.ts for why the old per-canvas multiplier was retired.)
30
+ const tileViewportScale = 1;
30
31
  const ctx = useMemo(() => ({
31
32
  document: canvas,
32
33
  selection,
@@ -317,6 +318,39 @@ export const useAnnotationCanvasState = (props) => {
317
318
  ],
318
319
  });
319
320
  },
321
+ bindColumn(id, binding) {
322
+ const c = ctxRef.current;
323
+ c.commit({
324
+ ops: [
325
+ {
326
+ op: 'updateMeasurement',
327
+ id,
328
+ patch: { groupId: binding.groupId, columnId: binding.columnId },
329
+ },
330
+ ],
331
+ });
332
+ },
333
+ placeColumnTileAtCenter(binding) {
334
+ const c = ctxRef.current;
335
+ const anchor = c.viewport.screenToWorld({
336
+ x: width / 2,
337
+ y: height / 2,
338
+ });
339
+ const placed = {
340
+ id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
341
+ layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
342
+ groupId: binding.groupId,
343
+ columnId: binding.columnId,
344
+ anchor,
345
+ showLabel: true,
346
+ showValue: true,
347
+ createdAt: Date.now(),
348
+ };
349
+ c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
350
+ // Select it so the consumer can immediately move it / open its editor.
351
+ c.setSelection({ ids: [placed.id] });
352
+ return placed.id;
353
+ },
320
354
  deleteSelected() {
321
355
  const c = ctxRef.current;
322
356
  const ids = c.selection?.ids;
@@ -50,6 +50,7 @@ export interface PlacedMeasurementRef {
50
50
  measurementPath?: string;
51
51
  measurementId?: string;
52
52
  groupId?: string;
53
+ columnId?: string;
53
54
  anchor: Vec2;
54
55
  placement?: MeasurementPlacement;
55
56
  line?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.20",
3
+ "version": "1.6.23",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",