@reekon-tools/boldr-utils 1.8.4 → 1.9.0

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 (81) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +41 -10
  2. package/dist/annotation/canvas/measurementGeometry.js +8 -8
  3. package/dist/annotation/canvas/stampLayout.js +59 -54
  4. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +4 -1
  5. package/dist/annotation/canvas/useAnnotationCanvasState.js +88 -38
  6. package/dist/calculator/evaluate.js +38 -4
  7. package/dist/calculator/expressionUnits.d.ts +84 -0
  8. package/dist/calculator/expressionUnits.js +439 -0
  9. package/dist/calculator/index.d.ts +1 -0
  10. package/dist/calculator/index.js +1 -0
  11. package/dist/calculator/schema.d.ts +18 -0
  12. package/dist/calculator/schema.js +26 -5
  13. package/dist/calculator/validate.d.ts +12 -2
  14. package/dist/calculator/validate.js +116 -43
  15. package/dist/theme/index.d.ts +3 -0
  16. package/dist/theme/index.js +3 -0
  17. package/dist/theme/preset.d.ts +120 -0
  18. package/dist/theme/preset.js +25 -0
  19. package/dist/theme/semantic.d.ts +136 -0
  20. package/dist/theme/semantic.js +116 -0
  21. package/package.json +1 -1
  22. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  23. package/dist/canvas/AnnotationCanvas.js +0 -10
  24. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  25. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  26. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  27. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  28. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  29. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  30. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  31. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  32. package/dist/canvas/Tool.d.ts +0 -38
  33. package/dist/canvas/Tool.js +0 -1
  34. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  35. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  36. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  37. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  38. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  39. package/dist/canvas/elements/ShapeElement.js +0 -62
  40. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  41. package/dist/canvas/elements/StrokeElement.js +0 -18
  42. package/dist/canvas/measurementPicker.d.ts +0 -10
  43. package/dist/canvas/measurementPicker.js +0 -1
  44. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  45. package/dist/canvas/measurementStampOverlay.js +0 -1
  46. package/dist/canvas/pointerAdapter.d.ts +0 -3
  47. package/dist/canvas/pointerAdapter.js +0 -19
  48. package/dist/canvas/stampLayout.d.ts +0 -5
  49. package/dist/canvas/stampLayout.js +0 -14
  50. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  51. package/dist/canvas/tools/measurementStampTool.js +0 -37
  52. package/dist/canvas/tools/panTool.d.ts +0 -5
  53. package/dist/canvas/tools/panTool.js +0 -25
  54. package/dist/canvas/tools/penTool.d.ts +0 -13
  55. package/dist/canvas/tools/penTool.js +0 -68
  56. package/dist/canvas/tools/selectTool.d.ts +0 -2
  57. package/dist/canvas/tools/selectTool.js +0 -182
  58. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  59. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  60. package/dist/canvas/viewport.d.ts +0 -16
  61. package/dist/canvas/viewport.js +0 -54
  62. package/dist/data/AnnotationDataContext.d.ts +0 -8
  63. package/dist/data/AnnotationDataContext.js +0 -11
  64. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  65. package/dist/data/AnnotationDataProvider.js +0 -4
  66. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  67. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  68. package/dist/data/canvasPersistence.d.ts +0 -3
  69. package/dist/data/canvasPersistence.js +0 -26
  70. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  71. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  72. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  73. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  74. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  75. package/dist/data/hooks/useAnnotationList.js +0 -26
  76. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  77. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  78. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  79. package/dist/hooks/useParseMeasurement.js +0 -14
  80. package/dist/utils/evaluateFormula.d.ts +0 -20
  81. package/dist/utils/evaluateFormula.js +0 -31
@@ -628,8 +628,9 @@ export const AnnotationCanvasInner = (props) => {
628
628
  // effect so an in-flight gesture's shared values are never clobbered by a
629
629
  // late-flushing setViewport from a previous gesture.
630
630
  const activeViewportGestures = useRef(0);
631
- // >0 while an animated viewport transition (fitContentToRect) is in
632
- // flight. Guards the JS→UI sync effect the same way the gesture counter
631
+ // >0 while an animated viewport transition (a gliding handle move —
632
+ // zoomToFit, fitContentToRect, ensureMeasurementVisible) is in flight.
633
+ // Guards the JS→UI sync effect the same way the gesture counter
633
634
  // does: mid-glide re-renders must not snap the shared values back to the
634
635
  // (still-old) JS snapshot.
635
636
  const viewportAnimations = useRef(0);
@@ -646,7 +647,7 @@ export const AnnotationCanvasInner = (props) => {
646
647
  panX.value = state.viewport.pan.x;
647
648
  panY.value = state.viewport.pan.y;
648
649
  }, [state.viewport, zoom, panX, panY]);
649
- // Animated viewport transitions (fitContentToRect with `animated`): a JS
650
+ // Animated viewport transitions (the handle's gliding moves): a JS
650
651
  // rAF driver eases the same shared values gestures write — DIRECT writes,
