@reekon-tools/boldr-utils 1.19.5 → 1.20.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 (88) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +2 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +12 -1
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +2 -0
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +329 -35
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +28 -1
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +99 -14
  7. package/dist/annotation/canvas/Tool.d.ts +10 -1
  8. package/dist/annotation/canvas/backgroundLayers.d.ts +20 -1
  9. package/dist/annotation/canvas/backgroundLayers.js +94 -3
  10. package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +2 -1
  11. package/dist/annotation/canvas/elements/BackgroundImageElement.js +10 -2
  12. package/dist/annotation/canvas/fileTileSwap.d.ts +12 -0
  13. package/dist/annotation/canvas/fileTileSwap.js +87 -0
  14. package/dist/annotation/canvas/snapGeometry.d.ts +32 -0
  15. package/dist/annotation/canvas/snapGeometry.js +168 -0
  16. package/dist/annotation/canvas/stampLayout.d.ts +1 -1
  17. package/dist/annotation/canvas/stampLayout.js +10 -8
  18. package/dist/annotation/canvas/tools/measurementTool.js +38 -4
  19. package/dist/annotation/canvas/tools/selectTool.d.ts +18 -0
  20. package/dist/annotation/canvas/tools/selectTool.js +201 -43
  21. package/dist/annotation/canvas/tools/shapeTool.js +27 -5
  22. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -0
  23. package/dist/annotation/canvas/useAnnotationCanvasState.js +19 -2
  24. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +5 -1
  25. package/dist/exports.d.ts +3 -1
  26. package/dist/exports.js +2 -0
  27. package/dist/types/annotation.d.ts +10 -0
  28. package/package.json +1 -1
  29. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  30. package/dist/canvas/AnnotationCanvas.js +0 -10
  31. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  32. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  33. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  34. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  35. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  36. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  37. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  38. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  39. package/dist/canvas/Tool.d.ts +0 -38
  40. package/dist/canvas/Tool.js +0 -1
  41. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  42. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  43. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  44. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  45. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  46. package/dist/canvas/elements/ShapeElement.js +0 -62
  47. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  48. package/dist/canvas/elements/StrokeElement.js +0 -18
  49. package/dist/canvas/measurementPicker.d.ts +0 -10
  50. package/dist/canvas/measurementPicker.js +0 -1
  51. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  52. package/dist/canvas/measurementStampOverlay.js +0 -1
  53. package/dist/canvas/pointerAdapter.d.ts +0 -3
  54. package/dist/canvas/pointerAdapter.js +0 -19
  55. package/dist/canvas/stampLayout.d.ts +0 -5
  56. package/dist/canvas/stampLayout.js +0 -14
  57. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  58. package/dist/canvas/tools/measurementStampTool.js +0 -37
  59. package/dist/canvas/tools/panTool.d.ts +0 -5
  60. package/dist/canvas/tools/panTool.js +0 -25
  61. package/dist/canvas/tools/penTool.d.ts +0 -13
  62. package/dist/canvas/tools/penTool.js +0 -68
  63. package/dist/canvas/tools/selectTool.d.ts +0 -2
  64. package/dist/canvas/tools/selectTool.js +0 -182
  65. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  66. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  67. package/dist/canvas/viewport.d.ts +0 -16
  68. package/dist/canvas/viewport.js +0 -54
  69. package/dist/data/AnnotationDataContext.d.ts +0 -8
  70. package/dist/data/AnnotationDataContext.js +0 -11
  71. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  72. package/dist/data/AnnotationDataProvider.js +0 -4
  73. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  74. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  75. package/dist/data/canvasPersistence.d.ts +0 -3
  76. package/dist/data/canvasPersistence.js +0 -26
  77. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  78. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  79. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  80. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  81. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  82. package/dist/data/hooks/useAnnotationList.js +0 -26
  83. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  84. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  85. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  86. package/dist/hooks/useParseMeasurement.js +0 -14
  87. package/dist/utils/evaluateFormula.d.ts +0 -20
  88. package/dist/utils/evaluateFormula.js +0 -31
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Canvas, Circle, DashPathEffect, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
2
+ import { Canvas, Circle, DashPathEffect, Group, Line, Path, Rect, RoundedRect, Skia, } from '@shopify/react-native-skia';
3
3
  import { normalizeRect, placementOf } from './measurementGeometry.js';
4
4
  import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeometry.js';
5
5
  import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
6
6
  import { visualShapeBounds } from './shapeGeometry.js';
7
- import { backgroundLayerDocRect, backgroundLayersOf, isBackgroundLayerLocked, } from './backgroundLayers.js';
7
+ import { backgroundCropBars, backgroundLayerDocRect, backgroundLayersOf, isBackgroundLayerLocked, } from './backgroundLayers.js';
8
+ import { YellowColors } from '../../theme/colors.js';
8
9
  import { BackgroundLayerElement } from './elements/BackgroundImageElement.js';
