@reekon-tools/boldr-utils 1.6.29 → 1.6.30

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.
@@ -42,7 +42,12 @@ export const AnnotationCanvasInner = (props) => {
42
42
  provider.registerFont(textTypeface, TEXT_FONT_FAMILY);
43
43
  return provider;
44
44
  }, [textTypeface]);
45
- const state = useAnnotationCanvasState(props);
45
+ // This is the web renderer, so the canvas reads web's tile-scale knob (web
46
+ // and mobile persist separate values — see resolveTileScaleFactor).
47
+ const state = useAnnotationCanvasState({
48
+ ...props,
49
+ tileScalePlatform: 'web',
50
+ });
46
51
  const containerRef = useRef(null);
47
52
  const panGestureRef = useRef(null);
48
53
  const spaceDownRef = useRef(false);
@@ -261,7 +266,7 @@ export const AnnotationCanvasInner = (props) => {
261
266
  const world = state.ctx.viewport.screenToWorld(screen);
262
267
  const placed = [...state.effectiveCanvas.placedMeasurements]
263
268
  .reverse()
264
- .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale));
269
+ .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.tileScaleFactor, state.tileViewportScale));
265
270
  if (placed)
266
271
  onMeasurementStampDoubleTap(placed);
267
272
  }, [onMeasurementStampDoubleTap, state]);
@@ -293,7 +298,7 @@ export const AnnotationCanvasInner = (props) => {
293
298
  inset: 0,
294
299
  pointerEvents: 'none',
295
300
  }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
296
- const size = stampTileSize(placed, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale);
301
+ const size = stampTileSize(placed, state.tileScaleFactor, state.tileViewportScale);
297
302
  const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
298
303
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
299
304
  const isSelected = selection?.ids.includes(placed.id) ?? false;
@@ -58,7 +58,13 @@ const LOUPE_CROSSHAIR_ARM = 9; // half-length of each crosshair arm, points
58
58
  export const AnnotationCanvasInner = (props) => {
59
59
  const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, } = props;
60
60
  const valueFont = useFont(stampFontSource, stampValueFontSize);
61
- const state = useAnnotationCanvasState(props);
61
+ // This is the native (mobile) renderer, so the canvas reads mobile's
62
+ // tile-scale knob (web and mobile persist separate values — see
63
+ // resolveTileScaleFactor).
64
+ const state = useAnnotationCanvasState({
65
+ ...props,
66
+ tileScalePlatform: 'mobile',
67
+ });
62
68
  // Decode the background image ONCE here and share it with both the main
63
69
  // canvas and the magnifier loupe. If each canvas loaded its own, the loupe
64
70
  // would flash its blank base every gesture while it re-resolved + re-decoded.
