@reekon-tools/boldr-utils 1.6.36 → 1.6.38

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 (76) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.js +11 -7
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +12 -7
  3. package/dist/annotation/canvas/measurementGeometry.js +9 -7
  4. package/dist/annotation/canvas/measurementStampOverlay.d.ts +2 -0
  5. package/dist/annotation/canvas/stampLayout.d.ts +8 -1
  6. package/dist/annotation/canvas/stampLayout.js +18 -2
  7. package/dist/annotation/canvas/tools/selectTool.js +14 -12
  8. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +3 -0
  9. package/dist/annotation/canvas/useAnnotationCanvasState.js +32 -8
  10. package/dist/canvas/AnnotationCanvas.d.ts +11 -0
  11. package/dist/canvas/AnnotationCanvas.js +10 -0
  12. package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
  13. package/dist/canvas/AnnotationCanvas.native.js +6 -0
  14. package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
  15. package/dist/canvas/AnnotationCanvasInner.js +219 -0
  16. package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
  17. package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
  18. package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
  19. package/dist/canvas/AnnotationCanvasSkia.js +20 -0
  20. package/dist/canvas/Tool.d.ts +38 -0
  21. package/dist/canvas/Tool.js +1 -0
  22. package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
  23. package/dist/canvas/elements/BackgroundImageElement.js +37 -0
  24. package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
  25. package/dist/canvas/elements/MeasurementStampElement.js +30 -0
  26. package/dist/canvas/elements/ShapeElement.d.ts +7 -0
  27. package/dist/canvas/elements/ShapeElement.js +62 -0
  28. package/dist/canvas/elements/StrokeElement.d.ts +7 -0
  29. package/dist/canvas/elements/StrokeElement.js +18 -0
  30. package/dist/canvas/measurementPicker.d.ts +10 -0
  31. package/dist/canvas/measurementPicker.js +1 -0
  32. package/dist/canvas/measurementStampOverlay.d.ts +11 -0
  33. package/dist/canvas/measurementStampOverlay.js +1 -0
  34. package/dist/canvas/pointerAdapter.d.ts +3 -0
  35. package/dist/canvas/pointerAdapter.js +19 -0
  36. package/dist/canvas/stampLayout.d.ts +5 -0
  37. package/dist/canvas/stampLayout.js +14 -0
  38. package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
  39. package/dist/canvas/tools/measurementStampTool.js +37 -0
  40. package/dist/canvas/tools/panTool.d.ts +5 -0
  41. package/dist/canvas/tools/panTool.js +25 -0
  42. package/dist/canvas/tools/penTool.d.ts +13 -0
  43. package/dist/canvas/tools/penTool.js +68 -0
  44. package/dist/canvas/tools/selectTool.d.ts +2 -0
  45. package/dist/canvas/tools/selectTool.js +182 -0
  46. package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
  47. package/dist/canvas/useAnnotationCanvasState.js +210 -0
  48. package/dist/canvas/viewport.d.ts +16 -0
  49. package/dist/canvas/viewport.js +54 -0
  50. package/dist/data/AnnotationDataContext.d.ts +8 -0
  51. package/dist/data/AnnotationDataContext.js +11 -0
  52. package/dist/data/AnnotationDataProvider.d.ts +65 -0
  53. package/dist/data/AnnotationDataProvider.js +4 -0
  54. package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
  55. package/dist/data/InMemoryAnnotationProvider.js +197 -0
  56. package/dist/data/canvasPersistence.d.ts +3 -0
  57. package/dist/data/canvasPersistence.js +26 -0
  58. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
  59. package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
  60. package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
  61. package/dist/data/hooks/useAnnotationDoc.js +33 -0
  62. package/dist/data/hooks/useAnnotationList.d.ts +7 -0
  63. package/dist/data/hooks/useAnnotationList.js +26 -0
  64. package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
  65. package/dist/data/hooks/useAnnotationMutations.js +11 -0
  66. package/dist/exports.d.ts +1 -1
  67. package/dist/exports.js +1 -1
  68. package/dist/hooks/useParseMeasurement.d.ts +4 -0
  69. package/dist/hooks/useParseMeasurement.js +14 -0
  70. package/dist/types/annotation.d.ts +1 -0
  71. package/dist/types/firestore.d.ts +1 -0
  72. package/dist/utils/evaluateFormula.d.ts +20 -0
  73. package/dist/utils/evaluateFormula.js +31 -0
  74. package/package.json +1 -1
  75. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +0 -12
  76. package/dist/annotation/canvas/tools/measurementLineTool.js +0 -95
