@reekon-tools/boldr-utils 1.6.28 → 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.
Files changed (79) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.js +8 -3
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +21 -17
  3. package/dist/annotation/canvas/Tool.d.ts +4 -3
  4. package/dist/annotation/canvas/stampLayout.d.ts +2 -1
  5. package/dist/annotation/canvas/stampLayout.js +23 -11
  6. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  7. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  8. package/dist/annotation/canvas/tools/panTool.js +1 -1
  9. package/dist/annotation/canvas/tools/selectTool.d.ts +4 -1
  10. package/dist/annotation/canvas/tools/selectTool.js +26 -14
  11. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +5 -1
  12. package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -2
  13. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.d.ts +1 -0
  14. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +12 -4
  15. package/dist/exports.d.ts +2 -2
  16. package/dist/exports.js +2 -2
  17. package/dist/types/annotation.d.ts +3 -0
  18. package/dist/types/annotation.js +14 -4
  19. package/package.json +1 -1
  20. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  21. package/dist/canvas/AnnotationCanvas.js +0 -10
  22. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  23. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  24. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  25. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  26. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  27. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  28. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  29. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  30. package/dist/canvas/Tool.d.ts +0 -38
  31. package/dist/canvas/Tool.js +0 -1
  32. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  33. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  34. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  35. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  36. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  37. package/dist/canvas/elements/ShapeElement.js +0 -62
  38. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  39. package/dist/canvas/elements/StrokeElement.js +0 -18
  40. package/dist/canvas/measurementPicker.d.ts +0 -10
  41. package/dist/canvas/measurementPicker.js +0 -1
  42. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  43. package/dist/canvas/measurementStampOverlay.js +0 -1
  44. package/dist/canvas/pointerAdapter.d.ts +0 -3
  45. package/dist/canvas/pointerAdapter.js +0 -19
  46. package/dist/canvas/stampLayout.d.ts +0 -5
  47. package/dist/canvas/stampLayout.js +0 -14
  48. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  49. package/dist/canvas/tools/measurementStampTool.js +0 -37
  50. package/dist/canvas/tools/panTool.d.ts +0 -5
  51. package/dist/canvas/tools/panTool.js +0 -25
  52. package/dist/canvas/tools/penTool.d.ts +0 -13
  53. package/dist/canvas/tools/penTool.js +0 -68
  54. package/dist/canvas/tools/selectTool.d.ts +0 -2
  55. package/dist/canvas/tools/selectTool.js +0 -182
  56. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  57. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  58. package/dist/canvas/viewport.d.ts +0 -16
  59. package/dist/canvas/viewport.js +0 -54
  60. package/dist/data/AnnotationDataContext.d.ts +0 -8
  61. package/dist/data/AnnotationDataContext.js +0 -11
  62. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  63. package/dist/data/AnnotationDataProvider.js +0 -4
  64. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  65. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  66. package/dist/data/canvasPersistence.d.ts +0 -3
  67. package/dist/data/canvasPersistence.js +0 -26
  68. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  69. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  70. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  71. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  72. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  73. package/dist/data/hooks/useAnnotationList.js +0 -26
  74. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  75. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  76. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  77. package/dist/hooks/useParseMeasurement.js +0 -14
  78. package/dist/utils/evaluateFormula.d.ts +0 -20
  79. package/dist/utils/evaluateFormula.js +0 -31
@@ -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;
@@ -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
+ };
@@ -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
  }
@@ -1,2 +1,5 @@
1
1
  import type { Tool } from '../Tool.js';
2
- export declare const createSelectTool: () => Tool;
2
+ export interface SelectToolOptions {
3
+ panOnEmptyDrag?: boolean;
4
+ }
5
+ export declare const createSelectTool: (options?: SelectToolOptions) => Tool;
@@ -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;
@@ -472,7 +473,7 @@ const dragPatch = (s, doc, delta, zoom) => {
472
473
  const op = translatePatch(s.elementKind, s.id, doc, delta);
473
474
  return op ? { ops: [op] } : null;
474
475
  };
475
- export const createSelectTool = () => ({
476
+ export const createSelectTool = (options = {}) => ({
476
477
  id: 'select',
477
478
  label: 'Select',
478
479
  cursor: 'default',
@@ -480,7 +481,7 @@ export const createSelectTool = () => ({
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 = () => ({
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,9 +580,14 @@ export const createSelectTool = () => ({
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);
586
+ // Nothing under the pointer: with panOnEmptyDrag the gesture pans the
587
+ // viewport instead of dead-ending, so bare drags navigate the canvas.
588
+ if (options.panOnEmptyDrag) {
589
+ return { kind: 'panning', lastScreen: event.screen };
590
+ }
585
591
  return { kind: 'idle' };
586
592
  }
587
593
  // Re-tapping an already-selected text shape (without dragging) opens its
@@ -595,8 +601,7 @@ export const createSelectTool = () => ({
595
601
  : null;
596
602
  ctx.setSelection({ ids: [hit.id] });
597
603
  const mode = hit.kind === 'measurement' &&
598
- classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
599
- 'slide'
604
+ classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor) === 'slide'
600
605
  ? 'slide'
601
606
  : 'move';
602
607
  return {
@@ -610,6 +615,13 @@ export const createSelectTool = () => ({
610
615
  },
611
616
  onPointerMove(event, ctx, state) {
612
617
  const s = state;
618
+ if (s?.kind === 'panning') {
619
+ ctx.applyPan({
620
+ x: event.screen.x - s.lastScreen.x,
621
+ y: event.screen.y - s.lastScreen.y,
622
+ });
623
+ return { ...s, lastScreen: event.screen };
624
+ }
613
625
  if (s?.kind !== 'dragging')
614
626
  return s;
615
627
  const delta = {
@@ -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,
@@ -6,6 +6,7 @@ export interface UseAnnotationCanvasDocOptions {
6
6
  fileId: string | null;
7
7
  fallbackViewport?: Partial<AnnotationViewport>;
8
8
  debounceMs?: number;
9
+ autoSave?: boolean;
9
10
  createSeed?: {
10
11
  name?: string;
11
12
  fileType?: 'sketch' | 'document';