@@ -746,7 +752,7 @@ export const AnnotationCanvasInner = (props) => {
746
752
  return;
747
753
  const world = st.ctx.viewport.screenToWorld(screen);
748
754
  const zoomNow = st.ctx.viewport.state.zoom;
749
- const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
755
+ const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
750
756
  if (!hit || hit.kind !== 'measurement' || hit.id !== prevSelectedId) {
751
757
  return;
752
758
  }
@@ -1012,7 +1018,7 @@ export const AnnotationCanvasInner = (props) => {
1012
1018
  // so a selected tile isn't stuck until you deselect it).
1013
1019
  const selId = st.ctx.selection?.ids[0];
1014
1020
  if (selId &&
1015
- !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale)) {
1021
+ !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)) {
1016
1022
  const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
1017
1023
  if (handle) {
1018
1024
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
@@ -1112,7 +1118,7 @@ export const AnnotationCanvasInner = (props) => {
1112
1118
  return;
1113
1119
  }
1114
1120
  }
1115
- const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
1121
+ const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
1116
1122
  if (!hit) {
1117
1123
  st.ctx.setSelection(null);
1118
1124
  dragTargetRef.current = null;
@@ -1121,7 +1127,7 @@ export const AnnotationCanvasInner = (props) => {
1121
1127
  st.ctx.setSelection({ ids: [hit.id] });
1122
1128
  // Grabbing a line annotation's tile slides it; otherwise group-move.
1123
1129
  const grab = hit.kind === 'measurement'
1124
- ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale)
1130
+ ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)
1125
1131
  : 'move';
1126
1132
  if (grab === 'slide') {
1127
1133
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
@@ -1423,19 +1429,17 @@ export const AnnotationCanvasInner = (props) => {
1423
1429
  const loupeWrap = (content) => (_jsxs(_Fragment, { children: [_jsxs(Group, { clip: loupeClip, children: [_jsx(Rect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, color: LOUPE_BG_COLOR }), content] }), _jsx(RoundedRect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, r: LOUPE_RADIUS, color: LOUPE_BORDER_COLOR, style: "stroke", strokeWidth: LOUPE_BORDER_WIDTH }), _jsx(Line, { p1: { x: loupeCx - LOUPE_CROSSHAIR_ARM, y: loupeCy }, p2: { x: loupeCx + LOUPE_CROSSHAIR_ARM, y: loupeCy }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 }), _jsx(Line, { p1: { x: loupeCx, y: loupeCy - LOUPE_CROSSHAIR_ARM }, p2: { x: loupeCx, y: loupeCy + LOUPE_CROSSHAIR_ARM }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 })] }));
1424
1430
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1425
1431
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1426
- : (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: () => {
1427
- const defaultRemove = () => {
1428
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1429
- state.ctx.commit({ ops });
1430
- if (!keepSelection)
1431
- state.ctx.setSelection(null);
1432
- };
1433
- if (onMeasurementStampRemove) {
1432
+ : (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.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1433
+ ? () => {
1434
+ const defaultRemove = () => {
1435
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1436
+ state.ctx.commit({ ops });
1437
+ if (!keepSelection)
1438
+ state.ctx.setSelection(null);
1439
+ };
1434
1440
  onMeasurementStampRemove(placed, defaultRemove);
1435
- return;
1436
1441
  }
1437
- defaultRemove();
1438
- } }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
1442
+ : undefined }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
1439
1443
  ...skiaProps,
1440
1444
  worldTransform: loupeTransform,
1441
1445
  wrapContent: loupeWrap,
@@ -1517,7 +1521,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1517
1521
  selected,
1518
1522
  size,
1519
1523
  zoom: zoomSnapshot,
1520
- }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
1524
+ }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && onRemove && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
1521
1525
  position: 'absolute',
1522
1526
  top: 0,
1523
1527
  right: 0,
@@ -23,6 +23,7 @@ export interface ToolContext {
23
23
  selection: Selection | null;
24
24
  viewport: ViewportApi;
25
25
  tileViewportScale: number;
26
+ tileScaleFactor: number;
26
27
  preview(patch: AnnotationDocumentPatch): void;
27
28
  commit(patch: AnnotationDocumentPatch): void;
28
29
  setSelection(selection: Selection | null): void;
@@ -53,12 +54,12 @@ export interface ShapeDrawConfig {
53
54
  }
54
55
  export type DragElementKind = 'stroke' | 'shape' | 'measurement';
55
56
  export interface DragSelectionConfig {
56
- hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number): {
57
+ hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): {
57
58
  id: AnnotationElementId;
58
59
  kind: DragElementKind;
59
60
  } | null;
60
61
  buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
61
- classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): 'slide' | 'move';
62
+ classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): 'slide' | 'move';
62
63
  buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
63
64
  hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
64
65
  buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
@@ -76,7 +77,7 @@ export interface DragSelectionConfig {
76
77
  fixed: Vec2;
77
78
  } | null;
78
79
  buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
79
- isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): boolean;
80
+ isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): boolean;
80
81
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
81
82
  buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