651
652
  // three per frame, no React involvement until the single end-of-motion
652
653
  // commit. Direct `.value =` writes are the proven repaint channel for the
@@ -670,8 +671,22 @@ export const AnnotationCanvasInner = (props) => {
670
671
  animateViewportImplRef.current = (target, durationMs) => {
671
672
  cancelViewportDrive();
672
673
  const from = { zoom: zoom.value, panX: panX.value, panY: panY.value };
674
+ // Interpolate the world-space view CENTER plus geometric zoom, deriving
675
+ // pan per frame. Pan is the world point at the screen origin (screen =
676
+ // (world − pan)·zoom), so lerping it while zoom sweeps makes the content
677
+ // veer off-line mid-glide; a straight center path with geometric zoom is
678
+ // the standard camera move. Pan-only glides (zoomRatio 1) reduce to a
679
+ // plain world-space lerp.
680
+ const fromCx = from.panX + width / (2 * from.zoom);
681
+ const fromCy = from.panY + height / (2 * from.zoom);
682
+ const toCx = target.pan.x + width / (2 * target.zoom);
683
+ const toCy = target.pan.y + height / (2 * target.zoom);
684
+ const zoomRatio = target.zoom / from.zoom;
673
685
  const start = Date.now();
674
- const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - (2 - 2 * t) ** 3 / 2;
686
+ // Smootherstep: velocity AND acceleration are zero at both ends, so the
687
+ // glide has no kick at launch and no jolt on landing — easeInOutCubic's
688
+ // abrupt acceleration endpoints read as rigid/mechanical.
689
+ const ease = (t) => t * t * t * (t * (t * 6 - 15) + 10);
675
690
  viewportAnimations.current = 1;
676
691
  const step = () => {
677
692
  if (activeViewportGestures.current > 0) {
@@ -680,10 +695,11 @@ export const AnnotationCanvasInner = (props) => {
680
695
  return;
681
696
  }
682
697
  const t = Math.min(1, (Date.now() - start) / durationMs);
683
- const eased = easeInOutCubic(t);
684
- zoom.value = from.zoom + (target.zoom - from.zoom) * eased;
685
- panX.value = from.panX + (target.pan.x - from.panX) * eased;
686
- panY.value = from.panY + (target.pan.y - from.panY) * eased;
698
+ const eased = ease(t);
699
+ const z = from.zoom * zoomRatio ** eased;
700
+ zoom.value = z;
701
+ panX.value = fromCx + (toCx - fromCx) * eased - width / (2 * z);
702
+ panY.value = fromCy + (toCy - fromCy) * eased - height / (2 * z);
687
703
  if (t < 1) {
688
704
  viewportDriveRef.current = requestAnimationFrame(step);
689
705
  }
@@ -1529,11 +1545,20 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1529
1545
  const anchorX = placed.anchor.x;
1530
1546
  const anchorY = placed.anchor.y;
1531
1547
  // doc → screen each frame, on the UI thread. Position is a translate
1532
- // transform (cheap, no layout) so the pin stays glued to its anchor. While
1548
+ // transform (cheap, no layout) so the tile stays glued to its anchor. While
1533
1549
  // dragging, the world-space drag offset is folded in; while sliding, the
1534
1550
  // position is mapped onto the line. `dragging`/`sliding` are captured props,
1535
1551
  // so the moment they flip false (on commit) the worklet drops the live offset
1536
1552
  // in the same render the new anchor arrives — no flicker.
1553
+ //
1554
+ // Size: the LAID-OUT box is the tile's screen size at `zoomSnapshot` (the
1555
+ // JS viewport this render was built from). Tiles scale with zoom, so while
1556
+ // a pinch/glide has the live zoom ahead of that snapshot the worklet
1557
+ // bridges the gap with a scale transform (about the centered anchor);
1558
+ // when the gesture ends and the JS snapshot syncs, the item re-renders at
1559
+ // the final size and the scale settles back to exactly 1 — the same
1560
+ // captured-prop handoff the drag offsets use, so there is no jump.
1561
+ const zoomBase = zoomSnapshot > 0 ? zoomSnapshot : 1;
1537
1562
  const animatedStyle = useAnimatedStyle(() => {
1538
1563
  'worklet';
1539
1564
  let worldX = anchorX;
@@ -1581,7 +1606,13 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1581
1606
  const cx = (worldX - panX.value) * zoom.value;
1582
1607
  const cy = (worldY - panY.value) * zoom.value;
1583
1608
  return {
1584
- transform: [{ translateX: cx - halfW }, { translateY: cy - halfH }],
1609
+ transform: [
1610
+ { translateX: cx - halfW },
1611
+ { translateY: cy - halfH },
1612
+ // Scales about the box center, which the translate just pinned to
1613
+ // the anchor — so the tile grows/shrinks in place.
1614
+ { scale: zoom.value / zoomBase },
1615
+ ],
1585
1616
  };
1586
1617
  });
1587
1618
  return (_jsxs(Animated.View, { pointerEvents: "box-none", style: [
@@ -106,17 +106,17 @@ const segmentDistSq = (p, a, b) => {
106
106
  const dy = p.y - (a.y + t * aby);
107
107
  return dx * dx + dy * dy;
108
108
  };
109
- // Whether a world-space point hits a placed measurement: the (screen-constant)
110
- // stamp tile around the anchor, the line body of a line annotation, or the
109
+ // Whether a world-space point hits a placed measurement: the stamp tile
110
+ // around the anchor, the line body of a line annotation, or the
111
111
  // border ring of a rectangle annotation (interiors stay transparent to hits so
112
112
  // elements inside remain reachable).
113
113
  export const hitPlacedMeasurement = (m, p, zoom = 1, tileScaleFactor = 1, viewportScale = 1) => {
114
- // The stamp renders as a constant *screen*-size box centered on the
115
- // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
116
- // screen-space half-extents (+ padding) back to doc space via the zoom so
117
- // the hit box always matches what's drawn (blank and populated stamps share
118
- // the square footprint; group-header chips are a wide pill — see
119
- // stampTileDims in stampLayout).
114
+ // stampTileDims yields the tile's CURRENT screen-space box (the
115
+ // zoom-relative multiplier arrives via `viewportScale`); converting the
116
+ // half-extents (+ padding) back to doc space via the zoom keeps the hit box
117
+ // matching what's drawn — a doc-constant footprint (blank and populated
118
+ // stamps share the square footprint; group-header chips are a wide pill —
119
+ // see stampTileDims in stampLayout).
120
120
  const dims = stampTileDims(m, tileScaleFactor, viewportScale);
121
121
  const halfW = (dims.width / 2 + STAMP_HIT_PADDING) / zoom;
122
122
  const halfH = (dims.height / 2 + STAMP_HIT_PADDING) / zoom;
@@ -3,26 +3,31 @@
3
3
  // overlay — see measurementStampOverlay.ts) and the hit-test (selectTool,
4
4
  // measurementGeometry) can import it without dragging
5
5
  // @shopify/react-native-skia into the consumer's static import graph.
6
- // Constant SCREEN-space edge length of a placed measurement, rendered as a
7
- // square tile. The tile is a fixed-size pin: its on-screen size is this times
8
- // `placed.scale` and does NOT change with zoom (only its position tracks the
9
- // canvas). Also drives the select-tool hit box, which converts it back to doc
10
- // space via the zoom. Blank "+" input placeholders take the SAME footprint as
11
- // populated tiles: earlier builds gave them a compact 44px size, but then a
12
- // user who set the tile-scale slider against blank tiles saw their tiles
13
- // balloon once populated (Asana 1216233394800417).
6
+ // Base edge length of a placed measurement, rendered as a square tile: the
7
+ // tile's on-screen size at the document's content-fit zoom, times
8
+ // `placed.scale`. Tiles scale WITH the zoom from there (the zoom-relative
9
+ // `viewportScale` multiplier below Asana 1216878199784924, matching the
10
+ // Nutrient viewer), so a tile's DOC-space footprint is constant like any
11
+ // other element on the drawing. Also drives the select-tool hit box, which
12
+ // converts the same footprint back to doc space via the zoom. Blank "+"
13
+ // input placeholders take the SAME footprint as populated tiles: earlier
14
+ // builds gave them a compact 44px size, but then a user who set the
15
+ // tile-scale slider against blank tiles saw their tiles balloon once
16
+ // populated (Asana 1216233394800417).
14
17
  export const STAMP_TILE_SIZE = 96;
15
- // Screen-space footprint of a group-header chip (`isGroupHeader`) — a wide
16
- // pill rather than the square tile, same 3.75:1 ratio as the legacy Nutrient
17
- // header stamp (150×40). Like STAMP_TILE_SIZE it is a fixed-size pin scaled by
18
- // `placed.scale` × the document-wide tile-scale factor, never by zoom.
18
+ // Base footprint of a group-header chip (`isGroupHeader`) — a wide pill
19
+ // rather than the square tile, same 3.75:1 ratio as the legacy Nutrient
20
+ // header stamp (150×40). Sized exactly like STAMP_TILE_SIZE: `placed.scale`
21
+ // × the document-wide tile-scale factor, scaling with zoom from the
22
+ // content-fit reference.
19
23
  export const GROUP_HEADER_TILE_WIDTH = 240;
20
24
  export const GROUP_HEADER_TILE_HEIGHT = 64;
21
25
  // Document-wide tile scale factor: one knob that shrinks/grows EVERY
22
26
  // measurement tile on the canvas at once, on top of each tile's own `scale`.
23
27
  // Lets a user pull tiles down on a dense drawing where lines crowd together,
24
- // or bump them up on a sparse one. Like `scale` it is purely a screen-space
25
- // multiplier it does NOT change with zoom. Absent === DEFAULT_TILE_SCALE
28
+ // or bump them up on a sparse one. Like `scale` it is a plain multiplier on
29
+ // the tile footprint, orthogonal to the zoom-relative `viewportScale`.
30
+ // Absent === DEFAULT_TILE_SCALE
26
31
  // (visually identical to documents written before the knob existed).
27
32
  // Web and mobile persist separate knobs (AnnotationCanvasState.tileScaleFactor
28
33
  // vs .tileScaleFactorMobile) — read them through resolveTileScaleFactor below.
@@ -44,41 +49,40 @@ export const resolveTileScaleFactor = (canvas, platform) => (platform === 'mobil
44
49
  // for the value before it lands in the document (slider input, restored docs).
45
50
  export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
46
51
  // --- Cross-platform initial-size calibration ---------------------------------
47
- // The platform knobs are screen-space multipliers, so the same value covers a
48
- // very different share of the DRAWING on a phone than on a desktop pane. To
52
+ // The platform knobs are fit-referenced multipliers, so the same value covers
53
+ // a very different share of the DRAWING on a phone than on a desktop pane. To
49
54
  // hand mobile a sensible starting size, web persists the doc-space edge length
50
- // a scale-1 tile covered as the user saw it (`tileDocSize`, stamped on tile
51
- // placement and web slider commits); a mobile canvas with no own knob yet
52
- // inverts that at its content-fit zoom so its tiles cover the SAME area of the
53
- // image. Two inverses of one identity: screenSize = docSize × zoom.
54
- // Doc-space edge length a scale-1 tile covers at `zoom` under `factor` — the
55
- // web write side of the calibration. Non-positive zoom yields 0, which the
56
- // setTileDocSize op ignores.
55
+ // a scale-1 tile covers (`tileDocSize`, stamped on tile placement and web
56
+ // slider commits); a mobile canvas with no own knob yet inverts that at its
57
+ // own content-fit zoom so its tiles cover the SAME area of the image. Two
58
+ // inverses of one identity: fitScreenSize = docSize × fitZoom. Both sides pass
59
+ // the content-fit reference zoom (tiles are doc-constant, so the CURRENT zoom
60
+ // has no bearing on how much of the drawing a tile covers).
61
+ // Doc-space edge length a scale-1 tile covers under `factor`, given the
62
+ // canvas's content-fit reference `zoom` — the web write side of the
63
+ // calibration. Non-positive zoom yields 0, which the setTileDocSize op
64
+ // ignores.
57
65
  export const tileDocSizeForFactor = (factor, zoom) => zoom > 0 ? (STAMP_TILE_SIZE * factor) / zoom : 0;
58
- // Tile-scale factor that makes a scale-1 tile cover `docSize` doc units at
59
- // `zoom` — the mobile read side of the calibration. Deliberately NOT clamped
60
- // to the slider range: faithfully matching the web look can land below
61
- // TILE_SCALE_MIN on a phone, and rendering/hit-testing handle any positive
62
- // value. The clamp applies once the user drives the slider themselves.
66
+ // Tile-scale factor that makes a scale-1 tile cover `docSize` doc units,
67
+ // given the canvas's content-fit reference `zoom` — the mobile read side of
68
+ // the calibration. Deliberately NOT clamped to the slider range: faithfully
69
+ // matching the web look can land below TILE_SCALE_MIN on a phone, and
70
+ // rendering/hit-testing handle any positive value. The clamp applies once the
71
+ // user drives the slider themselves.
63
72
  export const tileScaleFromDocSize = (docSize, zoom) => (docSize * zoom) / STAMP_TILE_SIZE;
64
- // --- Tile sizing is independent of canvas size ------------------------------
65
- // A measurement tile's footprint is base × per-tile `scale` × the document-wide
66
- // `tileScaleFactor` (below) — and NOTHING tied to the canvas's pixel size, so
67
- // the same document shows the same tiles at any canvas/window size.
73
+ // --- How the footprint composes ----------------------------------------------
74
+ // A measurement tile's screen footprint is base × per-tile `scale` × the
75
+ // document-wide `tileScaleFactor` × the zoom-relative `viewportScale`.
68
76
  //
69
- // Earlier builds multiplied the footprint by a per-canvas `viewportTileScale`
70
- // derived from how large the document rendered when fit to the canvas, to keep a
71
- // tile a constant fraction of the drawing across devices. But that made a tile's
72
- // size change whenever the canvas was resized (e.g. dragging a desktop pane)
73
- // the same document read with different-sized tiles at different window sizes.
74
- // That auto-multiplier is retired in favor of the user-driven "Tile size"
75
- // slider (the document-wide `tileScaleFactor`), which is explicit, persisted,
76
- // and consistent everywhere. Mobile is unchanged its old factor was already
77
- // pinned at 1, calibrated to a phone's width.
78
- //
79
- // `stampTileSize` keeps its `viewportScale` parameter (default 1) only so the
80
- // render overlay, hit-test, and tools that thread it stay call-compatible; it is
81
- // now always 1.
77
+ // `viewportScale` is the canvas's CURRENT zoom over its content-fit reference
78
+ // zoom (see tileViewportScale in useAnnotationCanvasState): 1 at the fit
79
+ // view, growing/shrinking with pinch/wheel zoom so tiles track the drawing
80
+ // like every other element (Asana 1216878199784924 the Nutrient behavior).
81
+ // Earlier builds pinned tiles to a constant screen size regardless of zoom;
82
+ // users kept pinch-zooming to read a tile and it refused to get bigger.
83
+ // (An even earlier per-canvas auto-multiplier tile size derived from the
84
+ // canvas's pixel size was retired for the user-driven "Tile size" slider,
85
+ // which remains: the slider and the zoom multiplier compose.)
82
86
  // A placed measurement is an unassociated input until a measurement reference
83
87
  // is attached (id or path) OR it is bound to a form column (`columnId`). Such
84
88
  // stamps render as the blank "+" placeholder (same footprint as a populated
@@ -86,15 +90,16 @@ export const tileScaleFromDocSize = (docSize, zoom) => (docSize * zoom) / STAMP_
86
90
  // NOT inputs — never treat them as blank "+" placeholders.
87
91
  export const isUnassociatedStamp = (m) => !m.isGroupHeader && !m.measurementId && !m.measurementPath && !m.columnId;
88
92
  // Screen-space edge length for a placed stamp: the base size scaled by the
89
- // per-stamp `scale` and the document-wide `tileScaleFactor`. Blank and
90
- // populated stamps share the footprint so a tile never resizes when it gains a
91
- // value. The ONE source of truth for tile footprint render overlay,
92
- // hit-test, and slide-grab classification all call this so the drawn tile and
93
- // its touch box always agree. `tileScaleFactor` is the document-wide knob
94
- // (default 1), driven by the "Tile size" slider and already resolved for the
95
- // caller's platform via resolveTileScaleFactor above. `viewportScale` is
96
- // retained for call compatibility and is always 1 the old per-canvas
97
- // multiplier was retired (see the note above). Independent of zoom.
93
+ // per-stamp `scale`, the document-wide `tileScaleFactor`, and the
94
+ // zoom-relative `viewportScale`. Blank and populated stamps share the
95
+ // footprint so a tile never resizes when it gains a value. The ONE source of
96
+ // truth for tile footprint — render overlay, hit-test, and slide-grab
97
+ // classification all call this so the drawn tile and its touch box always
98
+ // agree. `tileScaleFactor` is the document-wide knob (default 1), driven by
99
+ // the "Tile size" slider and already resolved for the caller's platform via
100
+ // resolveTileScaleFactor above. `viewportScale` is the current zoom over the
101
+ // canvas's content-fit reference zoom (see the note above), so tiles scale
102
+ // with the drawing as the user zooms.
98
103
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => STAMP_TILE_SIZE * (m.scale ?? 1) * tileScaleFactor * viewportScale;
99
104
  export const stampTileDims = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => {
100
105
  const k = (m.scale ?? 1) * tileScaleFactor * viewportScale;
@@ -14,7 +14,10 @@ export interface AnnotationCanvasHandle {
14
14
  redo(): void;
15
15
  canUndo(): boolean;
16
16
  canRedo(): boolean;
17
- zoomToFit(): void;
17
+ zoomToFit(opts?: {
18
+ animated?: boolean;
19
+ durationMs?: number;
20
+ }): void;
18
21
  resetView(): void;
19
22
  fitContentToRect(rect: CanvasScreenRect, opts?: {
20
23
  animated?: boolean;
@@ -11,10 +11,11 @@ import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_PO
11
11
  // too: authors routinely drop them AROUND the image (dimension labels sit
12
12
  // outside the object they measure), and a fit framed on the image alone opens
13
13
  // with those tiles off-screen — so the rect is widened to the union of the
14
- // base rect and every placed tile's doc geometry. Tiles are screen-constant
15
- // boxes centered on that geometry, so when the union extends past the base
16
- // rect their half-extent is reserved as screen-space padding; a canvas whose
17
- // tiles all sit inside the image keeps the exact image fit it had before.
14
+ // base rect and every placed tile's doc geometry. Tiles are boxes centered on
15
+ // that geometry (≈ base size on screen at the fit view), so when the union
16
+ // extends past the base rect a half-tile of screen-space padding is reserved;
17
+ // a canvas whose tiles all sit inside the image keeps the exact image fit it
18
+ // had before.
18
19
  // Shared by the load-time auto-fit and the zoomToFit/resetView/
19
20
  // fitContentToRect handle methods so they stay in lockstep.
20
21
  const computeContentFitRect = (canvas, rect) => {
@@ -63,13 +64,30 @@ const computeContentFitRect = (canvas, rect) => {
63
64
  };
64
65
  };
65
66
  const computeContentFit = (canvas, width, height) => computeContentFitRect(canvas, { x: 0, y: 0, width, height });
67
+ // Content-fit zoom of the DOCUMENT alone — the background image's rendered
68
+ // rect when present, the document rect otherwise, deliberately ignoring
69
+ // placed tiles — into the full canvas box. This is the stable reference zoom
70
+ // for tile sizing (see tileViewportScale below): at this zoom a tile renders
71
+ // at exactly its slider size. computeContentFitRect's tile-extended union
72
+ // would make the reference move whenever a tile is dragged outside the image
73
+ // (every tile on the canvas would resize); the document rect can't.
74
+ const computeBaseFitZoom = (canvas, width, height) => {
75
+ if (!(width > 0) || !(height > 0))
76
+ return 1;
77
+ const { viewport } = canvas;
78
+ const bg = viewport.backgroundImage;
79
+ const baseRect = bg
80
+ ? imageDocRect(bg.widthPx, bg.heightPx, viewport.width, viewport.height, viewport.backgroundFit ?? 'contain')
81
+ : { x: 0, y: 0, width: viewport.width, height: viewport.height };
82
+ return fitRectToScreen(baseRect, width, height).zoom;
83
+ };
66
84
  // Default leader length (doc units) for a placed measurement when no explicit
67
85
  // length is given. A generous fraction of the document width, clamped, so the
68
- // leader — and its draggable endcaps — stay clear of the fixed-size value tile
69
- // at typical full-fit zoom (Asana 1216212322376723: leaders were collapsing
70
- // under the tile). The tile is screen-constant while the line is doc-space, so
71
- // this can't guarantee clearance on very large drawings (the clamp caps the
72
- // length) or at extreme zoom-out — zoom in to grab the endcaps there.
86
+ // leader — and its draggable endcaps — stay clear of the value tile at
87
+ // typical full-fit zoom (Asana 1216212322376723: leaders were collapsing
88
+ // under the tile). Tiles are doc-constant boxes like the line itself, so the
89
+ // clearance holds at any zoom, but the clamp can still cap the length short
90
+ // of it on very large drawings.
73
91
  const defaultLeaderLenDoc = (docWidth) => Math.min(800, Math.max(240, docWidth * 0.45));
74
92
  // Platform-agnostic state machine for the annotation canvas. Web and native
75
93
  // inners share this hook; each wraps it with platform-specific event
@@ -109,13 +127,16 @@ export const useAnnotationCanvasState = (props) => {
109
127
  didInitialFitRef.current = true;
110
128
  setViewport(computeContentFit(canvas, width, height));
111
129
  }, [canvas, width, height]);
112
- // Tiles are sized independently of the canvas: their footprint is base ×
113
- // per-tile scale × the document-wide `tileScaleFactor` ("Tile size" slider),
114
- // never the canvas pixel size so resizing the pane no longer rescales tiles.
115
- // Retained as a (constant 1) value because the overlays (drawn size) and tools
116
- // (hit box) thread it; keeping them in lockstep at 1 means both still agree.
117
- // (See stampLayout.ts for why the old per-canvas multiplier was retired.)
118
- const tileViewportScale = 1;
130
+ // Tiles scale WITH the zoom (Asana 1216878199784924 match the Nutrient
131
+ // viewer): a tile renders at base × per-tile scale × `tileScaleFactor` at
132
+ // the document's content-fit zoom, and grows/shrinks proportionally as the
133
+ // user zooms its DOC-space footprint is constant, like any other element
134
+ // on the drawing. `tileViewportScale` carries the zoom-relative multiplier
135
+ // through the existing plumbing: overlays multiply the drawn size by it and
136
+ // hit-tests divide the same footprint by the current zoom, so the drawn
137
+ // tile and its touch box agree by construction.
138
+ const baseFitZoom = computeBaseFitZoom(effectiveCanvas, width, height);
139
+ const tileViewportScale = baseFitZoom > 0 ? viewport.zoom / baseFitZoom : 1;
119
140
  // The platform's tile-scale knob, resolved once from the effective canvas so
120
141
  // tools (hit boxes) and overlays (drawn size) share one value. Uses the
121
142
  // effective (preview-applied) canvas for parity with what is on screen; the
@@ -133,7 +154,7 @@ export const useAnnotationCanvasState = (props) => {
133
154
  effectiveCanvas.tileDocSize > 0 &&
134
155
  width > 0 &&
135
156
  height > 0) {
136
- tileScaleFactor = tileScaleFromDocSize(effectiveCanvas.tileDocSize, computeContentFit(effectiveCanvas, width, height).zoom);
157
+ tileScaleFactor = tileScaleFromDocSize(effectiveCanvas.tileDocSize, baseFitZoom);
137
158
  }
138
159
  const ctx = useMemo(() => ({
139
160
  document: canvas,
@@ -146,15 +167,17 @@ export const useAnnotationCanvasState = (props) => {
146
167
  },
147
168
  commit(patch) {
148
169
  // Placing a tile on WEB stamps the doc-space calibration: the area of
149
- // the drawing a scale-1 tile covers at the zoom the user is looking
150
- // at, right when they judged the size against the image. Mobile
151
- // derives its initial factor from it (see tileScaleFactor above).
152
- // Appended into the same patch so placement + calibration are one
153
- // undo step.
170
+ // the drawing a scale-1 tile covers. Tiles are doc-constant boxes
171
+ // (base size at the content-fit zoom, scaling with zoom from there),
172
+ // so the calibration derives from the fit reference — the current
173
+ // zoom no longer changes how much of the drawing a tile covers.
174
+ // Mobile derives its initial factor from it (see tileScaleFactor
175
+ // above). Appended into the same patch so placement + calibration are
176
+ // one undo step.
154
177
  let effective = patch;
155
178
  if (tileScalePlatformResolved !== 'mobile' &&
156
179
  patch.ops.some((o) => o.op === 'addMeasurement')) {
157
- const docSize = tileDocSizeForFactor(tileScaleFactor, viewportApi.state.zoom);
180
+ const docSize = tileDocSizeForFactor(tileScaleFactor, baseFitZoom);
158
181
  if (docSize > 0) {
159
182
  effective = {
160
183
  ops: [...patch.ops, { op: 'setTileDocSize', value: docSize }],
@@ -191,6 +214,7 @@ export const useAnnotationCanvasState = (props) => {
191
214
  tileViewportScale,
192
215
  tileScaleFactor,
193
216
  tileScalePlatformResolved,
217
+ baseFitZoom,
194
218
  onCommit,
195
219
  onSelectionChange,
196
220
  pickMeasurement,
@@ -200,6 +224,9 @@ export const useAnnotationCanvasState = (props) => {
200
224
  // would otherwise capture a stale ctx/viewport).
201
225
  const ctxRef = useRef(ctx);
202
226
  ctxRef.current = ctx;
227
+ // Ditto for the tile-sizing fit reference (getTileDocSize).
228
+ const baseFitZoomRef = useRef(baseFitZoom);
229
+ baseFitZoomRef.current = baseFitZoom;
203
230
  // Tool hand-over. When the active tool's identity changes — a real tool
204
231
  // switch OR the consumer rebuilding the tools array (e.g. a style change
205
232
  // recreates every factory) — give the outgoing instance a chance to wind
@@ -314,8 +341,8 @@ export const useAnnotationCanvasState = (props) => {
314
341
  // down-right in fixed screen-px steps from center until a slot with no
315
342
  // existing anchor is found. Occupancy is derived from the live placed
316
343
  // measurements (not a drop counter), so moving or deleting a tile frees
317
- // its slot automatically. Screen space is the right frame — tiles are
318
- // screen-constant boxes — and mirrors the Nutrient file-viewer's cascade.
344
+ // its slot automatically. Screen space is the right frame — the drop
345
+ // lands where the user is looking — and mirrors the Nutrient cascade.
319
346
  const cascadeCenterWorld = () => {
320
347
  const c = ctxRef.current;
321
348
  const CASCADE_STEP = 30;
@@ -356,21 +383,31 @@ export const useAnnotationCanvasState = (props) => {
356
383
  canRedo() {
357
384
  return redoStackRef.current.length > 0;
358
385
  },
359
- zoomToFit() {
360
- setViewport(computeContentFit(canvas, width, height));
386
+ zoomToFit(opts) {
387
+ // The document is read through ctxRef, not the effect closure: this
388
+ // effect's deps deliberately exclude `canvas`, so the closed-over
389
+ // binding can predate tiles that landed or moved since — and the fit
390
+ // unions placed-tile geometry.
391
+ const target = computeContentFit(ctxRef.current.document, width, height);
392
+ if (opts?.animated && animateViewport) {
393
+ animateViewport(target, opts.durationMs ?? 650);
394
+ }
395
+ else {
396
+ setViewport(target);
397
+ }
361
398
  },
362
399
  resetView() {
363
400
  // "Reset view" frames the whole document/image again — the same fit the
364
401
  // canvas opens with — rather than snapping to a 1:1 top-left view, which
365
402
  // for a high-res background is the very crop this is meant to escape.
366
- setViewport(computeContentFit(canvas, width, height));
403
+ setViewport(computeContentFit(ctxRef.current.document, width, height));
367
404
  },
368
405
  fitContentToRect(rect, opts) {
369
406
  if (!(rect.width > 0) || !(rect.height > 0))
370
407
  return;
371
- const target = computeContentFitRect(canvas, rect);
408
+ const target = computeContentFitRect(ctxRef.current.document, rect);
372
409
  if (opts?.animated && animateViewport) {
373
- animateViewport(target, opts.durationMs ?? 300);
410
+ animateViewport(target, opts.durationMs ?? 450);
374
411
  }
375
412
  else {
376
413
  setViewport(target);
@@ -380,15 +417,17 @@ export const useAnnotationCanvasState = (props) => {
380
417
  return ctxRef.current.tileScaleFactor;
381
418
  },
382
419
  getTileDocSize(factor) {
383
- return tileDocSizeForFactor(factor, ctxRef.current.viewport.state.zoom);
420
+ // Tiles are doc-constant, so the doc size a factor yields derives
421
+ // from the content-fit reference, not the current zoom.
422
+ return tileDocSizeForFactor(factor, baseFitZoomRef.current);
384
423
  },
385
424
  ensureMeasurementVisible(id) {
386
425
  const c = ctxRef.current;
387
426
  const m = c.document.placedMeasurements.find((x) => x.id === id);
388
427
  if (!m)
389
428
  return;
390
- // The tile is a screen-constant box centered on its anchor, so its
391
- // half-extents are screen px regardless of zoom (stampTileDims).
429
+ // The tile's CURRENT on-screen half-extents: stampTileDims with the
430
+ // zoom-relative tileViewportScale folded in.
392
431
  const dims = stampTileDims(m, c.tileScaleFactor, c.tileViewportScale);
393
432
  const halfW = dims.width / 2;
394
433
  const halfH = dims.height / 2;
@@ -399,13 +438,24 @@ export const useAnnotationCanvasState = (props) => {
399
438
  s.y + halfH <= height) {
400
439
  return;
401
440
  }
402
- setViewport((v) => ({
403
- zoom: v.zoom,
441
+ // Glide, don't snap: this fires mid-flow (a value just landed and the
442
+ // selection auto-advanced), where an instant recenter reads as the
443
+ // canvas hopping around. Same fallback as fitContentToRect when the
444
+ // renderer has no animation hook (web).
445
+ const zoomNow = c.viewport.state.zoom;
446
+ const target = {
447
+ zoom: zoomNow,
404
448
  pan: {
405
- x: m.anchor.x - width / (2 * v.zoom),
406
- y: m.anchor.y - height / (2 * v.zoom),
449
+ x: m.anchor.x - width / (2 * zoomNow),
450
+ y: m.anchor.y - height / (2 * zoomNow),
407
451
  },
408
- }));
452
+ };
453
+ if (animateViewport) {
454
+ animateViewport(target, 450);
455
+ }
456
+ else {
457
+ setViewport(target);
458
+ }
409
459
  },
410
460
  placeMeasurementAtCenter(ref) {
411
461
  const c = ctxRef.current;
@@ -1,6 +1,7 @@
1
1
  import { create, all } from 'mathjs';
2
2
  import { ColumnType } from '../types/firestore.js';
3
3
  import { equationForField, findField } from './schema.js';
4
+ import { fromCanonical, toCanonical } from './units.js';
4
5
  // Trig in calculator equations works in DEGREES: angle fields are stored in
5
6
  // degrees canonically, and construction authors write `H = W * tan(A)`
6
7
  // expecting A in degrees. mathjs has no `angle` config option (evaluateFormula
@@ -125,7 +126,20 @@ const evaluateEquationInContext = (equation, ctx) => {
125
126
  }
126
127
  return resolved;
127
128
  }
128
- scope[variable] = resolved.value;
129
+ // Field values resolve canonically; a variable unit re-expresses the
130
+ // value in the author's chosen display unit before it enters the scope.
131
+ const unit = equation.variableUnits?.[variable];
132
+ try {
133
+ scope[variable] =
134
+ unit != null ? fromCanonical(resolved.value, unit) : resolved.value;
135
+ }
136
+ catch (err) {
137
+ return {
138
+ ok: false,
139
+ reason: 'error',
140
+ error: err instanceof Error ? err.message : String(err),
141
+ };
142
+ }
129
143
  }
130
144
  if (missing.length > 0) {
131
145
  const result = {
@@ -137,9 +151,29 @@ const evaluateEquationInContext = (equation, ctx) => {
137
151
  return result;
138
152
  }
139
153
  const evaluated = evaluateExpression(equation.expression, scope);
140
- const result = evaluated.ok
141
- ? { ok: true, value: evaluated.value }
142
- : { ok: false, reason: 'error', error: evaluated.error };
154
+ let result;
155
+ if (!evaluated.ok) {
156
+ result = { ok: false, reason: 'error', error: evaluated.error };
157
+ }
158
+ else {
159
+ // A result unit means the expression produced a display-unit value;
160
+ // convert back so the returned value is canonical like everything else.
161
+ try {
162
+ result = {
163
+ ok: true,
164
+ value: equation.resultUnit != null
165
+ ? toCanonical(evaluated.value, equation.resultUnit)
166
+ : evaluated.value,
167
+ };
168
+ }
169
+ catch (err) {
170
+ result = {
171
+ ok: false,
172
+ reason: 'error',
173
+ error: err instanceof Error ? err.message : String(err),
174
+ };
175
+ }
176
+ }
143
177
  ctx.cache.set(equation.id, result);
144
178
  return result;
145
179
  }