@reekon-tools/boldr-utils 1.19.5 → 1.19.6

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.
@@ -21,6 +21,7 @@ export interface AnnotationCanvasInnerProps {
21
21
  decimalTolerance?: DecimalTolerance;
22
22
  resolveImageUrl?: (storagePath: string) => Promise<string>;
23
23
  deferBackgroundToResolver?: boolean;
24
+ hideLinkBadges?: boolean;
24
25
  pickMeasurement?: () => Promise<MeasurementRef | null>;
25
26
  requestTextInput?: RequestTextInput;
26
27
  renderBackground?: RenderCanvasBackground;
@@ -27,7 +27,7 @@ const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
27
27
  // (SSR / the vitest environment), where 1 is the right answer.
28
28
  const devicePixelRatioOf = () => typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1;
29
29
  export const AnnotationCanvasInner = (props) => {
30
- const { resolveImageUrl, deferBackgroundToResolver, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, snapshotRef, renderBackground, } = props;
30
+ const { resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, snapshotRef, renderBackground, } = props;
31
31
  // Snapshot plumbing for thumbnail capture: hold the <Canvas> ref and publish
32
32
  // a capture fn on the consumer's snapshotRef. encodeToBytes() yields PNG.
33
33
  const canvasRef = useCanvasRef();
@@ -316,6 +316,7 @@ export const AnnotationCanvasInner = (props) => {
316
316
  viewport: state.viewport,
317
317
  resolveImageUrl,
318
318
  deferBackgroundToResolver,
319
+ hideLinkBadges,
319
320
  backgroundContent,
320
321
  canvasRef,
321
322
  valueFont,
@@ -22,6 +22,7 @@ export interface AnnotationCanvasInnerProps {
22
22
  decimalTolerance?: DecimalTolerance;
23
23
  resolveImageUrl?: (storagePath: string) => Promise<string>;
24
24
  deferBackgroundToResolver?: boolean;
25
+ hideLinkBadges?: boolean;
25
26
  pickMeasurement?: () => Promise<MeasurementRef | null>;
26
27
  requestTextInput?: RequestTextInput;
27
28
  renderBackground?: RenderCanvasBackground;
@@ -7,7 +7,7 @@ import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useShare
7
7
  import { stampTileDims } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
- import { backgroundLayersOf, backgroundLayerDocRect, } from './backgroundLayers.js';
10
+ import { MIN_BACKGROUND_DOC_EXTENT, backgroundLayersOf, backgroundLayerDocRect, backgroundLayerFullDocRect, } from './backgroundLayers.js';
11
11
  import { BackgroundSkImageLoader } from './elements/BackgroundImageElement.js';
12
12
  import { buildRemoveMeasurementOps, linePosOf, } from './measurementGeometry.js';
13
13
  import { planeEdgeLine, planeGridSegments, planeGridSegmentsForScale, resolvePlaneGridSpacingUm, } from './planeGeometry.js';
@@ -64,7 +64,7 @@ const LOUPE_CROSS_WIDTH_PX = 1.5;
64
64
  // is only re-synced once each gesture ends. The tool/tap path still hops to
65
65
  // JS (`runOnJS(true)`) since drawing/selection are React-state operations.
66
66
  export const AnnotationCanvasInner = (props) => {
67
- const { resolveImageUrl, deferBackgroundToResolver, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, renderBackground, } = props;
67
+ const { resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, renderBackground, } = props;
68
68
  const valueFont = useFont(stampFontSource, stampValueFontSize);
69
69
  // The animated-viewport implementation needs the Reanimated shared values,
70
70
  // which are created below AFTER the state hook — so the hook gets a stable
@@ -661,6 +661,9 @@ export const AnnotationCanvasInner = (props) => {
661
661
  // goes through buildResizePatch, which clamps identically.
662
662
  const [resizingId, setResizingId] = useState(null);
663
663
  const resizeCtx = useSharedValue({ px: 0, py: 0, hx: 0, hy: 0, minS: 1, maxS: 1 });
664
+ // The grab-start geometry rides along to the commit: a background offers
665
+ // four corner handles, each scaling about its opposite corner, so the patch
666
+ // must reuse whichever geometry the grab's hit-test chose.
664
667
  const resizeTargetRef = useRef(null);
665
668
  const resizeTransform = useDerivedValue(() => {
666
669
  'worklet';
@@ -684,6 +687,60 @@ export const AnnotationCanvasInner = (props) => {
684
687
  { translateY: -c.py },
685
688
  ];
686
689
  });
690
+ // Background crop-bar drag. `bgCropId` (React state) gates which layer
691
+ // clips to the live rect; `bgCropCtx` carries the grabbed edge (0 top ·
692
+ // 1 bottom · 2 left · 3 right), the visible doc rect at grab-start and the
693
+ // full (uncropped) image's doc rect it may expand back out to — so the
694
+ // preview is a pure UI-thread rect rebuild driven by dragX/dragY. The
695
+ // commit on release goes through buildBackgroundCropPatch, which clamps
696
+ // identically.
697
+ const [bgCropId, setBgCropId] = useState(null);
698
+ const bgCropTargetRef = useRef(null);
699
+ const bgCropCtx = useSharedValue({ edge: 0, x: 0, y: 0, w: 0, h: 0, bx: 0, by: 0, bw: 0, bh: 0 });
700
+ const liveBgCropRect = useDerivedValue(() => {
701
+ 'worklet';
702
+ // WORKLET TWIN of selectTool's cropDragVisibleRect — keep in sync.
703
+ const c = bgCropCtx.value;
704
+ let x = c.x;
705
+ let y = c.y;
706
+ let w = c.w;
707
+ let h = c.h;
708
+ if (c.edge === 0) {
709
+ const newY = Math.min(Math.max(c.y + dragY.value, c.by), c.y + c.h - MIN_BACKGROUND_DOC_EXTENT);
710
+ h = c.y + c.h - newY;
711
+ y = newY;
712
+ }
713
+ else if (c.edge === 1) {
714
+ h = Math.min(Math.max(c.h + dragY.value, MIN_BACKGROUND_DOC_EXTENT), c.by + c.bh - c.y);
715
+ }
716
+ else if (c.edge === 2) {
717
+ const newX = Math.min(Math.max(c.x + dragX.value, c.bx), c.x + c.w - MIN_BACKGROUND_DOC_EXTENT);
718
+ w = c.x + c.w - newX;
719
+ x = newX;
720
+ }
721
+ else {
722
+ w = Math.min(Math.max(c.w + dragX.value, MIN_BACKGROUND_DOC_EXTENT), c.bx + c.bw - c.x);
723
+ }
724
+ return { x, y, width: w, height: h };
725
+ });
726
+ // Per-component slices for the live outline (Skia props take one animated
727
+ // value each; the clip takes the whole rect).
728
+ const liveBgCropX = useDerivedValue(() => {
729
+ 'worklet';
730
+ return liveBgCropRect.value.x;
731
+ });
732
+ const liveBgCropY = useDerivedValue(() => {
733
+ 'worklet';
734
+ return liveBgCropRect.value.y;
735
+ });
736
+ const liveBgCropW = useDerivedValue(() => {
737
+ 'worklet';
738
+ return liveBgCropRect.value.width;
739
+ });
740
+ const liveBgCropH = useDerivedValue(() => {
741
+ 'worklet';
742
+ return liveBgCropRect.value.height;
743
+ });
687
744
  // Geometric-shape corner resize (rect/ellipse/polygon). `shapeResizeId`
688
745
  // (React state) gates which shape renders live; `shapeResizeCtx` carries the
689
746
  // fixed (opposite) corner and the grabbed corner's start position (from
@@ -1477,11 +1534,42 @@ export const AnnotationCanvasInner = (props) => {
1477
1534
  minS: resizeGeom.minScale,
1478
1535
  maxS: resizeGeom.maxScale,
1479
1536
  };
1480
- resizeTargetRef.current = { id: selId };
1537
+ resizeTargetRef.current = { id: selId, geom: resizeGeom };
1481
1538
  setResizingId(selId);
1482
1539
  setMagnifying(true);
1483
1540
  return;
1484
1541
  }
1542
+ // Crop bar on a selected, unlocked background layer — the edge
1543
+ // windows the image (non-destructively) instead of scaling it.
1544
+ const cropEdge = cfg.hitTestBackgroundCropBar?.(st.ctx.document, selId, world, zoomNow);
1545
+ if (cropEdge) {
1546
+ const cropLayer = backgroundLayersOf(st.ctx.document.viewport).find((l) => l.id === selId);
1547
+ if (cropLayer) {
1548
+ const visible = backgroundLayerDocRect(cropLayer);
1549
+ const full = backgroundLayerFullDocRect(cropLayer);
1550
+ bgCropCtx.value = {
1551
+ edge: cropEdge === 'top'
1552
+ ? 0
1553
+ : cropEdge === 'bottom'
1554
+ ? 1
1555
+ : cropEdge === 'left'
1556
+ ? 2
1557
+ : 3,
1558
+ x: visible.x,
1559
+ y: visible.y,
1560
+ w: visible.width,
1561
+ h: visible.height,
1562
+ bx: full.x,
1563
+ by: full.y,
1564
+ bw: full.width,
1565
+ bh: full.height,
1566
+ };
1567
+ bgCropTargetRef.current = { id: selId, edge: cropEdge };
1568
+ setBgCropId(selId);
1569
+ setMagnifying(true);
1570
+ return;
1571
+ }
1572
+ }
1485
1573
  // Rectangle-annotation corner handle — also selected-element-only.
1486
1574
  const rectCornerHit = cfg.hitTestRectCorner?.(st.ctx.document, selId, world, zoomNow);
1487
1575
  if (rectCornerHit) {
@@ -1580,10 +1668,7 @@ export const AnnotationCanvasInner = (props) => {
1580
1668
  const rT = resizeTargetRef.current;
1581
1669
  if (rT) {
1582
1670
  if (dx !== 0 || dy !== 0) {
1583
- const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, {
1584
- x: dx,
1585
- y: dy,
1586
- });
1671
+ const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, { x: dx, y: dy }, rT.geom);
1587
1672
  if (patch)
1588
1673
  st.ctx.commit(patch);
1589
1674
  }
@@ -1591,6 +1676,19 @@ export const AnnotationCanvasInner = (props) => {
1591
1676
  setResizingId(null);
1592
1677
  return;
1593
1678
  }
1679
+ // Crop commit: window the layer to the live rect's final edges
1680
+ // (clamped in buildBackgroundCropPatch exactly like the live preview).
1681
+ const cropT = bgCropTargetRef.current;
1682
+ if (cropT) {
1683
+ if (dx !== 0 || dy !== 0) {
1684
+ const patch = cfg.buildBackgroundCropPatch?.(st.ctx.document, cropT.id, cropT.edge, { x: dx, y: dy });
1685
+ if (patch)
1686
+ st.ctx.commit(patch);
1687
+ }
1688
+ bgCropTargetRef.current = null;
1689
+ setBgCropId(null);
1690
+ return;
1691
+ }
1594
1692
  // Rect-corner commit: drag the grabbed corner by the world delta
1595
1693
  // (opposite corner fixed); anchor re-centers in the same patch.
1596
1694
  const rectT = rectTargetRef.current;
@@ -1678,6 +1776,7 @@ export const AnnotationCanvasInner = (props) => {
1678
1776
  resizeTargetRef.current = null;
1679
1777
  rectTargetRef.current = null;
1680
1778
  shapeResizeTargetRef.current = null;
1779
+ bgCropTargetRef.current = null;
1681
1780
  setDraggingId(null);
1682
1781
  setSlidingId(null);
1683
1782
  setEpDragId(null);
@@ -1685,6 +1784,7 @@ export const AnnotationCanvasInner = (props) => {
1685
1784
  setResizingId(null);
1686
1785
  setRectDragId(null);
1687
1786
  setShapeResizeId(null);
1787
+ setBgCropId(null);
1688
1788
  };
1689
1789
  return Gesture.Pan()
1690
1790
  .minPointers(1)
@@ -1950,6 +2050,7 @@ export const AnnotationCanvasInner = (props) => {
1950
2050
  worldTransform,
1951
2051
  resolveImageUrl,
1952
2052
  deferBackgroundToResolver,
2053
+ hideLinkBadges,
1953
2054
  backgroundSkImages,
1954
2055
  // Shared with the loupe overlay below (it re-runs this same scene through a
1955
2056
  // magnifying transform), so the lens shows the substrate magnified instead
@@ -1998,6 +2099,14 @@ export const AnnotationCanvasInner = (props) => {
1998
2099
  shapeEndpointDragId: shapeEpDragId,
1999
2100
  liveLineP1,
2000
2101
  liveLineP2,
2102
+ bgCropId,
2103
+ liveBgCrop: {
2104
+ x: liveBgCropX,
2105
+ y: liveBgCropY,
2106
+ width: liveBgCropW,
2107
+ height: liveBgCropH,
2108
+ },
2109
+ liveBgCropClip: liveBgCropRect,
2001
2110
  rectDragId,
2002
2111
  liveRect: {
2003
2112
  x: liveRectX,
@@ -24,6 +24,7 @@ export interface AnnotationCanvasSkiaProps {
24
24
  viewport?: ViewportState;
25
25
  resolveImageUrl?: (storagePath: string) => Promise<string>;
26
26
  deferBackgroundToResolver?: boolean;
27
+ hideLinkBadges?: boolean;
27
28
  backgroundSkImages?: Record<string, SkImage | null>;
28
29
  backgroundContent?: ReactNode;
29
30
  valueFont: SkFont | null;
@@ -74,6 +75,26 @@ export interface AnnotationCanvasSkiaProps {
74
75
  width: AnimatedNumber;
75
76
  height: AnimatedNumber;
76
77
  };
78
+ bgCropId?: string | null;
79
+ liveBgCrop?: {
80
+ x: AnimatedNumber;
81
+ y: AnimatedNumber;
82
+ width: AnimatedNumber;
83
+ height: AnimatedNumber;
84
+ };
85
+ liveBgCropClip?: {
86
+ x: number;
87
+ y: number;
88
+ width: number;
89
+ height: number;
90
+ } | {
91
+ value: {
92
+ x: number;
93
+ y: number;
94
+ width: number;
95
+ height: number;
96
+ };
97
+ };
77
98
  shapeResizeId?: string | null;
78
99
  shapeResizePath?: SkPath | {
79
100
  value: SkPath;
@@ -106,5 +127,5 @@ export interface AnnotationCanvasSkiaProps {
106
127
  wrapContent?: (content: ReactNode) => ReactNode;
107
128
  canvasRef?: RefObject<CanvasRef | null>;
108
129
  }
109
- export declare 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, measurementLineScale, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
130
+ export declare 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, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale, measurementLineScale, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
110
131
  export {};
@@ -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,6 +30,30 @@ 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
+ // Outline of a selected LOCKED background layer: the lock accent (the same
34
+ // yellow the selected group-header border uses), distinct from the blue
35
+ // manipulable-selection chrome — locked means "selected, but pinned".
36
+ const BACKGROUND_LOCKED_COLOR = YellowColors.mainYellow;
37
+ // Link badge on a LINKED background layer (layer.fileId — an expanded file
38
+ // tile, see fileTileSwap.ts): a chain glyph in a white disc at the layer's
39
+ // top-right, saying "this is a group file's image, not a plain background".
40
+ // Always on (selected or not) so the link is legible at a glance — which
41
+ // makes it the one piece of chrome selection-clearing does NOT strip before a
42
+ // capture: hosts must pass `hideLinkBadges` while snapshotting for
43
+ // thumbnails/share/export, or the badge bakes into the output image.
44
+ // Doc-space sized off the visible rect, like the selection stroke and bars.
45
+ const LINKED_BADGE_BG = '#FFFFFF';
46
+ const LINKED_BADGE_INK = '#181A1C';
47
+ const LINKED_BADGE_RADIUS_RATIO = 0.05;
48
+ // Lucide `link` (24-unit viewBox, stroke-width 2) — drawn stroked inside a
49
+ // scale-to-badge Group, so the stroke width scales with the glyph.
50
+ 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' +
51
+ 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
52
+ // Lazy + cached: on web this module is only evaluated after CanvasKit loads
53
+ // (WithSkiaWeb gates the import), but deferring to first draw costs nothing
54
+ // and can't regress if that ever changes.
55
+ let linkedGlyphPath = null;
56
+ const getLinkedGlyphPath = () => (linkedGlyphPath ?? (linkedGlyphPath = Skia.Path.MakeFromSVGString(LINKED_GLYPH_SVG)));
32
57
  // A draggable selection handle (line endpoints, rect corners, text resize):
33
58
  // a white disc with a colored ring, so it reads as a distinct, grabbable knob
34
59
  // over a line of ANY color — including the default measurement-line blue, which
@@ -80,7 +105,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
80
105
  // since the function-call pattern works identically on native we use it
81
106
  // in both Inners for consistency. Don't add hooks here; this is a plain
82
107
  // 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, }) => {
108
+ 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, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, measurementLineScale = 1, customPreview, wrapContent, canvasRef, }) => {
84
109
  // Doc rect a SCALE-mode plane grid covers: the first background layer (the
85
110
  // photo being calibrated), else the document rect. Pinned to layer 0 like
86
111
  // the tile-sizing reference — adding a second image must not re-span the
@@ -94,7 +119,39 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
94
119
  width: effectiveCanvas.viewport.width,
95
120
  height: effectiveCanvas.viewport.height,
96
121
  };
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) => {
122
+ const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundContent, backgroundLayersOf(effectiveCanvas.viewport).map((layer) => {
123
+ // Native crop-bar drag: clip to the LIVE visible rect (doc space)
124
+ // instead of the committed crop window, which is suppressed so a bar
125
+ // dragged outward reveals what the window hid.
126
+ const isCropDrag = layer.id === bgCropId && liveBgCropClip != null;
127
+ const element = (_jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, deferToResolver: deferBackgroundToResolver, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height, suppressCrop: isCropDrag }));
128
+ // Link badge on a LINKED layer (an expanded file tile) — always on,
129
+ // selected or not, locked or not: the link is a fact about the layer,
130
+ // not an affordance. A sibling of the (possibly live-clipped) image
131
+ // inside the same DraggableElement, so it rides drags and resizes;
132
+ // hidden during a live crop drag (it would sit on stale committed
133
+ // edges) and under `hideLinkBadges` (capture/export renders).
134
+ const badge = layer.fileId && !isCropDrag && !hideLinkBadges
135
+ ? (() => {
136
+ const glyph = getLinkedGlyphPath();
137
+ if (!glyph)
138
+ return null;
139
+ const r = backgroundLayerDocRect(layer);
140
+ const badgeR = Math.min(r.width, r.height) * LINKED_BADGE_RADIUS_RATIO;
141
+ const bc = {
142
+ x: r.x + r.width - 2 * badgeR,
143
+ y: r.y + 2 * badgeR,
144
+ };
145
+ const glyphSize = badgeR * 1.2;
146
+ return (_jsxs(_Fragment, { children: [_jsx(Circle, { c: bc, r: badgeR, color: LINKED_BADGE_BG }), _jsx(Group, { transform: [
147
+ { translateX: bc.x - glyphSize / 2 },
148
+ { translateY: bc.y - glyphSize / 2 },
149
+ { scale: glyphSize / 24 },
150
+ ], children: _jsx(Path, { path: glyph, color: LINKED_BADGE_INK, style: "stroke", strokeWidth: 2, strokeCap: "round", strokeJoin: "round" }) })] }));
151
+ })()
152
+ : null;
153
+ 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));
154
+ }), (effectiveCanvas.planes ?? []).map((plane) => {
98
155
  if (plane.id === planeDragId && planeDragPath) {
99
156
  const statics = plane.mode === 'perspective'
100
157
  ? plane.corners
@@ -247,21 +304,40 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
247
304
  shape.kind === 'polygon';
248
305
  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
306
  }
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.
307
+ // A selected background layer: outline its VISIBLE (cropped) doc rect
308
+ // and, when unlocked, offer FOUR corner handles (each scales about
309
+ // its opposite corner) plus four edge crop bars (raster layers only —
310
+ // dragging a bar windows the image non-destructively). Chrome tracks
311
+ // the live drag/resize transform the same way the layer itself does
312
+ // above. A LOCKED layer (backgroundLayerIsLocked) keeps the outline —
313
+ // it is selected, and the host's chrome offers the lock toggle — but
314
+ // recolored to the lock yellow and with no handles: it cannot scale
315
+ // or crop, so a grabbable gizmo would lie. During a native crop-bar
316
+ // drag the chrome is just the LIVE outline — handles and bars would
317
+ // sit on stale committed edges.
258
318
  const layer = backgroundLayersOf(effectiveCanvas.viewport).find((l) => l.id === selectedId);
259
319
  if (layer) {
320
+ if (layer.id === bgCropId && liveBgCrop) {
321
+ return (_jsx(Rect, { x: liveBgCrop.x, y: liveBgCrop.y, width: liveBgCrop.width, height: liveBgCrop.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }));
322
+ }
260
323
  const locked = isBackgroundLayerLocked(effectiveCanvas.viewport, layer.id);
261
324
  const r = backgroundLayerDocRect(layer);
262
325
  const isResizing = layer.id === resizingId;
263
326
  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 }))] }));
327
+ const corners = [
328
+ { x: r.x, y: r.y },
329
+ { x: r.x + r.width, y: r.y },
330
+ { x: r.x, y: r.y + r.height },
331
+ { x: r.x + r.width, y: r.y + r.height },
332
+ ];
333
+ 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 &&
334
+ layer.format !== 'svg' &&
335
+ backgroundCropBars(layer).map((bar) => {
336
+ const inset = Math.min(bar.width, bar.height) * 0.18;
337
+ 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));
338
+ }), handleRadius != null &&
339
+ !locked &&
340
+ corners.map((c, i) => (_jsx(Handle, { c: c, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }, i)))] }));
265
341
  }