82
83
  }
@@ -1,8 +1,9 @@
1
- import type { PlacedMeasurementRef } from '../../types/annotation.js';
1
+ import type { AnnotationCanvasState, PlacedMeasurementRef, TileScalePlatform } from '../../types/annotation.js';
2
2
  export declare const STAMP_TILE_SIZE = 96;
3
3
  export declare const DEFAULT_TILE_SCALE = 1;
4
4
  export declare const TILE_SCALE_MIN = 0.4;
5
5
  export declare const TILE_SCALE_MAX = 2;
6
+ export declare const resolveTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile">, platform: TileScalePlatform) => number;
6
7
  export declare const clampTileScale: (v: number) => number;
7
8
  export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
8
9
  export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
@@ -12,16 +12,28 @@
12
12
  // user who set the tile-scale slider against blank tiles saw their tiles
13
13
  // balloon once populated (Asana 1216233394800417).
14
14
  export const STAMP_TILE_SIZE = 96;
15
- // Document-wide tile scale factor (AnnotationCanvasState.tileScaleFactor): one
16
- // knob that shrinks/grows EVERY measurement tile on the canvas at once, on top
17
- // of each tile's own `scale`. Lets a user pull tiles down on a dense drawing
18
- // where lines crowd together, or bump them up on a sparse one. Like `scale` it
19
- // is purely a screen-space multiplier — it does NOT change with zoom. Absent ===
20
- // DEFAULT_TILE_SCALE (visually identical to documents written before the knob
21
- // existed).
15
+ // Document-wide tile scale factor: one knob that shrinks/grows EVERY
16
+ // measurement tile on the canvas at once, on top of each tile's own `scale`.
17
+ // Lets a user pull tiles down on a dense drawing where lines crowd together,
18
+ // or bump them up on a sparse one. Like `scale` it is purely a screen-space
19
+ // multiplier — it does NOT change with zoom. Absent === DEFAULT_TILE_SCALE
20
+ // (visually identical to documents written before the knob existed).
21
+ // Web and mobile persist separate knobs (AnnotationCanvasState.tileScaleFactor
22
+ // vs .tileScaleFactorMobile) — read them through resolveTileScaleFactor below.
22
23
  export const DEFAULT_TILE_SCALE = 1;
23
24
  export const TILE_SCALE_MIN = 0.4;
24
25
  export const TILE_SCALE_MAX = 2;
26
+ // Resolve the document-wide tile-scale knob for a platform. Web and mobile
27
+ // persist separate values because a scale calibrated against a desktop window
28
+ // is wrong on a phone screen (and vice versa): web reads `tileScaleFactor`
29
+ // (also the legacy shared field from before the split); mobile reads its own
30
+ // `tileScaleFactorMobile` and falls back to the shared field, so a pre-split
31
+ // document keeps its current mobile look until a mobile user first moves the
32
+ // slider. The ONE read path for these fields — renderers, hit-testing, and
33
+ // the slider UIs must all resolve through here so they agree.
34
+ export const resolveTileScaleFactor = (canvas, platform) => (platform === 'mobile'
35
+ ? (canvas.tileScaleFactorMobile ?? canvas.tileScaleFactor)
36
+ : canvas.tileScaleFactor) ?? DEFAULT_TILE_SCALE;
25
37
  // Clamp a tile-scale-factor candidate to the supported range. The single guard
26
38
  // for the value before it lands in the document (slider input, restored docs).
27
39
  export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
@@ -54,8 +66,8 @@ export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath
54
66
  // value. The ONE source of truth for tile footprint — render overlay,
55
67
  // hit-test, and slide-grab classification all call this so the drawn tile and
56
68
  // its touch box always agree. `tileScaleFactor` is the document-wide knob