9
10
  import { PlaneElement, PLANE_CHROME_COLOR, PLANE_GRID_COLOR, PLANE_GRID_OPACITY, PLANE_GRID_WIDTH, PLANE_OUTLINE_WIDTH, } from './elements/PlaneElement.js';
10
11
  import { ShapeElement } from './elements/ShapeElement.js';
@@ -29,12 +30,45 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
29
30
  // hit-test the text resize handle, which sits on the padded box corner.
30
31
  const SELECTION_COLOR = '#0066FF';
31
32
  const SELECTION_STROKE = 1.5;
33
+ // Snap ring (magnetic endpoint snapping, see snapGeometry.ts): drawn around
34
+ // the target the dragged endpoint is currently locked onto. Orange so it reads
35
+ // against both the blue selection chrome and the yellow lock accent.
36
+ const SNAP_INDICATOR_COLOR = '#FF7A00';
37
+ // Outline of a selected LOCKED background layer: the lock accent (the same
38
+ // yellow the selected group-header border uses), distinct from the blue
39
+ // manipulable-selection chrome — locked means "selected, but pinned".
40
+ const BACKGROUND_LOCKED_COLOR = YellowColors.mainYellow;
41
+ // Link badge on a LINKED background layer (layer.fileId — an expanded file
42
+ // tile, see fileTileSwap.ts): a chain glyph in a white disc at the layer's
43
+ // top-right, saying "this is a group file's image, not a plain background".
44
+ // Always on (selected or not) so the link is legible at a glance — which
45
+ // makes it the one piece of chrome selection-clearing does NOT strip before a
46
+ // capture: hosts must pass `hideLinkBadges` while snapshotting for
47
+ // thumbnails/share/export, or the badge bakes into the output image.
48
+ // Doc-space sized off the visible rect, like the selection stroke and bars.
49
+ const LINKED_BADGE_BG = '#FFFFFF';
50
+ const LINKED_BADGE_INK = '#181A1C';
51
+ const LINKED_BADGE_RADIUS_RATIO = 0.05;
52
+ // Lucide `link` (24-unit viewBox, stroke-width 2) — drawn stroked inside a
53
+ // scale-to-badge Group, so the stroke width scales with the glyph.
54
+ const LINKED_GLYPH_SVG = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' +
55
+ 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
56
+ // Lazy + cached: on web this module is only evaluated after CanvasKit loads
57
+ // (WithSkiaWeb gates the import), but deferring to first draw costs nothing
58
+ // and can't regress if that ever changes.
59
+ let linkedGlyphPath = null;
60
+ const getLinkedGlyphPath = () => (linkedGlyphPath ?? (linkedGlyphPath = Skia.Path.MakeFromSVGString(LINKED_GLYPH_SVG)));
32
61
  // A draggable selection handle (line endpoints, rect corners, text resize):
33
62
  // a white disc with a colored ring, so it reads as a distinct, grabbable knob
34
63
  // over a line of ANY color — including the default measurement-line blue, which
35
64
  // a solid-colored handle would camouflage into. `c`/`r`/`ringWidth` may be
36
65
  // Reanimated values (native live-drag) or plain numbers (web).
37
66
  const Handle = ({ c, r, ringWidth, color = MEASUREMENT_HANDLE_COLOR, }) => (_jsxs(_Fragment, { children: [_jsx(Circle, { c: c, r: r, color: "#FFFFFF" }), _jsx(Circle, { c: c, r: r, color: color, style: "stroke", strokeWidth: ringWidth })] }));
67
+ // The snap indicator: a stroke-only ring (NOT a Handle — its white disc would
68
+ // hide the very pixel being snapped to). Screen-constant like the handles;
69
+ // `opacity` lets the native UI-thread path show/hide it without remounting
70
+ // (a zero radius still paints a strokeWidth-sized dot, so radius can't gate it).
71
+ const SnapRing = ({ c, r, ringWidth, opacity = 1, }) => (_jsx(Circle, { c: c, r: r, color: SNAP_INDICATOR_COLOR, style: "stroke", strokeWidth: ringWidth, opacity: opacity }));
38
72
  // Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