266
342
  return null;
267
343
  })(), 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] }));
@@ -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';
@@ -90,7 +91,9 @@ export interface DragSelectionConfig {
90
91
  locked: boolean;
91
92
  } | null;
92
93
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
93
- buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
94
+ buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, geom?: ResizeGeometry): AnnotationDocumentPatch | null;
95
+ hitTestBackgroundCropBar?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): BackgroundCropEdge | null;
96
+ buildBackgroundCropPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, edge: BackgroundCropEdge, delta: Vec2): AnnotationDocumentPatch | null;
94
97
  }
95
98
  export interface PlaneEditConfig {
96
99
  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
+ };
@@ -20,7 +20,7 @@ export declare const TILE_SCALE_MIN = 0.05;
20
20
  export declare const TILE_SCALE_MAX = 2;
21
21
  export declare const resolveTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile">, platform: TileScalePlatform) => number;
22
22
  export declare const resolveHeaderTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile" | "headerTileScaleFactor" | "headerTileScaleFactorMobile">, platform: TileScalePlatform) => number;
23
- export declare const resolveFileTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile" | "fileTileScaleFactor" | "fileTileScaleFactorMobile">, platform: TileScalePlatform) => number;
23
+ export declare const resolveFileTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile">, platform: TileScalePlatform) => number;
24
24
  export declare const clampTileScale: (v: number) => number;
