@reekon-tools/boldr-utils 1.7.0 → 1.7.1

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.
@@ -58,12 +58,20 @@ 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
+ // The animated-viewport implementation needs the Reanimated shared values,
62
+ // which are created below AFTER the state hook — so the hook gets a stable
63
+ // trampoline into a ref the implementation fills in each render.
64
+ const animateViewportImplRef = useRef(null);
65
+ const animateViewport = useCallback((target, durationMs) => {
66
+ animateViewportImplRef.current?.(target, durationMs);
67
+ }, []);
61
68
  // This is the native (mobile) renderer, so the canvas reads mobile's
62
69
  // tile-scale knob (web and mobile persist separate values — see
63
70
  // resolveTileScaleFactor).
64
71
  const state = useAnnotationCanvasState({
65
72
  ...props,
66
73
  tileScalePlatform: 'mobile',
74
+ animateViewport,
67
75
  });
68
76
  // Decode the background image ONCE here and share it with both the main
69
77
  // canvas and the magnifier loupe. If each canvas loaded its own, the loupe
@@ -620,17 +628,73 @@ export const AnnotationCanvasInner = (props) => {
620
628
  // effect so an in-flight gesture's shared values are never clobbered by a
621
629
  // late-flushing setViewport from a previous gesture.
622
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
633
+ // does: mid-glide re-renders must not snap the shared values back to the
634
+ // (still-old) JS snapshot.
635
+ const viewportAnimations = useRef(0);
623
636
  // Push JS-originated viewport changes (initial mount, zoomToFit, resetView)
624
637
  // onto the UI thread. Gesture-driven changes already live in the shared
625
638
  // values, so during a gesture this is skipped; the post-gesture re-sync
626
- // writes back identical values (no visual jump).
639
+ // writes back identical values (no visual jump). Ditto animated transitions.
627
640
  useEffect(() => {
628
641
  if (activeViewportGestures.current > 0)
629
642
  return;
643
+ if (viewportAnimations.current > 0)
644
+ return;
630
645
  zoom.value = state.viewport.zoom;
631
646
  panX.value = state.viewport.pan.x;
632
647
  panY.value = state.viewport.pan.y;
633
648
  }, [state.viewport, zoom, panX, panY]);
649
+ // Animated viewport transitions (fitContentToRect with `animated`): a JS
650
+ // rAF driver eases the same shared values gestures write — DIRECT writes,
651
+ // three per frame, no React involvement until the single end-of-motion
652
+ // commit. Direct `.value =` writes are the proven repaint channel for the
653
+ // Skia consumers (it's exactly how gestures render); driving these values
654
+ // through reanimated's animation path (withTiming) completed without ever
655
+ // repainting on device, so it is deliberately not used here. Mid-flight
656
+ // re-renders can't stomp the drive (the sync effect above is guarded), and
657
+ // a viewport gesture starting mid-flight ends the drive on its next frame
658
+ // — before it writes — and owns the viewport from there. A new drive takes
659
+ // over from the live values, so interrupted transitions resume from where
660
+ // they actually are.
661
+ const viewportDriveRef = useRef(null);
662
+ const cancelViewportDrive = useCallback(() => {
663
+ if (viewportDriveRef.current != null) {
664
+ cancelAnimationFrame(viewportDriveRef.current);
665
+ viewportDriveRef.current = null;
666
+ viewportAnimations.current = 0;
667
+ }
668
+ }, []);
669
+ useEffect(() => cancelViewportDrive, [cancelViewportDrive]);
670
+ animateViewportImplRef.current = (target, durationMs) => {
671
+ cancelViewportDrive();
672
+ const from = { zoom: zoom.value, panX: panX.value, panY: panY.value };
673
+ const start = Date.now();
674
+ const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - (2 - 2 * t) ** 3 / 2;
675
+ viewportAnimations.current = 1;
676
+ const step = () => {
677
+ if (activeViewportGestures.current > 0) {
678
+ viewportDriveRef.current = null;
679
+ viewportAnimations.current = 0;
680
+ return;
681
+ }
682
+ 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;
687
+ if (t < 1) {
688
+ viewportDriveRef.current = requestAnimationFrame(step);
689
+ }
690
+ else {
691
+ viewportDriveRef.current = null;
692
+ viewportAnimations.current = 0;
693
+ stateRef.current.setViewport(target);
694
+ }
695
+ };
696
+ viewportDriveRef.current = requestAnimationFrame(step);
697
+ };
634
698
  const gesture = useMemo(() => {
635
699
  const buildEvent = (pointerId, screen) => ({
636
700
  pointerId,
@@ -1427,7 +1491,12 @@ export const AnnotationCanvasInner = (props) => {
1427
1491
  // magnification lives in loupeTransform, applied to `content`).
1428
1492
  const loupeClip = rrect(rect(loupeX, loupeY, loupeSize, loupeSize), LOUPE_RADIUS, LOUPE_RADIUS);
1429
1493
  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 })] }));
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
1494
+ return (
1495
+ // overflow hidden mirrors the web container: the Skia surface clips its
1496
+ // drawing to the canvas box by construction, but the stamp overlay is
1497
+ // plain RN views — without clipping, tiles panned past the edge of a
1498
+ // non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
1499
+ _jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, 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
1431
1500
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1432
1501
  : (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
1502
  ? () => {
@@ -18,10 +18,15 @@ const useBackgroundUrl = (image, resolveUrl) => {
18
18
  let cancelled = false;
19
19
  const resolve = resolveUrlRef.current;
20
20
  if (resolve) {
21
- resolve(image.storagePath).then((next) => {
21
+ // A rejecting resolver (e.g. offline URL resolution) must never become
22
+ // an unhandled rejection here — keep the current url; the consumer's
23
+ // own retries/invalidations drive the re-resolve.
24
+ resolve(image.storagePath)
25
+ .then((next) => {
22
26
  if (!cancelled)
23
27
  setUrl(next);
24
- });
28
+ })
29
+ .catch(() => { });
25
30
  }
26
31
  else {
27
32
  setUrl(image.downloadUrl);
@@ -3,6 +3,12 @@ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type Annotati
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';
6
+ export interface CanvasScreenRect {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ }
6
12
  export interface AnnotationCanvasHandle {
7
13
  undo(): void;
8
14
  redo(): void;
@@ -10,6 +16,10 @@ export interface AnnotationCanvasHandle {
10
16
  canRedo(): boolean;
11
17
  zoomToFit(): void;
12
18
  resetView(): void;
19
+ fitContentToRect(rect: CanvasScreenRect, opts?: {
20
+ animated?: boolean;
21
+ durationMs?: number;
22
+ }): void;
13
23
  getTileScaleFactor(): number;
14
24
  getTileDocSize(factor: number): number;
15
25
  ensureMeasurementVisible(id: AnnotationElementId): void;
@@ -49,6 +59,7 @@ export interface UseAnnotationCanvasStateProps {
49
59
  height: number;
50
60
  initialViewport?: ViewportState;
51
61
  tileScalePlatform?: TileScalePlatform;
62
+ animateViewport?: (target: ViewportState, durationMs: number) => void;
52
63
  imperativeRef?: {
53
64
  current: AnnotationCanvasHandle | null;
54
65
  };
@@ -3,22 +3,66 @@ import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotati
3
3
  import { resolveTileScaleFactor, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
4
4
  import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
5
5
  import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
6
- // The viewport that frames the document's content on screen. When a background
7
- // image is present we fit its rendered rect (so the whole image shows, filling
8
- // the viewport regardless of its pixel resolution — never the native-resolution
9
- // top-left crop a 1:1 viewport gives a high-res image); otherwise we fit the
10
- // document rect. Shared by the load-time auto-fit and the zoomToFit/resetView
11
- // handle methods so they stay in lockstep.
12
- const computeContentFit = (canvas, width, height) => {
13
- if (!(width > 0) || !(height > 0))
6
+ // The viewport that frames the document's content inside a screen rect of the
7
+ // canvas box. When a background image is present we fit its rendered rect (so
8
+ // the whole image shows, filling the rect regardless of its pixel resolution —
9
+ // never the native-resolution top-left crop a 1:1 viewport gives a high-res
10
+ // image); otherwise we fit the document rect. Placed tiles count as content
11
+ // too: authors routinely drop them AROUND the image (dimension labels sit
12
+ // outside the object they measure), and a fit framed on the image alone opens
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.
18
+ // Shared by the load-time auto-fit and the zoomToFit/resetView/
19
+ // fitContentToRect handle methods so they stay in lockstep.
20
+ const computeContentFitRect = (canvas, rect) => {
21
+ if (!(rect.width > 0) || !(rect.height > 0))
14
22
  return DEFAULT_VIEWPORT;
15
23
  const { viewport } = canvas;
16
24
  const bg = viewport.backgroundImage;
17
- const rect = bg
25
+ const baseRect = bg
18
26
  ? imageDocRect(bg.widthPx, bg.heightPx, viewport.width, viewport.height, viewport.backgroundFit ?? 'contain')
19
27
  : { x: 0, y: 0, width: viewport.width, height: viewport.height };
20
- return fitRectToScreen(rect, width, height);
28
+ let minX = baseRect.x;
29
+ let minY = baseRect.y;
30
+ let maxX = baseRect.x + baseRect.width;
31
+ let maxY = baseRect.y + baseRect.height;
32
+ const extend = (p) => {
33
+ minX = Math.min(minX, p.x);
34
+ minY = Math.min(minY, p.y);
35
+ maxX = Math.max(maxX, p.x);
36
+ maxY = Math.max(maxY, p.y);
37
+ };
38
+ for (const placed of canvas.placedMeasurements) {
39
+ extend(placed.anchor);
40
+ if (placed.line) {
41
+ extend(placed.line.a);
42
+ extend(placed.line.b);
43
+ }
44
+ if (placed.rect) {
45
+ extend(placed.rect.a);
46
+ extend(placed.rect.b);
47
+ }
48
+ }
49
+ const extended = minX < baseRect.x ||
50
+ minY < baseRect.y ||
51
+ maxX > baseRect.x + baseRect.width ||
52
+ maxY > baseRect.y + baseRect.height;
53
+ const fit = fitRectToScreen({ x: minX, y: minY, width: maxX - minX, height: maxY - minY }, rect.width, rect.height, extended ? STAMP_TILE_SIZE / 2 : 0);
54
+ // fitRectToScreen frames within a box anchored at the origin; shift the
55
+ // window so the framed content lands in the sub-rect instead
56
+ // (screen = (world − pan) · zoom, so a +x screen offset is −x/zoom of pan).
57
+ return {
58
+ zoom: fit.zoom,
59
+ pan: {
60
+ x: fit.pan.x - rect.x / fit.zoom,
61
+ y: fit.pan.y - rect.y / fit.zoom,
62
+ },
63
+ };
21
64
  };
65
+ const computeContentFit = (canvas, width, height) => computeContentFitRect(canvas, { x: 0, y: 0, width, height });
22
66
  // Default leader length (doc units) for a placed measurement when no explicit
23
67
  // length is given. A generous fraction of the document width, clamped, so the
24
68
  // leader — and its draggable endcaps — stay clear of the fixed-size value tile
@@ -31,7 +75,7 @@ const defaultLeaderLenDoc = (docWidth) => Math.min(800, Math.max(240, docWidth *
31
75
  // inners share this hook; each wraps it with platform-specific event
32
76
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
33
77
  export const useAnnotationCanvasState = (props) => {
34
- const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, imperativeRef, } = props;
78
+ const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
35
79
  const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
36
80
  const [toolState, setToolState] = useState(undefined);
37
81
  const [previewPatch, setPreviewPatch] = useState(null);
@@ -321,6 +365,17 @@ export const useAnnotationCanvasState = (props) => {
321
365
  // for a high-res background is the very crop this is meant to escape.
322
366
  setViewport(computeContentFit(canvas, width, height));
323
367
  },
368
+ fitContentToRect(rect, opts) {
369
+ if (!(rect.width > 0) || !(rect.height > 0))
370
+ return;
371
+ const target = computeContentFitRect(canvas, rect);
372
+ if (opts?.animated && animateViewport) {
373
+ animateViewport(target, opts.durationMs ?? 300);
374
+ }
375
+ else {
376
+ setViewport(target);
377
+ }
378
+ },
324
379
  getTileScaleFactor() {
325
380
  return ctxRef.current.tileScaleFactor;
326
381
  },
@@ -2,4 +2,5 @@ export * from './schema.js';
2
2
  export * from './units.js';
3
3
  export * from './evaluate.js';
4
4
  export * from './solve.js';
5
+ export * from './instance.js';
5
6
  export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, type IssueSeverity, type ValidationIssue, type ValidationResult, } from './validate.js';
@@ -6,4 +6,5 @@ export * from './schema.js';
6
6
  export * from './units.js';
7
7
  export * from './evaluate.js';
8
8
  export * from './solve.js';
9
+ export * from './instance.js';
9
10
  export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, } from './validate.js';
@@ -0,0 +1,36 @@
1
+ import type { AnnotationCanvasState } from '../types/annotation.js';
2
+ import type { CalculatorDefinition } from './schema.js';
3
+ import type { CalculatorUnit } from './units.js';
4
+ export type CalculatorEntrySource = 'user' | 'tool';
5
+ export interface CalculatorEntryAttribution {
6
+ deviceId: string;
7
+ deviceName: string | null;
8
+ userName: string | null;
9
+ /** Epoch ms. */
10
+ at: number;
11
+ }
12
+ export interface CalculatorFieldEntry {
13
+ value: number | string | string[];
14
+ source: CalculatorEntrySource;
15
+ attribution: CalculatorEntryAttribution | null;
16
+ /** Pinned: edits, clears, and tape readings are refused until unlocked. */
17
+ locked: boolean;
18
+ /** Epoch ms. */
19
+ enteredAt: number;
20
+ }
21
+ export interface CalculatorInstanceFileData {
22
+ /** Source doc id in the top-level `calculators` collection. */
23
+ calculatorId: string;
24
+ /** definition.version at save time. */
25
+ sourceVersion: number;
26
+ definition: CalculatorDefinition;
27
+ entries: Record<string, CalculatorFieldEntry>;
28
+ /** Session display-unit choices, keyed by field id. */
29
+ displayUnitOverrides?: Record<string, CalculatorUnit>;
30
+ /**
31
+ * Embedded copy of the source diagram canvas. Its background image is
32
+ * re-uploaded under the instance file's own Storage path (and
33
+ * viewport.backgroundImage.storagePath repointed) at save time.
34
+ */
35
+ canvas?: AnnotationCanvasState;
36
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",