39
73
  const strokeBounds = (points) => {
40
74
  if (points.length < 2)
@@ -80,7 +114,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
80
114
  // since the function-call pattern works identically on native we use it
81
115
  // in both Inners for consistency. Don't add hooks here; this is a plain
82
116
  // JSX-returning helper, not a component.
83
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, measurementLineScale = 1, customPreview, wrapContent, canvasRef, }) => {
117
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, snapIndicator, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, measurementLineScale = 1, customPreview, wrapContent, canvasRef, }) => {
84
118
  // Doc rect a SCALE-mode plane grid covers: the first background layer (the
85
119
  // photo being calibrated), else the document rect. Pinned to layer 0 like
86
120
  // the tile-sizing reference — adding a second image must not re-span the
@@ -94,7 +128,39 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
94
128
  width: effectiveCanvas.viewport.width,
95
129
  height: effectiveCanvas.viewport.height,
96
130
  };
97
- const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundContent, backgroundLayersOf(effectiveCanvas.viewport).map((layer) => (_jsx(DraggableElement, { isDragging: layer.id === draggingId || layer.id === resizingId, transform: layer.id === resizingId ? resizeTransform : dragTransform, children: _jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, deferToResolver: deferBackgroundToResolver, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height }) }, layer.id))), (effectiveCanvas.planes ?? []).map((plane) => {
131
+ const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundContent, backgroundLayersOf(effectiveCanvas.viewport).map((layer) => {
132
+ // Native crop-bar drag: clip to the LIVE visible rect (doc space)
133
+ // instead of the committed crop window, which is suppressed so a bar
134
+ // dragged outward reveals what the window hid.
135
+ const isCropDrag = layer.id === bgCropId && liveBgCropClip != null;
136
+ const element = (_jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, deferToResolver: deferBackgroundToResolver, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height, suppressCrop: isCropDrag }));
137
+ // Link badge on a LINKED layer (an expanded file tile) — always on,
138
+ // selected or not, locked or not: the link is a fact about the layer,
139
+ // not an affordance. A sibling of the (possibly live-clipped) image
140
+ // inside the same DraggableElement, so it rides drags and resizes;
141
+ // hidden during a live crop drag (it would sit on stale committed
142
+ // edges) and under `hideLinkBadges` (capture/export renders).
143
+ const badge = layer.fileId && !isCropDrag && !hideLinkBadges
144
+ ? (() => {
145
+ const glyph = getLinkedGlyphPath();
146
+ if (!glyph)
147
+ return null;
148
+ const r = backgroundLayerDocRect(layer);
149
+ const badgeR = Math.min(r.width, r.height) * LINKED_BADGE_RADIUS_RATIO;
150
+ const bc = {
151
+ x: r.x + r.width - 2 * badgeR,
152
+ y: r.y + 2 * badgeR,
153
+ };
154
+ const glyphSize = badgeR * 1.2;
155
+ return (_jsxs(_Fragment, { children: [_jsx(Circle, { c: bc, r: badgeR, color: LINKED_BADGE_BG }), _jsx(Group, { transform: [
156
+ { translateX: bc.x - glyphSize / 2 },
157
+ { translateY: bc.y - glyphSize / 2 },
158
+ { scale: glyphSize / 24 },
159
+ ], children: _jsx(Path, { path: glyph, color: LINKED_BADGE_INK, style: "stroke", strokeWidth: 2, strokeCap: "round", strokeJoin: "round" }) })] }));
160
+ })()
161
+ : null;
162
+ return (_jsxs(DraggableElement, { isDragging: layer.id === draggingId || layer.id === resizingId, transform: layer.id === resizingId ? resizeTransform : dragTransform, children: [isCropDrag ? (_jsx(Group, { clip: liveBgCropClip, children: element })) : (element), badge] }, layer.id));
163
+ }), (effectiveCanvas.planes ?? []).map((plane) => {
98
164
  if (plane.id === planeDragId && planeDragPath) {
99
165
  const statics = plane.mode === 'perspective'
100
166
  ? plane.corners
@@ -247,23 +313,42 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
247
313
  shape.kind === 'polygon';
248
314
  return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isLive, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isLive, transform: liveTransform, children: _jsx(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) })), cornerResizable && handleRadius != null && (_jsxs(DraggableElement, { isDragging: isDragging, transform: dragTransform, children: [_jsx(Handle, { c: { x: b.minX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.minX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR })] }))] }));
249
315
  }
250
- // A selected background layer: outline its doc rect and offer ONE
251
- // bottom-right handle — backgrounds scale uniformly about their
252
- // top-left corner (the transform origin), so a single opposite-corner
253
- // grab is the whole gizmo. Chrome tracks the live drag/resize
254
- // transform the same way the layer itself does above. A LOCKED layer
255
- // (backgroundLayerIsLocked) keeps the outline — it is selected, and
256
- // the host's chrome offers the lock toggle — but gets no handle: it
257
- // cannot scale, so a grabbable corner would lie.
316
+ // A selected background layer: outline its VISIBLE (cropped) doc rect
317
+ // and, when unlocked, offer FOUR corner handles (each scales about
318
+ // its opposite corner) plus four edge crop bars (raster layers only —
319
+ // dragging a bar windows the image non-destructively). Chrome tracks
320
+ // the live drag/resize transform the same way the layer itself does
321
+ // above. A LOCKED layer (backgroundLayerIsLocked) keeps the outline —
322
+ // it is selected, and the host's chrome offers the lock toggle — but
323
+ // recolored to the lock yellow and with no handles: it cannot scale
324
+ // or crop, so a grabbable gizmo would lie. During a native crop-bar
325
+ // drag the chrome is just the LIVE outline — handles and bars would
326
+ // sit on stale committed edges.
258
327
  const layer = backgroundLayersOf(effectiveCanvas.viewport).find((l) => l.id === selectedId);