25
25
  export declare const tileDocSizeForFactor: (factor: number, zoom: number) => number;
26
26
  export declare const tileScaleFromDocSize: (docSize: number, zoom: number) => number;
@@ -97,14 +97,16 @@ export const resolveTileScaleFactor = (canvas, platform) => (platform === 'mobil
97
97
  export const resolveHeaderTileScaleFactor = (canvas, platform) => (platform === 'mobile'
98
98
  ? (canvas.headerTileScaleFactorMobile ?? canvas.headerTileScaleFactor)
99
99
  : canvas.headerTileScaleFactor) ?? resolveTileScaleFactor(canvas, platform);
100
- // Resolve the document-wide OBJECT-tile-scale knob for a platform — the third
101
- // knob (Asana 1217570500895545), resolved exactly like the header knob: own
102
- // platform value → web value → the MEASUREMENT factor, so an object tile
103
- // placed before its knob was ever moved matches the measurement tiles beside
104
- // it. The ONE read path for the file fields.
105
- export const resolveFileTileScaleFactor = (canvas, platform) => (platform === 'mobile'
106
- ? (canvas.fileTileScaleFactorMobile ?? canvas.fileTileScaleFactor)
107
- : canvas.fileTileScaleFactor) ?? resolveTileScaleFactor(canvas, platform);
100
+ // Resolve the OBJECT-tile scale for a platform: the MEASUREMENT factor.
101
+ // Object tiles briefly had their own third knob (Asana 1217570500895545), but
102
+ // it was retired — an object thumbnail must size consistently with the
103
+ // measurement tiles beside it, so its slider now drives (and follows) the one
104
+ // shared factor. The `fileTileScaleFactor(Mobile)` fields it wrote persist on
105
+ // old documents and old undo payloads still write them; they are deliberately
106
+ // IGNORED here — resolving them would let a stale divergent value shadow the
107
+ // shared knob forever. Kept as a named resolver (not inlined at call sites)
108
+ // so the read path stays greppable and the retirement is documented once.
109
+ export const resolveFileTileScaleFactor = (canvas, platform) => resolveTileScaleFactor(canvas, platform);
108
110
  // Clamp a tile-scale-factor candidate to the supported range. The single guard
109
111
  // for the value before it lands in the document (slider input, restored docs).
110
112
  export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
@@ -1,5 +1,23 @@
1
+ import type { Vec2 } from '../../../types/annotation.js';
2
+ import { type BackgroundCropEdge } from '../backgroundLayers.js';
1
3
  import type { Tool } from '../Tool.js';
2
4
  export interface SelectToolOptions {
3
5
  panOnEmptyDrag?: boolean;
4
6
  }
7
+ export declare const cropDragVisibleRect: (start: {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }, full: {
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ }, edge: BackgroundCropEdge, delta: Vec2) => {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ };
5
23
  export declare const createSelectTool: (options?: SelectToolOptions) => Tool;
@@ -1,4 +1,4 @@
1
- import { backgroundLayerDocRect, backgroundLayerIsLocked, backgroundLayersOf, isBackgroundLayerLocked, } from '../backgroundLayers.js';
1
+ import { MAX_BACKGROUND_DOC_EXTENT, MIN_BACKGROUND_DOC_EXTENT, backgroundCropBars, backgroundLayerDocRect, backgroundLayerFullDocRect, backgroundLayerIsLocked, backgroundLayersOf, isBackgroundLayerLocked, } from '../backgroundLayers.js';
2
2
  import { stampTileDims } from '../stampLayout.js';
3
3
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
4
4
  import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
@@ -468,12 +468,8 @@ const shapeCornerPatch = (doc, id, corner, delta) => {
468
468
  // other, but never moves or scales:
469
469
  // the drag/resize hit-tests below refuse it, and a gesture on it behaves like
470
470
  // empty canvas (pan) while leaving the selection in place. ---
471
- // Doc-space clamp on a resized background's larger doc dimension. The floor
472
- // keeps a corner drag from collapsing a layer below grabbable size; the
473
- // ceiling keeps the doc rect (which feeds fit math and the web SVG raster
474
- // planner) from exploding to numerically silly sizes.
475
- const MIN_BACKGROUND_DOC_EXTENT = 16;
476
- const MAX_BACKGROUND_DOC_EXTENT = 65536;
471
+ // Doc-space size clamps live in backgroundLayers.ts (the native crop preview
472
+ // worklet shares the floor).
477
473
  const backgroundLayerById = (doc, id) => backgroundLayersOf(doc.viewport).find((l) => l.id === id) ?? null;
478
474
  const pointInBackgroundLayer = (layer, p) => {
479
475
  const r = backgroundLayerDocRect(layer);
@@ -511,19 +507,30 @@ const findSelectedBackgroundHit = (doc, world, selectedIds) => {
511
507
  }
512
508
  return null;
513
509
  };
514
- // Corner-scale geometry for a background layer: pivot = the doc rect's
515
- // top-left — the layer's transform origin, so scaling about it multiplies
516
- // scaleX/scaleY while x/y stay put — and handle = the EXACT bottom-right
517
- // corner (the selection chrome draws it un-padded, unlike the padded text
518
- // box; the grab tolerance lives in the hit radius, not the geometry).
519
- const backgroundResizeGeometry = (layer) => {
510
+ // Corner-scale geometry for a background layer: handle = the grabbed corner
511
+ // of its VISIBLE (cropped) doc rect, pivot = the opposite corner, which stays
512
+ // put — so the patch scales scaleX/scaleY AND repositions transform.x/y about
513
+ // the pivot (unlike text, whose top-left anchor is always the pivot). The
514
+ // chrome draws all four handles un-padded on the exact rect corners; the grab
515
+ // tolerance lives in the hit radius, not the geometry.
516
+ const BACKGROUND_RESIZE_CORNERS = [
517
+ 'tl',
518
+ 'tr',
519
+ 'bl',
520
+ 'br',
521
+ ];
522
+ const backgroundResizeGeometry = (layer, corner) => {
520
523
  const r = backgroundLayerDocRect(layer);
521
524
  const maxDim = Math.max(r.width, r.height);
522
525
  if (!(maxDim > 0))
523
526
  return null;
527
+ const cornerPoint = (c) => ({
528
+ x: c === 'tl' || c === 'bl' ? r.x : r.x + r.width,
529
+ y: c === 'tl' || c === 'tr' ? r.y : r.y + r.height,
530
+ });
524
531
  return {
525
- pivot: { x: r.x, y: r.y },
526
- handle: { x: r.x + r.width, y: r.y + r.height },
532
+ pivot: cornerPoint(oppositeRectCorner(corner)),
533
+ handle: cornerPoint(corner),
527
534
  minScale: MIN_BACKGROUND_DOC_EXTENT / maxDim,
528
535
  maxScale: MAX_BACKGROUND_DOC_EXTENT / maxDim,
529
536
  };
@@ -532,41 +539,51 @@ const backgroundResizeGeometry = (layer) => {
532
539
  // native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
533
540
  // Resize geometry when the (selected) text shape's or background layer's
534
541
  // corner handle is under `world`, else null. A text handle sits on the padded
535
- // selection box's bottom-right corner, a background handle on its exact doc
536
- // rect corner (see AnnotationCanvasSkia); grab radius matches the measurement
537
- // endpoint handles either way.
542
+ // selection box's bottom-right corner; a background offers all FOUR exact doc
543
+ // rect corners, each scaling about its opposite (see AnnotationCanvasSkia);
544
+ // grab radius matches the measurement endpoint handles either way.
538
545
  const findResizeHandleHit = (doc, id, world, zoom) => {
546
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
547
+ const within = (geom) => {
548
+ const dx = world.x - geom.handle.x;
549
+ const dy = world.y - geom.handle.y;
550
+ return dx * dx + dy * dy <= r2;
551
+ };
539
552
  const s = doc.shapes.find((x) => x.id === id);
540
- // A locked background cannot scale, so its corner offers no handle (the
553
+ if (s) {
554
+ const geom = textResizeGeometry(s);
555
+ return geom && within(geom) ? geom : null;
556
+ }
557
+ // A locked background cannot scale, so its corners offer no handles (the
541
558
  // chrome draws none either — see AnnotationCanvasSkia).
542
- const layer = s || isBackgroundLayerLocked(doc.viewport, id)
543
- ? null
544
- : backgroundLayerById(doc, id);
545
- const geom = s
546
- ? textResizeGeometry(s)
547
- : layer
548
- ? backgroundResizeGeometry(layer)
549
- : null;
550
- if (!geom)
559
+ if (isBackgroundLayerLocked(doc.viewport, id))
551
560
  return null;
552
- const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
553
- const dx = world.x - geom.handle.x;
554
- const dy = world.y - geom.handle.y;
555
- return dx * dx + dy * dy <= r2 ? geom : null;
561
+ const layer = backgroundLayerById(doc, id);
562
+ if (!layer)
563
+ return null;
564
+ for (const corner of BACKGROUND_RESIZE_CORNERS) {
565
+ const geom = backgroundResizeGeometry(layer, corner);
566
+ if (geom && within(geom))
567
+ return geom;
568
+ }
569
+ return null;
556
570
  };
557
571
  // Scale the element by the drag (clamped to the geometry's scale range, so it
558
- // matches the native live preview exactly). The pivot is untouched: a text
559
- // shape keeps its anchor and bakes the scale into fontSize; a background keeps
560
- // transform.x/y — its top-left IS the scale pivot — and bakes the scale into
561
- // scaleX/scaleY (both axes, so the aspect ratio is preserved even on layers
562
- // synthesized from a legacy 'stretch' fit).
563
- const resizePatch = (doc, id, delta) => {
572
+ // matches the native live preview exactly). A text shape keeps its top-left
573
+ // anchor (the pivot) and bakes the scale into fontSize. A background scales
574
+ // uniformly about `geom.pivot` — the corner OPPOSITE the grabbed one — so
575
+ // scaleX/scaleY multiply AND transform.x/y move to keep the pivot fixed
576
+ // (both axes scale together, preserving the aspect ratio even on layers
577
+ // synthesized from a legacy 'stretch' fit). `geom` is the grab-start geometry
578
+ // the hit-test chose; without it (defensive) the bottom-right corner's
579
+ // geometry is assumed, which reproduces the pre-four-corner behavior.
580
+ const resizePatch = (doc, id, delta, grabGeom) => {
564
581
  const s = doc.shapes.find((x) => x.id === id);
565
582
  if (!s) {
566
583
  const layer = backgroundLayerById(doc, id);
567
584
  if (!layer || isBackgroundLayerLocked(doc.viewport, id))
568
585
  return null;
569
- const geom = backgroundResizeGeometry(layer);
586
+ const geom = grabGeom ?? backgroundResizeGeometry(layer, 'br');
570
587
  if (!geom)
571
588
  return null;
572
589
  const scale = resizeScaleFromDrag(geom, delta);
@@ -577,7 +594,8 @@ const resizePatch = (doc, id, delta) => {
577
594
  id,
578
595
  patch: {
579
596
  transform: {
580
- ...layer.transform,
597
+ x: geom.pivot.x + (layer.transform.x - geom.pivot.x) * scale,
598
+ y: geom.pivot.y + (layer.transform.y - geom.pivot.y) * scale,
581
599
  scaleX: layer.transform.scaleX * scale,
582
600
  scaleY: layer.transform.scaleY * scale,
583
601
  },
@@ -597,6 +615,82 @@ const resizePatch = (doc, id, delta) => {
597
615
  ],
598
616
  };
599
617
  };
618
+ // --- Background crop (the four edge bars on a selected, unlocked layer) ----
619
+ // Which crop bar of the given (selected) background layer is under `world`,
620
+ // or null. Bars exist only on an unlocked RASTER layer (an SVG layer's
621
+ // doc-rect math — the web rasterizer — never sees a crop). The bar rect is
622
+ // the drawn gizmo (backgroundCropBars) padded by the standard screen-constant
623
+ // grab tolerance.
624
+ const findBackgroundCropBarHit = (doc, id, world, zoom) => {
625
+ if (isBackgroundLayerLocked(doc.viewport, id))
626
+ return null;
627
+ const layer = backgroundLayerById(doc, id);
628
+ if (!layer || layer.format === 'svg')
629
+ return null;
630
+ const pad = HANDLE_GRAB_PX / zoom;
631
+ for (const bar of backgroundCropBars(layer)) {
632
+ if (world.x >= bar.x - pad &&
633
+ world.x <= bar.x + bar.width + pad &&
634
+ world.y >= bar.y - pad &&
635
+ world.y <= bar.y + bar.height + pad) {
636
+ return bar.edge;
637
+ }
638
+ }
639
+ return null;
640
+ };
641
+ // New VISIBLE doc rect for a crop-bar drag: the grabbed edge follows the
642
+ // delta, clamped so the rect never leaves the full image's bounds (dragging
643
+ // back out un-crops — the bytes were never touched) and never collapses below
644
+ // the same doc-extent floor a corner resize enforces. The native live-preview
645
+ // worklet in AnnotationCanvasInner.native.tsx is a WORKLET TWIN of this math
646
+ // — keep them in sync.
647
+ export const cropDragVisibleRect = (start, full, edge, delta) => {
648
+ let { x, y, width, height } = start;
649
+ if (edge === 'top') {
650
+ const newY = Math.min(Math.max(start.y + delta.y, full.y), start.y + start.height - MIN_BACKGROUND_DOC_EXTENT);
651
+ height = start.y + start.height - newY;
652
+ y = newY;
653
+ }
654
+ else if (edge === 'bottom') {
655
+ height = Math.min(Math.max(start.height + delta.y, MIN_BACKGROUND_DOC_EXTENT), full.y + full.height - start.y);
656
+ }
657
+ else if (edge === 'left') {
658
+ const newX = Math.min(Math.max(start.x + delta.x, full.x), start.x + start.width - MIN_BACKGROUND_DOC_EXTENT);
659
+ width = start.x + start.width - newX;
660
+ x = newX;
661
+ }
662
+ else {
663
+ width = Math.min(Math.max(start.width + delta.x, MIN_BACKGROUND_DOC_EXTENT), full.x + full.width - start.x);
664
+ }
665
+ return { x, y, width, height };
666
+ };
667
+ // Patch that commits a crop-bar drag: the new visible rect (clamped exactly
668
+ // like the live preview) converted back to an intrinsic-pixel crop window.
669
+ const backgroundCropPatch = (doc, id, edge, delta) => {
670
+ const layer = backgroundLayerById(doc, id);
671
+ if (!layer || isBackgroundLayerLocked(doc.viewport, id))
672
+ return null;
673
+ const { scaleX, scaleY } = layer.transform;
674
+ if (!(scaleX > 0) || !(scaleY > 0))
675
+ return null;
676
+ const visible = cropDragVisibleRect(backgroundLayerDocRect(layer), backgroundLayerFullDocRect(layer), edge, delta);
677
+ return {
678
+ ops: [
679
+ {
680
+ op: 'updateBackgroundImage',
681
+ id,
682
+ patch: {
683
+ crop: {
684
+ x: (visible.x - layer.transform.x) / scaleX,
685
+ y: (visible.y - layer.transform.y) / scaleY,
686
+ width: visible.width / scaleX,
687
+ height: visible.height / scaleY,
688
+ },
689
+ },
690
+ },
691
+ ],
692
+ };
693
+ };
600
694
  // Patch for the current drag mode (web pointer path), from a world-space delta.
601
695
  const dragPatch = (s, doc, delta, zoom) => {
602
696
  if (s.mode === 'endpoint' && s.handle) {
@@ -609,7 +703,10 @@ const dragPatch = (s, doc, delta, zoom) => {
609
703
  if (s.mode === 'slide')
610
704
  return slidePatch(doc, s.id, delta, zoom);
611
705
  if (s.mode === 'resize')
612
- return resizePatch(doc, s.id, delta);
706
+ return resizePatch(doc, s.id, delta, s.geom);
707
+ if (s.mode === 'bg-crop' && s.cropEdge) {
708
+ return backgroundCropPatch(doc, s.id, s.cropEdge, delta);
709
+ }
613
710
  if (s.mode === 'rect-corner' && s.corner) {
614
711
  return rectCornerPatch(doc, s.id, s.corner, delta);
615
712
  }
@@ -643,6 +740,8 @@ export const createSelectTool = (options = {}) => ({
643
740
  buildResizePatch: resizePatch,
644
741
  hitTestBackground: findBackgroundHit,
645
742
  hitTestSelectedBackground: findSelectedBackgroundHit,
743
+ hitTestBackgroundCropBar: findBackgroundCropBarHit,
744
+ buildBackgroundCropPatch: backgroundCropPatch,
646
745
  hitTestRectCorner: findRectCornerHit,
647
746
  buildRectCornerPatch: rectCornerPatch,
648
747
  hitTestShapeCorner: findShapeCornerHit,
@@ -693,7 +792,8 @@ export const createSelectTool = (options = {}) => ({
693
792
  delta: { x: 0, y: 0 },
694
793
  };
695
794
  }
696
- if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
795
+ const resizeGeom = findResizeHandleHit(ctx.document, selId, world, zoom);
796
+ if (resizeGeom) {
697
797
  return {
698
798
  kind: 'dragging',
699
799
  id: selId,
@@ -704,6 +804,22 @@ export const createSelectTool = (options = {}) => ({
704
804
  ? 'background'
705
805
  : 'shape',
706
806
  mode: 'resize',
807
+ // The grabbed corner's geometry — a background scales about the
808
+ // OPPOSITE corner, so the patch must reuse what the grab chose.
809
+ geom: resizeGeom,
810
+ start: world,
811
+ delta: { x: 0, y: 0 },
812
+ };
813
+ }
814
+ // Crop bar on a selected, unlocked background layer.
815
+ const cropEdge = findBackgroundCropBarHit(ctx.document, selId, world, zoom);
816
+ if (cropEdge) {
817
+ return {
818
+ kind: 'dragging',
819
+ id: selId,
820
+ elementKind: 'background',
821
+ mode: 'bg-crop',
822
+ cropEdge,
707
823
  start: world,
708
824
  delta: { x: 0, y: 0 },
709
825
  };
@@ -194,7 +194,9 @@ export const useAnnotationCanvasState = (props) => {
194
194
  // equals the measurement factor (resolveHeaderTileScaleFactor's fallback) —
195
195
  // the pre-split one-knob behavior.
196
196
  let headerTileScaleFactor = resolveHeaderTileScaleFactor(effectiveCanvas, tileScalePlatformResolved);
197
- // Object tiles: the third knob, same fallback (Asana 1217570500895545).
197
+ // Object tiles share the measurement factor — their own knob was retired
198
+ // (see resolveFileTileScaleFactor), so this always equals tileScaleFactor
199
+ // and rides the same calibration below.
198
200
  let fileTileScaleFactor = resolveFileTileScaleFactor(effectiveCanvas, tileScalePlatformResolved);
199
201
  if (tileScalePlatformResolved === 'mobile' &&
200
202
  effectiveCanvas.tileScaleFactorMobile == null &&
@@ -429,7 +429,11 @@ export const useAnnotationCanvasDoc = (options) => {
429
429
  ? backgroundLayersOf(viewport).find((l) => l.id === id)
430
430
  : undefined;
431
431
  const fileId = fileIdRef.current ?? createdIdRef.current;
432
- if (layer && fileId) {
432
+ // A LINKED layer (an expanded file tile — see fileTileSwap.ts) does not
433
+ // own its Storage object: its storagePath points into the SOURCE file's
434
+ // folder, and deleting it would destroy that file's photo. Remove the
435
+ // layer only.
436
+ if (layer && fileId && !layer.fileId) {
433
437
  try {
434
438
  await deleteImage(fileId, layer.storagePath);
435
439
  }
package/dist/exports.d.ts CHANGED
@@ -37,6 +37,7 @@ export { GROUP_HEADER_STYLE, describeGroupHeader, groupHeaderSignature, type Gro
37
37
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, DEFAULT_ZOOM_LIMITS, type ViewportApi, type ViewportState, type ZoomLimits, } from './annotation/canvas/viewport.js';
38
38
  export { DEFAULT_PDF_PAGE_GAP, DEFAULT_MIN_ZOOM, DEFAULT_MAX_ZOOM, makePdfDocumentId, createPdfDocument, pdfPageDocRects, pdfDocumentExtent, pdfFirstPageDocRect, computeZoomFloor, type PdfDocRect, } from './annotation/canvas/pdfDocument.js';
39
39
  export { LEGACY_BACKGROUND_LAYER_ID, BACKGROUND_PLACEMENT_GAP_RATIO, backgroundLayersOf, backgroundLayerIsLocked, isBackgroundLayerLocked, defaultBackgroundLayerLocked, backgroundLayerDocRect, backgroundLayersUnionRect, placeNewBackgroundLayer, } from './annotation/canvas/backgroundLayers.js';
40
+ export { fileTileImageSource, expandFileTileToBackground, collapseBackgroundToFileTile, type FileTileImageSource, } from './annotation/canvas/fileTileSwap.js';
40
41
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
41
42
  export { createSelectTool, type SelectToolOptions, } from './annotation/canvas/tools/selectTool.js';
42
43
  export { createMeasurementStampTool, type MeasurementStampToolOptions, } from './annotation/canvas/tools/measurementStampTool.js';
package/dist/exports.js CHANGED
@@ -41,6 +41,7 @@ export { GROUP_HEADER_STYLE, describeGroupHeader, groupHeaderSignature, } from '
41
41
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, DEFAULT_ZOOM_LIMITS, } from './annotation/canvas/viewport.js';
42
42
  export { DEFAULT_PDF_PAGE_GAP, DEFAULT_MIN_ZOOM, DEFAULT_MAX_ZOOM, makePdfDocumentId, createPdfDocument, pdfPageDocRects, pdfDocumentExtent, pdfFirstPageDocRect, computeZoomFloor, } from './annotation/canvas/pdfDocument.js';
43
43
  export { LEGACY_BACKGROUND_LAYER_ID, BACKGROUND_PLACEMENT_GAP_RATIO, backgroundLayersOf, backgroundLayerIsLocked, isBackgroundLayerLocked, defaultBackgroundLayerLocked, backgroundLayerDocRect, backgroundLayersUnionRect, placeNewBackgroundLayer, } from './annotation/canvas/backgroundLayers.js';
44
+ export { fileTileImageSource, expandFileTileToBackground, collapseBackgroundToFileTile, } from './annotation/canvas/fileTileSwap.js';
44
45
  export { createPenTool, } from './annotation/canvas/tools/penTool.js';
45
46
  export { createSelectTool, } from './annotation/canvas/tools/selectTool.js';
46
47
  export { createMeasurementStampTool, } from './annotation/canvas/tools/measurementStampTool.js';
@@ -135,6 +135,12 @@ export interface BackgroundLayerTransform {
135
135
  scaleX: number;
136
136
  scaleY: number;
137
137
  }
138
+ export interface BackgroundLayerCrop {
139
+ x: number;
140
+ y: number;
141
+ width: number;
142
+ height: number;
143
+ }
138
144
  export interface AnnotationBackgroundLayer {
139
145
  id: AnnotationElementId;
140
146
  storagePath: string;
@@ -143,6 +149,10 @@ export interface AnnotationBackgroundLayer {
143
149
  heightPx: number;
144
150
  format?: 'raster' | 'svg';
145
151
  transform: BackgroundLayerTransform;
152
+ crop?: BackgroundLayerCrop;
153
+ fileId?: string;
154
+ filePath?: string;
155
+ fileGroupId?: string;
146
156
  locked?: boolean;
147
157
  }
148
158
  export interface AnnotationPdfPageSize {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.19.5",
3
+ "version": "1.19.6",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",