57
- // (default 1, from `AnnotationCanvasState.tileScaleFactor`, driven by the
58
- // "Tile size" slider). `viewportScale` is retained for call compatibility and
59
- // is always 1 — the old per-canvas multiplier was retired (see the note
60
- // above). Independent of zoom.
69
+ // (default 1), driven by the "Tile size" slider and already resolved for the
70
+ // caller's platform via resolveTileScaleFactor above. `viewportScale` is
71
+ // retained for call compatibility and is always 1 — the old per-canvas
72
+ // multiplier was retired (see the note above). Independent of zoom.
61
73
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => STAMP_TILE_SIZE * (m.scale ?? 1) * tileScaleFactor * viewportScale;
@@ -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, ctx.document.tileScaleFactor, ctx.tileViewportScale)) {
54
+ if (hitPlacedMeasurement(m, event.world, zoom, ctx.tileScaleFactor, ctx.tileViewportScale)) {
55
55
  ctx.setSelection({ ids: [m.id] });
56
56
  return;
57
57
  }
@@ -56,11 +56,14 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
56
56
  const dy = py - cy;
57
57
  return dx * dx + dy * dy;
58
58
  };
59
- const findHit = (doc, world, zoom, viewportTileScale = 1) => {
59
+ const findHit = (doc, world, zoom, viewportTileScale = 1,
60
+ // Platform-resolved tile-scale knob (ctx.tileScaleFactor). Defaults to the
61
+ // doc's web/legacy field so un-threaded callers keep the pre-split behavior.
62
+ tileScaleFactor = doc.tileScaleFactor) => {
60
63
  // Hit-test in z-order (top first): measurements > shapes > strokes.
61
64
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
62
65
  const m = doc.placedMeasurements[i];
63
- if (hitPlacedMeasurement(m, world, zoom, doc.tileScaleFactor, viewportTileScale)) {
66
+ if (hitPlacedMeasurement(m, world, zoom, tileScaleFactor, viewportTileScale)) {
64
67
  return { id: m.id, kind: 'measurement' };
65
68
  }
66
69
  }
@@ -150,12 +153,11 @@ const translatePatch = (elementKind, id, doc, delta) => {
150
153
  // is selected; without this, a handle sitting under the tile (a rectangle
151
154
  // tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
152
155
  // and the tile becomes unmovable until deselected.
153
- const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
156
+ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
154
157
  const m = doc.placedMeasurements.find((x) => x.id === id);
155
158
  if (!m)
156
159
  return false;
157
- const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
158
- HIT_PADDING) /
160
+ const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
159
161
  zoom;
160
162
  return (Math.abs(world.x - m.anchor.x) <= half &&
161
163
  Math.abs(world.y - m.anchor.y) <= half);
@@ -164,15 +166,14 @@ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
164
166
  // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
165
167
  // Grabbing the tile of a line annotation slides it along the line; everything
166
168
  // else (bare stamps, grabs on the line body) is a group move.
167
- const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1) => {
169
+ const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
168
170
  const m = doc.placedMeasurements.find((x) => x.id === id);
169
171
  if (!m)
170
172
  return 'move';
171
173
  // Same footprint the tile is drawn at (smaller for unassociated inputs, #6;
172
174
  // folds in the document-wide tile scale + viewport scale so slide-grab
173
175
  // matches the draw).
174
- const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
175
- HIT_PADDING) /
176
+ const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
176
177
  zoom;
177
178
  const onTile = Math.abs(world.x - m.anchor.x) <= half &&
178
179
  Math.abs(world.y - m.anchor.y) <= half;
@@ -480,7 +481,7 @@ export const createSelectTool = (options = {}) => ({
480
481
  // reusing the same hit-test and translate logic the pointer handlers below
481
482
  // use for web — one source of truth.
482
483
  dragSelection: {
483
- hitTest: (doc, world, zoom, viewportTileScale) => findHit(doc, world, zoom, viewportTileScale),
484
+ hitTest: (doc, world, zoom, viewportTileScale, tileScaleFactor) => findHit(doc, world, zoom, viewportTileScale, tileScaleFactor),
484
485
  buildTranslatePatch: (doc, id, kind, delta) => {
485
486
  const op = translatePatch(kind, id, doc, delta);
486
487
  return op ? { ops: [op] } : null;
@@ -515,7 +516,7 @@ export const createSelectTool = (options = {}) => ({
515
516
  // tile stays draggable (otherwise it can only be moved after deselecting).
516
517
  const selId = ctx.selection?.ids[0];
517
518
  if (selId &&
518
- !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
519
+ !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor)) {
519
520
  const handle = findHandleHit(ctx.document, selId, world, zoom);
520
521
  if (handle) {
521
522
  ctx.setSelection({ ids: [selId] });
@@ -579,7 +580,7 @@ export const createSelectTool = (options = {}) => ({
579
580
  };
580
581
  }
581
582
  }
582
- const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale);
583
+ const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor);
583
584
  if (!hit) {
584
585
  ctx.setSelection(null);
585
586
  // Nothing under the pointer: with panOnEmptyDrag the gesture pans the
@@ -600,8 +601,7 @@ export const createSelectTool = (options = {}) => ({
600
601
  : null;
601
602
  ctx.setSelection({ ids: [hit.id] });
602
603
  const mode = hit.kind === 'measurement' &&
603
- classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
604
- 'slide'
604
+ classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor) === 'slide'
605
605
  ? 'slide'
606
606
  : 'move';
607
607
  return {
@@ -1,5 +1,5 @@
1
1
  import type { Measurement } from '../../types/firestore.js';
2
- import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type Vec2 } from '../../types/annotation.js';
2
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type TileScalePlatform, type Vec2 } from '../../types/annotation.js';
3
3
  import type { MeasurementRef } from './measurementPicker.js';
4
4
  import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
5
5
  import { type ViewportState } from './viewport.js';
@@ -15,6 +15,8 @@ export interface AnnotationCanvasHandle {
15
15
  defaultLengthDoc?: number;
16
16
  }): void;
17
17
  setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
18
+ centerOnLine(id: AnnotationElementId): void;
19
+ removeMeasurementValue(id: AnnotationElementId): void;
18
20
  associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
19
21
  bindColumn(id: AnnotationElementId, binding: {
20
22
  groupId: string;
@@ -39,6 +41,7 @@ export interface UseAnnotationCanvasStateProps {
39
41
  width: number;
40
42
  height: number;
41
43
  initialViewport?: ViewportState;
44
+ tileScalePlatform?: TileScalePlatform;
42
45
  imperativeRef?: {
43
46
  current: AnnotationCanvasHandle | null;
44
47
  };
@@ -58,6 +61,7 @@ export interface AnnotationCanvasStateApi {
58
61
  toolState: ToolState;
59
62
  ctx: ToolContext;
60
63
  tileViewportScale: number;
64
+ tileScaleFactor: number;
61
65
  penDrawingStroke: AnnotationStroke | null;
62
66
  customPreviewState: ToolState;
63
67
  dispatchPointerDown(event: CanvasPointerEvent): void;
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
+ import { resolveTileScaleFactor } from './stampLayout.js';
3
4
  import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
- import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
+ import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
6
  // The viewport that frames the document's content on screen. When a background
6
7
  // image is present we fit its rendered rect (so the whole image shows, filling
7
8
  // the viewport regardless of its pixel resolution — never the native-resolution
@@ -22,7 +23,7 @@ const computeContentFit = (canvas, width, height) => {
22
23
  // inners share this hook; each wraps it with platform-specific event
23
24
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
24
25
  export const useAnnotationCanvasState = (props) => {
25
- const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, imperativeRef, } = props;
26
+ const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, imperativeRef, } = props;
26
27
  const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
27
28
  const [toolState, setToolState] = useState(undefined);
28
29
  const [previewPatch, setPreviewPatch] = useState(null);
@@ -63,11 +64,17 @@ export const useAnnotationCanvasState = (props) => {
63
64
  // (hit box) thread it; keeping them in lockstep at 1 means both still agree.
64
65
  // (See stampLayout.ts for why the old per-canvas multiplier was retired.)
65
66
  const tileViewportScale = 1;
67
+ // The platform's tile-scale knob, resolved once from the effective canvas so
68
+ // tools (hit boxes) and overlays (drawn size) share one value. Uses the
69
+ // effective (preview-applied) canvas for parity with what is on screen; the
70
+ // slider only ever commits, so this matches ctx.document in practice.
71
+ const tileScaleFactor = resolveTileScaleFactor(effectiveCanvas, tileScalePlatform ?? 'web');
66
72
  const ctx = useMemo(() => ({
67
73
  document: canvas,
68
74
  selection,
69
75
  viewport: viewportApi,
70
76
  tileViewportScale,
77
+ tileScaleFactor,
71
78
  preview(patch) {
72
79
  setPreviewPatch(patch);
73
80
  },
@@ -100,6 +107,7 @@ export const useAnnotationCanvasState = (props) => {
100
107
  selection,
101
108
  viewportApi,
102
109
  tileViewportScale,
110
+ tileScaleFactor,
103
111
  onCommit,
104
112
  onSelectionChange,
105
113
  pickMeasurement,
@@ -331,6 +339,46 @@ export const useAnnotationCanvasState = (props) => {
331
339
  ops: [{ op: 'updateMeasurement', id, patch: { placement: 'none' } }],
332
340
  });
333
341
  },
342
+ centerOnLine(id) {
343
+ const c = ctxRef.current;
344
+ const m = c.document.placedMeasurements.find((x) => x.id === id);
345
+ if (!m)
346
+ return;
347
+ // Same default-line synthesis as setAnnotationType('line') so centering
348
+ // a floating tile also gives it a line to sit on.
349
+ let line = m.line;
350
+ if (!line) {
351
+ const len = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
352
+ line = {
353
+ a: { x: m.anchor.x - len / 2, y: m.anchor.y },
354
+ b: { x: m.anchor.x + len / 2, y: m.anchor.y },
355
+ };
356
+ }
357
+ c.commit({
358
+ ops: [
359
+ {
360
+ op: 'updateMeasurement',
361
+ id,
362
+ patch: {
363
+ placement: 'line',
364
+ line,
365
+ linePos: 0.5,
366
+ anchor: recomputeAnchor(line, 'line', 0.5, m.anchor),
367
+ },
368
+ },
369
+ ],
370
+ });
371
+ },
372
+ removeMeasurementValue(id) {
373
+ const c = ctxRef.current;
374
+ const m = c.document.placedMeasurements.find((x) => x.id === id);
375
+ if (!m)
376
+ return;
377
+ const { ops, keepSelection } = buildRemoveMeasurementOps(m);
378
+ c.commit({ ops });
379
+ if (!keepSelection)
380
+ c.setSelection(null);
381
+ },
334
382
  associateMeasurement(id, ref) {
335
383
  const c = ctxRef.current;
336
384
  c.commit({
@@ -342,6 +390,11 @@ export const useAnnotationCanvasState = (props) => {
342
390
  measurementId: ref.measurementId,
343
391
  measurementPath: ref.measurementPath,
344
392
  groupId: ref.groupId,
393
+ // A direct association replaces any FORM-column binding — the
394
+ // tile now resolves through the measurement, not
395
+ // group.columns[columnId]. Cleared keys serialize away at the
396
+ // persistence boundary; undo restores the prior binding.
397
+ columnId: undefined,
345
398
  labelOverride: ref.label,
346
399
  unitOverride: ref.unit,
347
400
  },
@@ -443,6 +496,7 @@ export const useAnnotationCanvasState = (props) => {
443
496
  toolState,
444
497
  ctx,
445
498
  tileViewportScale,
499
+ tileScaleFactor,
446
500
  penDrawingStroke,
447
501
  customPreviewState: toolState,
448
502
  dispatchPointerDown,
package/dist/exports.d.ts CHANGED
@@ -22,7 +22,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
22
22
  export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
23
23
  export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
24
24
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
25
- export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
25
+ export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
26
26
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
27
27
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
28
28
  export { createSelectTool, type SelectToolOptions, } from './annotation/canvas/tools/selectTool.js';
package/dist/exports.js CHANGED
@@ -22,7 +22,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
22
22
  export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
23
23
  export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
24
24
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
25
- export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
25
+ export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
26
26
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
27
27
  export { createPenTool, } from './annotation/canvas/tools/penTool.js';
28
28
  export { createSelectTool, } from './annotation/canvas/tools/selectTool.js';
@@ -100,6 +100,7 @@ export interface AnnotationViewport {
100
100
  backgroundImage?: AnnotationBackgroundImage;
101
101
  backgroundFit?: BackgroundFit;
102
102
  }
103
+ export type TileScalePlatform = 'web' | 'mobile';
103
104
  export interface AnnotationCanvasState {
104
105
  schemaVersion: 1;
105
106
  layers: AnnotationLayer[];
@@ -108,6 +109,7 @@ export interface AnnotationCanvasState {
108
109
  shapes: AnnotationShape[];
109
110
  placedMeasurements: PlacedMeasurementRef[];
110
111
  tileScaleFactor?: number;
112
+ tileScaleFactorMobile?: number;
111
113
  externalPayloadPath?: string;
112
114
  }
113
115
  export type AnnotationElement = (AnnotationStroke & {
@@ -156,6 +158,7 @@ export type AnnotationPatchOp = {
156
158
  } | {
157
159
  op: 'setTileScaleFactor';
158
160
  value: number;
161
+ platform?: TileScalePlatform;
159
162
  } | {
160
163
  op: 'setLayers';
161
164
  layers: AnnotationLayer[];
@@ -74,7 +74,9 @@ const applyOp = (state, op) => {
74
74
  case 'setViewport':
75
75
  return { ...state, viewport: { ...state.viewport, ...op.patch } };
76
76
  case 'setTileScaleFactor':
77
- return { ...state, tileScaleFactor: op.value };
77
+ return op.platform === 'mobile'
78
+ ? { ...state, tileScaleFactorMobile: op.value }
79
+ : { ...state, tileScaleFactor: op.value };
78
80
  case 'setLayers':
79
81
  return { ...state, layers: op.layers };
80
82
  }
@@ -150,9 +152,17 @@ const invertOp = (before, op) => {
150
152
  return { op: 'setViewport', patch: inversePatch };
151
153
  }
152
154
  case 'setTileScaleFactor':
153
- // Restore the prior factor; absent an explicit 1 (visually identical to
154
- // absent), keeping the op's `value: number` contract.
155
- return { op: 'setTileScaleFactor', value: before.tileScaleFactor ?? 1 };
155
+ // Restore the prior EFFECTIVE factor for the same platform knob. Mobile
156
+ // falls back to the shared/web value when it has no own value yet (the
157
+ // pre-split doc case mirrors resolveTileScaleFactor); absent → an
158
+ // explicit 1 (visually identical), keeping the `value: number` contract.
159
+ return {
160
+ op: 'setTileScaleFactor',
161
+ value: (op.platform === 'mobile'
162
+ ? (before.tileScaleFactorMobile ?? before.tileScaleFactor)
163
+ : before.tileScaleFactor) ?? 1,
164
+ ...(op.platform && { platform: op.platform }),
165
+ };
156
166
  case 'setLayers':
157
167
  return { op: 'setLayers', layers: before.layers };
158
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.29",
3
+ "version": "1.6.30",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",