259
328
  if (layer) {
329
+ if (layer.id === bgCropId && liveBgCrop) {
330
+ return (_jsx(Rect, { x: liveBgCrop.x, y: liveBgCrop.y, width: liveBgCrop.width, height: liveBgCrop.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }));
331
+ }
260
332
  const locked = isBackgroundLayerLocked(effectiveCanvas.viewport, layer.id);
261
333
  const r = backgroundLayerDocRect(layer);
262
334
  const isResizing = layer.id === resizingId;
263
335
  const liveTransform = isResizing ? resizeTransform : dragTransform;
264
- return (_jsxs(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: [_jsx(Rect, { x: r.x, y: r.y, width: r.width, height: r.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }), handleRadius != null && !locked && (_jsx(Handle, { c: { x: r.x + r.width, y: r.y + r.height }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }))] }));
336
+ const corners = [
337
+ { x: r.x, y: r.y },
338
+ { x: r.x + r.width, y: r.y },
339
+ { x: r.x, y: r.y + r.height },
340
+ { x: r.x + r.width, y: r.y + r.height },
341
+ ];
342
+ return (_jsxs(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: [_jsx(Rect, { x: r.x, y: r.y, width: r.width, height: r.height, color: locked ? BACKGROUND_LOCKED_COLOR : SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }), !locked &&
343
+ layer.format !== 'svg' &&
344
+ backgroundCropBars(layer).map((bar) => {
345
+ const inset = Math.min(bar.width, bar.height) * 0.18;
346
+ return (_jsxs(Group, { children: [_jsx(RoundedRect, { x: bar.x, y: bar.y, width: bar.width, height: bar.height, r: Math.min(bar.width, bar.height) / 2, color: "#FFFFFF" }), _jsx(RoundedRect, { x: bar.x + inset, y: bar.y + inset, width: bar.width - 2 * inset, height: bar.height - 2 * inset, r: (Math.min(bar.width, bar.height) - 2 * inset) / 2, color: SELECTION_COLOR })] }, bar.edge));
347
+ }), handleRadius != null &&
348
+ !locked &&
349
+ corners.map((c, i) => (_jsx(Handle, { c: c, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }, i)))] }));
265
350
  }
266
351
  return null;
267
- })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) }, i))), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }));
352
+ })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) }, i))), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), snapIndicator && (_jsx(SnapRing, { c: snapIndicator.c, r: snapIndicator.r, ringWidth: snapIndicator.ringWidth, opacity: snapIndicator.opacity })), customPreview] }));
268
353
  return (_jsx(Canvas, { ref: canvasRef, style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
269
354
  };
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode, ComponentType } from 'react';
2
2
  import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, AnnotationElementId, Selection, StrokeCap, Vec2 } from '../../types/annotation.js';
3
+ import type { BackgroundCropEdge } from './backgroundLayers.js';
3
4
  import type { MeasurementRef } from './measurementPicker.js';
4
5
  import type { RectCorner } from './measurementGeometry.js';
5
6
  import type { ShapeToolKind } from './shapeGeometry.js';
@@ -18,6 +19,11 @@ export interface CanvasPointerEvent {
18
19
  metaKey?: boolean;
19
20
  ctrlKey?: boolean;
20
21
  }
22
+ export interface SnapContext {
23
+ enabled: boolean;
24
+ radiusPx: number;
25
+ setIndicator(point: Vec2 | null): void;
26
+ }
21
27
  export interface ToolContext {
22
28
  document: AnnotationCanvasState;
23
29
  selection: Selection | null;
@@ -26,6 +32,7 @@ export interface ToolContext {
26
32
  tileScaleFactor: number;
27
33
  headerTileScaleFactor: number;
28
34
  fileTileScaleFactor: number;
35
+ snap?: SnapContext | null;
29
36
  preview(patch: AnnotationDocumentPatch): void;
30
37
  commit(patch: AnnotationDocumentPatch): void;
31
38
  setSelection(selection: Selection | null): void;
@@ -90,7 +97,9 @@ export interface DragSelectionConfig {
90
97
  locked: boolean;
91
98
  } | null;
92
99
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
93
- buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
100
+ buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, geom?: ResizeGeometry): AnnotationDocumentPatch | null;
101
+ hitTestBackgroundCropBar?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): BackgroundCropEdge | null;
102
+ buildBackgroundCropPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, edge: BackgroundCropEdge, delta: Vec2): AnnotationDocumentPatch | null;
94
103
  }