@@ -5,7 +5,7 @@ import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
5
  import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
6
6
  import { buildRemoveMeasurementOps, hitPlacedMeasurement, } from './measurementGeometry.js';
7
7
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
8
- import { stampTileSize } from './stampLayout.js';
8
+ import { stampTileDims } from './stampLayout.js';
9
9
  // Screen-px radius of a measurement-annotation endpoint handle (matches the
10
10
  // native HANDLE_RADIUS_PX). Divided by zoom for a constant on-screen size.
11
11
  const HANDLE_PX = 7;
@@ -317,7 +317,9 @@ export const AnnotationCanvasInner = (props) => {
317
317
  inset: 0,
318
318
  pointerEvents: 'none',
319
319
  }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
320
- const size = stampTileSize(placed, state.tileScaleFactor, state.tileViewportScale);
320
+ // Square tile or wide group-header pill — one footprint source of
321
+ // truth shared with the hit-test (stampTileDims).
322
+ const { width, height } = stampTileDims(placed, state.tileScaleFactor, state.tileViewportScale);
321
323
  const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
322
324
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
323
325
  const isSelected = selection?.ids.includes(placed.id) ?? false;
@@ -326,19 +328,21 @@ export const AnnotationCanvasInner = (props) => {
326
328
  : (resolveStampMeasurement?.(placed) ?? null);
327
329
  // Corner-pinned, tile-proportional remove target (see the style
328
330
  // comment below) so it can't blanket a small tile and eat its grab.
329
- const removeSize = Math.min(40, size * 0.4);
331
+ const removeSize = Math.min(40, Math.min(width, height) * 0.4);
330
332
  return (_jsxs("div", { style: {
331
333
  position: 'absolute',
332
334
  left: 0,
333
335
  top: 0,
334
- width: size,
335
- height: size,
336
- transform: `translate(${cx - size / 2}px, ${cy - size / 2}px)`,
336
+ width,
337
+ height,
338
+ transform: `translate(${cx - width / 2}px, ${cy - height / 2}px)`,
337
339
  }, children: [renderMeasurementStamp({
338
340
  placed,
339
341
  measurement,
340
342
  selected: isSelected,
341
- size,
343
+ size: width,
344
+ width,
345
+ height,
342
346
  zoom: state.viewport.zoom,
343
347
  }), onMeasurementStampPress && (_jsx(StampPressTarget, { onPress: () => onMeasurementStampPress(placed), onLongPress: onMeasurementStampLongPress
344
348
  ? () => onMeasurementStampLongPress(placed)
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
4
4
  import { StyleSheet, TouchableOpacity, View, } from 'react-native';
5
5
  import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
6
6
  import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
7
- import { stampTileSize } from './stampLayout.js';
7
+ import { stampTileDims } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
10
  import { useBackgroundSkImage } from './elements/BackgroundImageElement.js';
@@ -1446,14 +1446,17 @@ export const AnnotationCanvasInner = (props) => {
1446
1446
  }) }))] }));
1447
1447
  };
1448
1448
  const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1449
- const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
1450
- const half = size / 2;
1449
+ // Square tile or wide group-header pill — one footprint source of truth
1450
+ // shared with the hit-test (stampTileDims).
1451
+ const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale);
1452
+ const halfW = width / 2;
1453
+ const halfH = height / 2;
1451
1454
  // Remove-"X" touch target, sized as a fraction of the tile and corner-pinned
1452
1455
  // so it never reaches the center. A FIXED 36px+hitSlop target blanketed small
1453
1456
  // tiles (the tile-scale slider can shrink them to ~38px), swallowing the
1454
1457
  // center grab and making a selected tile impossible to drag (it had to be
1455
1458
  // deselected first). Capped so it doesn't grow past the old size on big tiles.
1456
- const removeSize = Math.min(36, size * 0.4);
1459
+ const removeSize = Math.min(36, Math.min(width, height) * 0.4);
1457
1460
  const anchorX = placed.anchor.x;
1458
1461
  const anchorY = placed.anchor.y;
1459
1462
  // doc → screen each frame, on the UI thread. Position is a translate
@@ -1509,17 +1512,19 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1509
1512
  const cx = (worldX - panX.value) * zoom.value;
1510
1513
  const cy = (worldY - panY.value) * zoom.value;
1511
1514
  return {
1512
- transform: [{ translateX: cx - half }, { translateY: cy - half }],
1515
+ transform: [{ translateX: cx - halfW }, { translateY: cy - halfH }],
1513
1516
  };
1514
1517
  });
1515
1518
  return (_jsxs(Animated.View, { pointerEvents: "box-none", style: [
1516
- { position: 'absolute', left: 0, top: 0, width: size, height: size },
1519
+ { position: 'absolute', left: 0, top: 0, width, height },
1517
1520
  animatedStyle,
1518
1521
  ], children: [_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: renderMeasurementStamp({
1519
1522
  placed,
1520
1523
  measurement,
1521
1524
  selected,
1522
- size,
1525
+ size: width,
1526
+ width,
1527
+ height,
1523
1528
  zoom: zoomSnapshot,
1524
1529
  }) }), 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: {
1525
1530
  position: 'absolute',
@@ -7,7 +7,7 @@
7
7
  // boundary, so they inline the same math. Those inlined copies are the "worklet
8
8
  // twins" of these helpers — if you change the math here, change them too (they
9
9
  // are commented as such in AnnotationCanvasInner.native.tsx).
10
- import { stampTileSize } from './stampLayout.js';
10
+ import { stampTileDims } from './stampLayout.js';
11
11
  // Default position of the tile along its line when `linePos` is absent: center.
12
12
  export const DEFAULT_LINE_POS = 0.5;
13
13
  export const lerp = (a, b, t) => ({
@@ -111,16 +111,18 @@ const segmentDistSq = (p, a, b) => {
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 square centered on the
114
+ // The stamp renders as a constant *screen*-size box centered on the
115
115
  // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
116
- // screen-space half-extent (+ padding) back to doc space via the zoom so
116
+ // screen-space half-extents (+ padding) back to doc space via the zoom so
117
117
  // the hit box always matches what's drawn (blank and populated stamps share
118
- // the footprint — see stampLayout).
119
- const half = (stampTileSize(m, tileScaleFactor, viewportScale) / 2 + STAMP_HIT_PADDING) /
120
- zoom;
118
+ // the square footprint; group-header chips are a wide pill — see
119
+ // stampTileDims in stampLayout).
120
+ const dims = stampTileDims(m, tileScaleFactor, viewportScale);
121
+ const halfW = (dims.width / 2 + STAMP_HIT_PADDING) / zoom;
122
+ const halfH = (dims.height / 2 + STAMP_HIT_PADDING) / zoom;
121
123
  const dx = Math.abs(p.x - m.anchor.x);
122
124
  const dy = Math.abs(p.y - m.anchor.y);
123
- if (dx <= half && dy <= half)
125
+ if (dx <= halfW && dy <= halfH)
124
126
  return true;
125
127
  // Measurement annotation: also grab anywhere along the line body.
126
128
  if (m.line) {
@@ -6,6 +6,8 @@ export interface MeasurementStampRenderArgs {
6
6
  measurement: Measurement | null;
7
7
  selected: boolean;
8
8
  size: number;
9
+ width: number;
10
+ height: number;
9
11
  zoom: number;
10
12
  }
11
13
  export type RenderMeasurementStamp = (args: MeasurementStampRenderArgs) => ReactNode;
@@ -1,5 +1,7 @@
1
1
  import type { AnnotationCanvasState, PlacedMeasurementRef, TileScalePlatform } from '../../types/annotation.js';
2
2
  export declare const STAMP_TILE_SIZE = 96;
3
+ export declare const GROUP_HEADER_TILE_WIDTH = 240;
4
+ export declare const GROUP_HEADER_TILE_HEIGHT = 64;
3
5
  export declare const DEFAULT_TILE_SCALE = 1;
4
6
  export declare const TILE_SCALE_MIN = 0.4;
5
7
  export declare const TILE_SCALE_MAX = 2;
@@ -7,5 +9,10 @@ export declare const resolveTileScaleFactor: (canvas: Pick<AnnotationCanvasState
7
9
  export declare const clampTileScale: (v: number) => number;
8
10
  export declare const tileDocSizeForFactor: (factor: number, zoom: number) => number;
9
11
  export declare const tileScaleFromDocSize: (docSize: number, zoom: number) => number;
10
- export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
12
+ export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId" | "isGroupHeader">) => boolean;
11
13
  export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
14
+ export interface StampTileDims {
15
+ width: number;
16
+ height: number;
17
+ }
18
+ export declare const stampTileDims: (m: Pick<PlacedMeasurementRef, "scale" | "isGroupHeader">, tileScaleFactor?: number, viewportScale?: number) => StampTileDims;
@@ -12,6 +12,12 @@
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
+ // 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.
19
+ export const GROUP_HEADER_TILE_WIDTH = 240;
20
+ export const GROUP_HEADER_TILE_HEIGHT = 64;
15
21
  // Document-wide tile scale factor: one knob that shrinks/grows EVERY
16
22
  // measurement tile on the canvas at once, on top of each tile's own `scale`.
17
23
  // Lets a user pull tiles down on a dense drawing where lines crowd together,
@@ -76,8 +82,9 @@ export const tileScaleFromDocSize = (docSize, zoom) => (docSize * zoom) / STAMP_
76
82
  // A placed measurement is an unassociated input until a measurement reference
77
83
  // is attached (id or path) OR it is bound to a form column (`columnId`). Such
78
84
  // stamps render as the blank "+" placeholder (same footprint as a populated
79
- // tile — see STAMP_TILE_SIZE).
80
- export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath && !m.columnId;
85
+ // tile — see STAMP_TILE_SIZE). Group-header chips carry neither ref but are
86
+ // NOT inputs never treat them as blank "+" placeholders.
87
+ export const isUnassociatedStamp = (m) => !m.isGroupHeader && !m.measurementId && !m.measurementPath && !m.columnId;
81
88
  // Screen-space edge length for a placed stamp: the base size scaled by the
82
89
  // per-stamp `scale` and the document-wide `tileScaleFactor`. Blank and
83
90
  // populated stamps share the footprint so a tile never resizes when it gains a
@@ -89,3 +96,12 @@ export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath
89
96
  // retained for call compatibility and is always 1 — the old per-canvas
90
97
  // multiplier was retired (see the note above). Independent of zoom.
91
98
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => STAMP_TILE_SIZE * (m.scale ?? 1) * tileScaleFactor * viewportScale;
99
+ export const stampTileDims = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => {
100
+ const k = (m.scale ?? 1) * tileScaleFactor * viewportScale;
101
+ return m.isGroupHeader
102
+ ? {
103
+ width: GROUP_HEADER_TILE_WIDTH * k,
104
+ height: GROUP_HEADER_TILE_HEIGHT * k,
105
+ }
106
+ : { width: STAMP_TILE_SIZE * k, height: STAMP_TILE_SIZE * k };
107
+ };
@@ -1,4 +1,4 @@
1
- import { stampTileSize } from '../stampLayout.js';
1
+ import { stampTileDims } from '../stampLayout.js';
2
2
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
3
  import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
4
4
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
@@ -157,10 +157,11 @@ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1, tileSc
157
157
  const m = doc.placedMeasurements.find((x) => x.id === id);
158
158
  if (!m)
159
159
  return false;
160
- const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
161
- zoom;
162
- return (Math.abs(world.x - m.anchor.x) <= half &&
163
- Math.abs(world.y - m.anchor.y) <= half);
160
+ const dims = stampTileDims(m, tileScaleFactor, viewportTileScale);
161
+ const halfW = (dims.width / 2 + HIT_PADDING) / zoom;
162
+ const halfH = (dims.height / 2 + HIT_PADDING) / zoom;
163
+ return (Math.abs(world.x - m.anchor.x) <= halfW &&
164
+ Math.abs(world.y - m.anchor.y) <= halfH);
164
165
  };
165
166
  // --- Measurement-annotation grab logic (shared by the native UI-thread drag
166
167
  // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
@@ -170,13 +171,14 @@ const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFact
170
171
  const m = doc.placedMeasurements.find((x) => x.id === id);
171
172
  if (!m)
172
173
  return 'move';
173
- // Same footprint the tile is drawn at (smaller for unassociated inputs, #6;
174
- // folds in the document-wide tile scale + viewport scale so slide-grab
175
- // matches the draw).
176
- const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
177
- zoom;
178
- const onTile = Math.abs(world.x - m.anchor.x) <= half &&
179
- Math.abs(world.y - m.anchor.y) <= half;
174
+ // Same footprint the tile is drawn at (wide pill for group headers; folds in
175
+ // the document-wide tile scale + viewport scale so slide-grab matches the
176
+ // draw).
177
+ const dims = stampTileDims(m, tileScaleFactor, viewportTileScale);
178
+ const halfW = (dims.width / 2 + HIT_PADDING) / zoom;
179
+ const halfH = (dims.height / 2 + HIT_PADDING) / zoom;
180
+ const onTile = Math.abs(world.x - m.anchor.x) <= halfW &&
181
+ Math.abs(world.y - m.anchor.y) <= halfH;
180
182
  return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
181
183
  };
182
184
  // Slide the tile along its line by a world-space drag delta (relative to
@@ -30,6 +30,9 @@ export interface AnnotationCanvasHandle {
30
30
  groupId: string;
31
31
  columnId: string;
32
32
  }): AnnotationElementId;
33
+ placeGroupHeaderAtCenter(ref: {
34
+ groupId: string;
35
+ }): AnnotationElementId;
33
36
  deleteSelected(): void;
34
37
  }
35
38
  export interface UseAnnotationCanvasStateProps {
@@ -1,6 +1,6 @@
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, stampTileSize, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
3
+ import { resolveTileScaleFactor, stampTileDims, 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
6
  // The viewport that frames the document's content on screen. When a background
@@ -306,14 +306,16 @@ export const useAnnotationCanvasState = (props) => {
306
306
  const m = c.document.placedMeasurements.find((x) => x.id === id);
307
307
  if (!m)
308
308
  return;
309
- // The tile is a screen-constant square centered on its anchor, so its
310
- // half-edge is screen px regardless of zoom (stampTileSize).
311
- const half = stampTileSize(m, c.tileScaleFactor, c.tileViewportScale) / 2;
309
+ // The tile is a screen-constant box centered on its anchor, so its
310
+ // half-extents are screen px regardless of zoom (stampTileDims).
311
+ const dims = stampTileDims(m, c.tileScaleFactor, c.tileViewportScale);
312
+ const halfW = dims.width / 2;
313
+ const halfH = dims.height / 2;
312
314
  const s = c.viewport.worldToScreen(m.anchor);
313
- if (s.x - half >= 0 &&
314
- s.x + half <= width &&
315
- s.y - half >= 0 &&
316
- s.y + half <= height) {
315
+ if (s.x - halfW >= 0 &&
316
+ s.x + halfW <= width &&
317
+ s.y - halfH >= 0 &&
318
+ s.y + halfH <= height) {
317
319
  return;
318
320
  }
319
321
  setViewport((v) => ({
@@ -509,6 +511,28 @@ export const useAnnotationCanvasState = (props) => {
509
511
  c.setSelection({ ids: [placed.id] });
510
512
  return placed.id;
511
513
  },
514
+ placeGroupHeaderAtCenter(ref) {
515
+ const c = ctxRef.current;
516
+ const anchor = c.viewport.screenToWorld({
517
+ x: width / 2,
518
+ y: height / 2,
519
+ });
520
+ // No showLabel/showValue/placement — a header is not an input; it
521
+ // renders the group's name/index, resolved live by the consumer.
522
+ const placed = {
523
+ id: `header-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
524
+ layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
525
+ isGroupHeader: true,
526
+ groupId: ref.groupId,
527
+ anchor,
528
+ createdAt: Date.now(),
529
+ };
530
+ c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
531
+ // Select it so the consumer can immediately move it (the consumer is
532
+ // responsible for switching to its move/select tool).
533
+ c.setSelection({ ids: [placed.id] });
534
+ return placed.id;
535
+ },
512
536
  deleteSelected() {
513
537
  const c = ctxRef.current;
514
538
  const ids = c.selection?.ids;
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.js';
3
+ export type { AnnotationCanvasHandle, GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
+ export interface CanvasKitOpts {
5
+ locateFile?: (file: string) => string;
6
+ }
7
+ export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
8
+ fallback?: ReactNode;
9
+ canvasKitOpts?: CanvasKitOpts;
10
+ };
11
+ export declare const AnnotationCanvas: ({ fallback, canvasKitOpts, ...rest }: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { WithSkiaWeb } from '@shopify/react-native-skia/lib/module/web/index.js';
3
+ // Web-only entry point: lazy-loads CanvasKit wasm via WithSkiaWeb. Imperative
4
+ // API (undo/redo/zoom) is exposed through the `imperativeRef` prop rather
5
+ // than React refs, since WithSkiaWeb can't forward refs through its lazy
6
+ // component boundary. The native build sits in `AnnotationCanvas.native.tsx`
7
+ // and skips WithSkiaWeb entirely (no wasm to load).
8
+ export const AnnotationCanvas = ({ fallback, canvasKitOpts, ...rest }) => (_jsx(WithSkiaWeb, { getComponent: () => import('./AnnotationCanvasInner.js').then((m) => ({
9
+ default: m.AnnotationCanvasInner,
10
+ })), fallback: fallback ?? null, componentProps: rest, opts: canvasKitOpts }));
@@ -0,0 +1,8 @@
1
+ import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.native.js';
2
+ export type { AnnotationCanvasHandle, } from './useAnnotationCanvasState.js';
3
+ export type { GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
+ export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
5
+ fallback?: unknown;
6
+ canvasKitOpts?: unknown;
7
+ };
8
+ export declare const AnnotationCanvas: (props: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { AnnotationCanvasInner } from './AnnotationCanvasInner.native.js';
3
+ export const AnnotationCanvas = (props) => {
4
+ const { fallback: _f, canvasKitOpts: _o, ...rest } = props;
5
+ return _jsx(AnnotationCanvasInner, { ...rest });
6
+ };
@@ -0,0 +1,39 @@
1
+ import { type CSSProperties, type MutableRefObject } from 'react';
2
+ import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
3
+ import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
4
+ import type { MeasurementRef } from './measurementPicker.js';
5
+ import type { Tool } from './Tool.js';
6
+ import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
7
+ import { type ViewportState } from './viewport.js';
8
+ import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
9
+ export type { AnnotationCanvasHandle };
10
+ export interface AnnotationCanvasInnerProps {
11
+ canvas: AnnotationCanvasState;
12
+ onCommit(patch: AnnotationDocumentPatch): void;
13
+ tools: Tool[];
14
+ activeToolId: string;
15
+ selection: Selection | null;
16
+ onSelectionChange(selection: Selection | null): void;
17
+ measurements?: Measurement[];
18
+ fallbackUnit?: Units;
19
+ fractionalTolerance?: FractionalTolerance;
20
+ decimalTolerance?: DecimalTolerance;
21
+ resolveImageUrl?: (storagePath: string) => Promise<string>;
22
+ pickMeasurement?: () => Promise<MeasurementRef | null>;
23
+ renderMeasurementStamp?: RenderMeasurementStamp;
24
+ stampFontSource?: unknown;
25
+ stampValueFontSize?: number;
26
+ stampLabelFontSize?: number;
27
+ gestures?: GestureConfig;
28
+ width: number;
29
+ height: number;
30
+ initialViewport?: ViewportState;
31
+ style?: CSSProperties;
32
+ imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
33
+ }
34
+ export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
35
+ export interface GestureConfig {
36
+ wheel?: 'zoom' | 'pan' | 'auto';
37
+ panTriggers?: PanTrigger[];
38
+ }
39
+ export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,219 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFont } from '@shopify/react-native-skia';
3
+ import { useCallback, useEffect, useRef, } from 'react';
4
+ import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
+ import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
6
+ import { STAMP_TILE_SIZE } from './stampLayout.js';
7
+ const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
8
+ export const AnnotationCanvasInner = (props) => {
9
+ const { fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, stampFontSource, stampValueFontSize = 14, stampLabelFontSize = 11, gestures, width, height, style, activeToolId, tools, } = props;
10
+ const wheelMode = gestures?.wheel ?? 'auto';
11
+ const panTriggers = gestures?.panTriggers ?? DEFAULT_PAN_TRIGGERS;
12
+ const allowSpacePan = panTriggers.includes('space');
13
+ const allowMiddlePan = panTriggers.includes('middleMouse');
14
+ const allowRightPan = panTriggers.includes('rightMouse');
15
+ const valueFont = useFont(stampFontSource, stampValueFontSize);
16
+ const labelFont = useFont(stampFontSource, stampLabelFontSize);
17
+ const state = useAnnotationCanvasState(props);
18
+ const containerRef = useRef(null);
19
+ const panGestureRef = useRef(null);
20
+ const spaceDownRef = useRef(false);
21
+ const activeTool = tools.find((t) => t.id === activeToolId) ?? null;
22
+ const toCanvasPointer = useCallback((event) => {
23
+ const rect = containerRef.current?.getBoundingClientRect();
24
+ const screen = {
25
+ x: event.clientX - (rect?.left ?? 0),
26
+ y: event.clientY - (rect?.top ?? 0),
27
+ };
28
+ return {
29
+ pointerId: event.pointerId,
30
+ screen,
31
+ world: state.ctx.viewport.screenToWorld(screen),
32
+ pressure: event.pressure || undefined,
33
+ shiftKey: event.shiftKey,
34
+ altKey: event.altKey,
35
+ metaKey: event.metaKey,
36
+ ctrlKey: event.ctrlKey,
37
+ };
38
+ }, [state.ctx.viewport]);
39
+ const isPanTriggerDown = useCallback((event) => {
40
+ if (allowMiddlePan && event.button === 1)
41
+ return true;
42
+ if (allowRightPan && event.button === 2)
43
+ return true;
44
+ if (allowSpacePan && event.button === 0 && spaceDownRef.current)
45
+ return true;
46
+ return false;
47
+ }, [allowMiddlePan, allowRightPan, allowSpacePan]);
48
+ const handlePointerDown = useCallback((event) => {
49
+ const rect = containerRef.current?.getBoundingClientRect();
50
+ const screen = {
51
+ x: event.clientX - (rect?.left ?? 0),
52
+ y: event.clientY - (rect?.top ?? 0),
53
+ };
54
+ if (isPanTriggerDown(event)) {
55
+ panGestureRef.current = { pointerId: event.pointerId, lastScreen: screen };
56
+ event.currentTarget.setPointerCapture(event.pointerId);
57
+ event.preventDefault();
58
+ return;
59
+ }
60
+ event.currentTarget.setPointerCapture(event.pointerId);
61
+ state.dispatchPointerDown(toCanvasPointer(event));
62
+ }, [state, toCanvasPointer, isPanTriggerDown]);
63
+ const handlePointerMove = useCallback((event) => {
64
+ const pan = panGestureRef.current;
65
+ if (pan && event.pointerId === pan.pointerId) {
66
+ const rect = containerRef.current?.getBoundingClientRect();
67
+ const screen = {
68
+ x: event.clientX - (rect?.left ?? 0),
69
+ y: event.clientY - (rect?.top ?? 0),
70
+ };
71
+ state.pan({
72
+ x: screen.x - pan.lastScreen.x,
73
+ y: screen.y - pan.lastScreen.y,
74
+ });
75
+ panGestureRef.current = { pointerId: pan.pointerId, lastScreen: screen };
76
+ return;
77
+ }
78
+ state.dispatchPointerMove(toCanvasPointer(event));
79
+ }, [state, toCanvasPointer]);
80
+ const handlePointerUp = useCallback((event) => {
81
+ const pan = panGestureRef.current;
82
+ if (pan && event.pointerId === pan.pointerId) {
83
+ panGestureRef.current = null;
84
+ return;
85
+ }
86
+ state.dispatchPointerUp(toCanvasPointer(event));
87
+ }, [state, toCanvasPointer]);
88
+ const handlePointerCancel = useCallback(() => {
89
+ panGestureRef.current = null;
90
+ state.dispatchPointerCancel();
91
+ }, [state]);
92
+ const handleWheel = useCallback((event) => {
93
+ const rect = containerRef.current?.getBoundingClientRect();
94
+ const focal = {
95
+ x: event.clientX - (rect?.left ?? 0),
96
+ y: event.clientY - (rect?.top ?? 0),
97
+ };
98
+ if (event.ctrlKey || event.metaKey) {
99
+ const factor = Math.exp(-event.deltaY / 200);
100
+ state.zoom(focal, state.ctx.viewport.state.zoom * factor);
101
+ return;
102
+ }
103
+ if (wheelMode === 'zoom') {
104
+ const factor = Math.exp(-event.deltaY / 300);
105
+ state.zoom(focal, state.ctx.viewport.state.zoom * factor);
106
+ return;
107
+ }
108
+ if (event.shiftKey) {
109
+ state.pan({ x: event.deltaY, y: event.deltaX });
110
+ return;
111
+ }
112
+ state.pan({ x: event.deltaX, y: event.deltaY });
113
+ }, [state, wheelMode]);
114
+ const handleContextMenu = useCallback((event) => {
115
+ if (allowRightPan)
116
+ event.preventDefault();
117
+ }, [allowRightPan]);
118
+ useEffect(() => {
119
+ const el = containerRef.current;
120
+ if (!el)
121
+ return;
122
+ const stop = (e) => {
123
+ if (e.target === el || el.contains(e.target)) {
124
+ e.preventDefault();
125
+ }
126
+ };
127
+ el.addEventListener('wheel', stop, { passive: false });
128
+ return () => el.removeEventListener('wheel', stop);
129
+ }, []);
130
+ useEffect(() => {
131
+ if (!allowSpacePan)
132
+ return;
133
+ const onKeyDown = (e) => {
134
+ if (e.code === 'Space' && !e.repeat) {
135
+ spaceDownRef.current = true;
136
+ if (containerRef.current)
137
+ containerRef.current.style.cursor = 'grab';
138
+ }
139
+ };
140
+ const onKeyUp = (e) => {
141
+ if (e.code === 'Space') {
142
+ spaceDownRef.current = false;
143
+ if (containerRef.current)
144
+ containerRef.current.style.cursor = '';
145
+ }
146
+ };
147
+ window.addEventListener('keydown', onKeyDown);
148
+ window.addEventListener('keyup', onKeyUp);
149
+ return () => {
150
+ window.removeEventListener('keydown', onKeyDown);
151
+ window.removeEventListener('keyup', onKeyUp);
152
+ };
153
+ }, [allowSpacePan]);
154
+ const containerStyle = {
155
+ position: 'relative',
156
+ width,
157
+ height,
158
+ overflow: 'hidden',
159
+ touchAction: 'none',
160
+ userSelect: 'none',
161
+ cursor: activeTool?.cursor ?? 'default',
162
+ ...style,
163
+ };
164
+ const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
165
+ const { renderMeasurementStamp, selection } = props;
166
+ return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
167
+ width,
168
+ height,
169
+ effectiveCanvas: state.effectiveCanvas,
170
+ worldTransform: state.worldTransform,
171
+ measurementsById: state.measurementsById,
172
+ fallbackUnit,
173
+ fractionalTolerance,
174
+ decimalTolerance,
175
+ resolveImageUrl,
176
+ valueFont,
177
+ labelFont,
178
+ hideMeasurementStamps: !!renderMeasurementStamp,
179
+ penDrawingStroke: state.penDrawingStroke,
180
+ customPreview,
181
+ }), renderMeasurementStamp && (_jsx("div", { style: {
182
+ position: 'absolute',
183
+ inset: 0,
184
+ pointerEvents: 'none',
185
+ }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
186
+ const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
187
+ const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
188
+ const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
189
+ const isSelected = selection?.ids.includes(placed.id) ?? false;
190
+ return (_jsxs("div", { style: {
191
+ position: 'absolute',
192
+ left: 0,
193
+ top: 0,
194
+ width: size,
195
+ height: size,
196
+ transform: `translate(${cx - size / 2}px, ${cy - size / 2}px)`,
197
+ }, children: [renderMeasurementStamp({
198
+ placed,
199
+ measurement: state.measurementsById.get(placed.measurementId) ?? null,
200
+ selected: isSelected,
201
+ size,
202
+ zoom: state.viewport.zoom,
203
+ }), isSelected && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
204
+ e.stopPropagation();
205
+ state.ctx.commit({
206
+ ops: [{ op: 'removeMeasurement', id: placed.id }],
207
+ });
208
+ state.ctx.setSelection(null);
209
+ }, style: {
210
+ position: 'absolute',
211
+ top: -10,
212
+ right: -10,
213
+ width: 40,
214
+ height: 40,
215
+ cursor: 'pointer',
216
+ pointerEvents: 'auto',
217
+ } }))] }, placed.id));
218
+ }) }))] }));
219
+ };