95
104
  export interface PlaneEditConfig {
96
105
  hitHandle(doc: AnnotationCanvasState, world: Vec2, zoom: number): number | null;
@@ -1,16 +1,35 @@
1
- import type { AnnotationBackgroundLayer, AnnotationViewport, BackgroundLayerTransform } from '../../types/annotation.js';
1
+ import type { AnnotationBackgroundLayer, AnnotationViewport, BackgroundLayerCrop, BackgroundLayerTransform } from '../../types/annotation.js';
2
2
  export declare const LEGACY_BACKGROUND_LAYER_ID = "background-legacy";
3
3
  export declare const BACKGROUND_PLACEMENT_GAP_RATIO = 0.02;
4
4
  export declare const backgroundLayersOf: (viewport: AnnotationViewport) => AnnotationBackgroundLayer[];
5
5
  export declare const backgroundLayerIsLocked: (layer: AnnotationBackgroundLayer, index: number) => boolean;
6
6
  export declare const isBackgroundLayerLocked: (viewport: AnnotationViewport, id: string) => boolean;
7
7
  export declare const defaultBackgroundLayerLocked: (existing: AnnotationBackgroundLayer[]) => boolean;
8
+ export declare const backgroundLayerCropRect: (layer: AnnotationBackgroundLayer) => BackgroundLayerCrop;
8
9
  export declare const backgroundLayerDocRect: (layer: AnnotationBackgroundLayer) => {
9
10
  x: number;
10
11
  y: number;
11
12
  width: number;
12
13
  height: number;
13
14
  };
15
+ export declare const backgroundLayerFullDocRect: (layer: AnnotationBackgroundLayer) => {
16
+ x: number;
17
+ y: number;
18
+ width: number;
19
+ height: number;
20
+ };
21
+ export declare const MIN_BACKGROUND_DOC_EXTENT = 16;
22
+ export declare const MAX_BACKGROUND_DOC_EXTENT = 65536;
23
+ export type BackgroundCropEdge = 'top' | 'bottom' | 'left' | 'right';
24
+ export declare const BACKGROUND_CROP_EDGES: readonly BackgroundCropEdge[];
25
+ export interface BackgroundCropBar {
26
+ edge: BackgroundCropEdge;
27
+ x: number;
28
+ y: number;
29
+ width: number;
30
+ height: number;
31
+ }
32
+ export declare const backgroundCropBars: (layer: AnnotationBackgroundLayer) => BackgroundCropBar[];
14
33
  export declare const backgroundLayersUnionRect: (layers: AnnotationBackgroundLayer[]) => {
15
34
  x: number;
16
35
  y: number;
@@ -69,14 +69,105 @@ export const isBackgroundLayerLocked = (viewport, id) => {
69
69
  // against it. Written explicitly onto the new layer so the state survives
70
70
  // later removals — a second image stays free even once it is the only one.
71
71
  export const defaultBackgroundLayerLocked = (existing) => existing.length === 0;
72
- // The doc-space rectangle a layer occupies: the intrinsic pixel grid under
73
- // the layer's translate+scale.
74
- export const backgroundLayerDocRect = (layer) => ({
72
+ // The layer's crop window in intrinsic pixels, clamped defensively into the
73
+ // image bounds (a hand-edited or future-client doc must not push the visible
74
+ // rect outside the bytes). Absent crop = the full image.
75
+ export const backgroundLayerCropRect = (layer) => {
76
+ const c = layer.crop;
77
+ if (!c)
78
+ return { x: 0, y: 0, width: layer.widthPx, height: layer.heightPx };
79
+ const x = Math.min(Math.max(c.x, 0), layer.widthPx);
80
+ const y = Math.min(Math.max(c.y, 0), layer.heightPx);
81
+ return {
82
+ x,
83
+ y,
84
+ width: Math.min(Math.max(c.width, 0), layer.widthPx - x),
85
+ height: Math.min(Math.max(c.height, 0), layer.heightPx - y),
86
+ };
87
+ };
88
+ // The doc-space rectangle a layer occupies: its VISIBLE (crop-windowed)
89
+ // pixel grid under the layer's translate+scale. Everything that treats "the
90
+ // layer" as a rect — selection chrome, hit-tests, fit/union math, new-layer
91
+ // placement — reads this, so a crop moves all of them together; only the
92
+ // renderer additionally knows the full image extends past it (clipped, see
93
+ // BackgroundLayerElement).
94
+ export const backgroundLayerDocRect = (layer) => {
95
+ const c = backgroundLayerCropRect(layer);
96
+ return {
97
+ x: layer.transform.x + c.x * layer.transform.scaleX,
98
+ y: layer.transform.y + c.y * layer.transform.scaleY,
99
+ width: c.width * layer.transform.scaleX,
100
+ height: c.height * layer.transform.scaleY,
101
+ };
102
+ };
103
+ // The full (uncropped) image's doc rect — the bounds a crop edge can expand
104
+ // back out to.
105
+ export const backgroundLayerFullDocRect = (layer) => ({
75
106
  x: layer.transform.x,
76
107
  y: layer.transform.y,
77
108
  width: layer.widthPx * layer.transform.scaleX,
78
109
  height: layer.heightPx * layer.transform.scaleY,
79
110
  });
111
+ // Doc-space clamp on a background layer's larger doc dimension (corner
112
+ // resize) and on its visible extent (crop). The floor keeps a drag from
113
+ // collapsing a layer below grabbable size; the ceiling keeps the doc rect
114
+ // (which feeds fit math and the web SVG raster planner) from exploding to
115
+ // numerically silly sizes. Here rather than in selectTool because the native
116
+ // crop preview worklet needs the floor too.
117
+ export const MIN_BACKGROUND_DOC_EXTENT = 16;
118
+ export const MAX_BACKGROUND_DOC_EXTENT = 65536;
119
+ export const BACKGROUND_CROP_EDGES = [
120
+ 'top',
121
+ 'bottom',
122
+ 'left',
123
+ 'right',
124
+ ];
125
+ // Bar proportions relative to the visible rect. Doc-space sizing, like the
126
+ // selection box stroke — the bars belong to the rect they crop, so they zoom
127
+ // with it; grab tolerance stays screen-constant in the hit-test instead.
128
+ const CROP_BAR_LENGTH_RATIO = 0.15;
129
+ const CROP_BAR_THICKNESS_RATIO = 0.22;
130
+ // The four crop bars for a layer's visible rect: pill-shaped grabs centered on
131
+ // each edge midpoint, long side along the edge.
132
+ export const backgroundCropBars = (layer) => {
133
+ const r = backgroundLayerDocRect(layer);
134
+ const midX = r.x + r.width / 2;
135
+ const midY = r.y + r.height / 2;
136
+ const hLen = r.width * CROP_BAR_LENGTH_RATIO;
137
+ const hThick = hLen * CROP_BAR_THICKNESS_RATIO;
138
+ const vLen = r.height * CROP_BAR_LENGTH_RATIO;
139
+ const vThick = vLen * CROP_BAR_THICKNESS_RATIO;
140
+ return [
141
+ {
142
+ edge: 'top',
143
+ x: midX - hLen / 2,
144
+ y: r.y - hThick / 2,
145
+ width: hLen,
146
+ height: hThick,
147
+ },
148
+ {
149
+ edge: 'bottom',
150
+ x: midX - hLen / 2,
151
+ y: r.y + r.height - hThick / 2,
152
+ width: hLen,
153
+ height: hThick,
154
+ },
155
+ {
156
+ edge: 'left',
157
+ x: r.x - vThick / 2,
158
+ y: midY - vLen / 2,
159
+ width: vThick,
160
+ height: vLen,
161
+ },
162
+ {
163
+ edge: 'right',
164
+ x: r.x + r.width - vThick / 2,
165
+ y: midY - vLen / 2,
166
+ width: vThick,
167
+ height: vLen,
168
+ },
169
+ ];
170
+ };
80
171
  // Bounding box of every layer's doc rect. Null when there are no layers —
81
172
  // callers fall back to the document rect.
82
173
  export const backgroundLayersUnionRect = (layers) => {
@@ -17,5 +17,6 @@ export interface BackgroundLayerElementProps {
17
17
  viewport?: ViewportState;
18
18
  screenWidth?: number;
19
19
  screenHeight?: number;
20
+ suppressCrop?: boolean;
20
21
  }
21
- export declare const BackgroundLayerElement: import("react").MemoExoticComponent<({ layer, resolveUrl, deferToResolver, skImage: skImageProp, viewport, screenWidth, screenHeight, }: BackgroundLayerElementProps) => import("react/jsx-runtime").JSX.Element | null>;
22
+ export declare const BackgroundLayerElement: import("react").MemoExoticComponent<({ layer, resolveUrl, deferToResolver, skImage: skImageProp, viewport, screenWidth, screenHeight, suppressCrop, }: BackgroundLayerElementProps) => import("react/jsx-runtime").JSX.Element | null>;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Group, Image, useImage, } from '@shopify/react-native-skia';
2
+ import { Group, Image, rect, useImage, } from '@shopify/react-native-skia';
3
3
  import { memo, useEffect, useRef } from 'react';
4
4
  import { backgroundLayerDocRect } from '../backgroundLayers.js';
5
5
  import { useBackgroundUrl } from './backgroundUrl.js';
@@ -38,7 +38,7 @@ export const BackgroundSkImageLoader = ({ layer, resolveUrl, deferToResolver, on
38
38
  // A component (not inlined into the scene helper) because each layer owns
39
39
  // per-image hooks — URL resolution and raster decode — and AnnotationCanvasSkia
40
40
  // is a hook-free plain function.
41
- export const BackgroundLayerElement = memo(({ layer, resolveUrl, deferToResolver, skImage: skImageProp, viewport, screenWidth, screenHeight, }) => {
41
+ export const BackgroundLayerElement = memo(({ layer, resolveUrl, deferToResolver, skImage: skImageProp, viewport, screenWidth, screenHeight, suppressCrop, }) => {
42
42
  const isSvg = layer.format === 'svg';
43
43
  // Self-load only when no pre-decoded image is provided; the hook still runs
44
44
  // unconditionally (with a null source, so it no-ops) to satisfy hook rules.
@@ -60,6 +60,14 @@ export const BackgroundLayerElement = memo(({ layer, resolveUrl, deferToResolver
60
60
  }
61
61
  if (!content)
62
62
  return null;
63
+ // Committed crop window: clip in the layer's intrinsic pixel space (inside
64
+ // the transform, so it scales with the image). Non-destructive — the full
65
+ // image is drawn, the clip just windows it. See suppressCrop for the one
66
+ // case that skips it.
67
+ const crop = !suppressCrop && layer.crop ? layer.crop : null;
68
+ if (crop) {
69
+ content = (_jsx(Group, { clip: rect(crop.x, crop.y, crop.width, crop.height), children: content }));
70
+ }
63
71
  return (_jsx(Group, { transform: [
64
72
  { translateX: t.x },
65
73
  { translateY: t.y },
@@ -0,0 +1,12 @@
1
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId } from '../../types/annotation.js';
2
+ import type { FileUpload } from '../../types/firestore.js';
3
+ export interface FileTileImageSource {
4
+ storagePath: string;
5
+ downloadUrl: string;
6
+ widthPx: number;
7
+ heightPx: number;
8
+ format?: 'raster' | 'svg';
9
+ }
10
+ export declare const fileTileImageSource: (file: Pick<FileUpload, "type" | "fileData"> | null | undefined) => FileTileImageSource | null;
11
+ export declare const expandFileTileToBackground: (doc: AnnotationCanvasState, placedId: AnnotationElementId, layerId: AnnotationElementId, image: FileTileImageSource) => AnnotationDocumentPatch | null;
12
+ export declare const collapseBackgroundToFileTile: (doc: AnnotationCanvasState, layerId: AnnotationElementId, tileId: AnnotationElementId, now?: number) => AnnotationDocumentPatch | null;
@@ -0,0 +1,87 @@
1
+ import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
2
+ import { backgroundLayerDocRect, backgroundLayersOf, } from './backgroundLayers.js';
3
+ import { imageDocRect } from './viewport.js';
4
+ export const fileTileImageSource = (file) => {
5
+ if (!file)
6
+ return null;
7
+ // Structural, not type-switched: Canvas, Annotation and Template docs all
8
+ // carry AnnotationFileData; anything else (Label, Note, Calculator, …) has
9
+ // no `canvas` key and falls out here.
10
+ const data = file.fileData;
11
+ if (!data?.canvas || data.isLabel)
12
+ return null;
13
+ if (data.canvas.viewport.pdfDocument)
14
+ return null;
15
+ const raster = backgroundLayersOf(data.canvas.viewport).find((l) => l.format !== 'svg' && l.storagePath && l.widthPx > 0);
16
+ if (!raster)
17
+ return null;
18
+ return {
19
+ storagePath: raster.storagePath,
20
+ downloadUrl: raster.downloadUrl,
21
+ widthPx: raster.widthPx,
22
+ heightPx: raster.heightPx,
23
+ ...(raster.format ? { format: raster.format } : {}),
24
+ };
25
+ };
26
+ // Patch that replaces the object tile `placedId` with a background layer
27
+ // showing `image`: the image contain-fits the document rect (the size a
28
+ // first-placed background gets) and centers on the tile's anchor, so it
29
+ // appears where the tile was. The layer starts FREE — the point of expanding
30
+ // is to arrange the photo, and the pill's lock is one tap away. Null when the
31
+ // tile is missing or isn't an object tile.
32
+ export const expandFileTileToBackground = (doc, placedId, layerId, image) => {
33
+ const placed = doc.placedMeasurements.find((m) => m.id === placedId);
34
+ if (!placed?.fileId)
35
+ return null;
36
+ const fitted = imageDocRect(image.widthPx, image.heightPx, doc.viewport.width, doc.viewport.height, 'contain');
37
+ const layer = {
38
+ id: layerId,
39
+ storagePath: image.storagePath,
40
+ downloadUrl: image.downloadUrl,
41
+ widthPx: image.widthPx,
42
+ heightPx: image.heightPx,
43
+ ...(image.format ? { format: image.format } : {}),
44
+ transform: {
45
+ x: placed.anchor.x - fitted.width / 2,
46
+ y: placed.anchor.y - fitted.height / 2,
47
+ scaleX: image.widthPx > 0 ? fitted.width / image.widthPx : 1,
48
+ scaleY: image.heightPx > 0 ? fitted.height / image.heightPx : 1,
49
+ },
50
+ locked: false,
51
+ fileId: placed.fileId,
52
+ ...(placed.filePath ? { filePath: placed.filePath } : {}),
53
+ ...(placed.groupId ? { fileGroupId: placed.groupId } : {}),
54
+ };
55
+ return {
56
+ ops: [
57
+ { op: 'removeMeasurement', id: placedId },
58
+ { op: 'addBackgroundImage', image: layer },
59
+ ],
60
+ };
61
+ };
62
+ // Patch that replaces the linked background layer `layerId` with an object
63
+ // tile at the layer's visible center — the inverse switch. The tile's fields
64
+ // come back off the layer (see the fileId trio on AnnotationBackgroundLayer),
65
+ // at the default tile scale; the crop window, if any, is simply dropped (a
66
+ // tile is a thumbnail, not a window). Null for a layer that isn't linked.
67
+ export const collapseBackgroundToFileTile = (doc, layerId, tileId, now = Date.now()) => {
68
+ const layer = backgroundLayersOf(doc.viewport).find((l) => l.id === layerId);
69
+ if (!layer?.fileId)
70
+ return null;
71
+ const r = backgroundLayerDocRect(layer);
72
+ const tile = {
73
+ id: tileId,
74
+ layerId: doc.layers[0]?.id ?? DEFAULT_LAYER_ID,
75
+ fileId: layer.fileId,
76
+ ...(layer.filePath ? { filePath: layer.filePath } : {}),
77
+ ...(layer.fileGroupId ? { groupId: layer.fileGroupId } : {}),
78
+ anchor: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
79
+ createdAt: now,
80
+ };
81
+ return {
82
+ ops: [
83
+ { op: 'removeBackgroundImage', id: layerId },
84
+ { op: 'addMeasurement', measurement: tile },
85
+ ],
86
+ };
87
+ };
@@ -0,0 +1,32 @@
1
+ import type { AnnotationCanvasState, AnnotationElementId, Vec2 } from '../../types/annotation.js';
2
+ export declare const SNAP_RADIUS_PX = 16;
3
+ export declare const SNAP_RING_PX = 11;
4
+ export declare const SNAP_RING_WIDTH_PX = 2;
5
+ export declare const SNAP_KIND_POINT = 0;
6
+ export declare const SNAP_KIND_MIDPOINT = 1;
7
+ export type SnapKind = 0 | 1;
8
+ export declare const SNAP_STRIDE = 3;
9
+ export interface SnapTarget {
10
+ x: number;
11
+ y: number;
12
+ kind: SnapKind;
13
+ sourceId: AnnotationElementId;
14
+ }
15
+ export interface SnapHit {
16
+ x: number;
17
+ y: number;
18
+ kind: SnapKind;
19
+ index: number;
20
+ }
21
+ export declare const collectSnapTargets: (doc: AnnotationCanvasState, excludeId?: AnnotationElementId | null) => SnapTarget[];
22
+ export declare const encodeSnapTargets: (targets: readonly SnapTarget[]) => number[];
23
+ export declare const collectSnapTargetsFlat: (doc: AnnotationCanvasState, excludeId?: AnnotationElementId | null) => number[];
24
+ export declare const snapNearest: (x: number, y: number, flat: readonly number[], radiusDoc: number) => SnapHit | null;
25
+ export declare const snapDelta: (base: Vec2, delta: Vec2, flat: readonly number[], radiusDoc: number) => {
26
+ delta: Vec2;
27
+ hit: SnapHit | null;
28
+ };
29
+ export declare const snapPoint: (p: Vec2, flat: readonly number[], radiusDoc: number) => {
30
+ point: Vec2;
31
+ hit: SnapHit | null;
32
+ };