@reekon-tools/boldr-utils 1.19.4 → 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.
Files changed (27) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +2 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +3 -1
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +2 -0
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +118 -8
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +23 -1
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +89 -13
  7. package/dist/annotation/canvas/Tool.d.ts +4 -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 +6 -3
  11. package/dist/annotation/canvas/elements/BackgroundImageElement.js +16 -8
  12. package/dist/annotation/canvas/elements/BackgroundSvg.d.ts +1 -0
  13. package/dist/annotation/canvas/elements/BackgroundSvg.js +4 -4
  14. package/dist/annotation/canvas/elements/backgroundUrl.d.ts +2 -1
  15. package/dist/annotation/canvas/elements/backgroundUrl.js +15 -2
  16. package/dist/annotation/canvas/fileTileSwap.d.ts +12 -0
  17. package/dist/annotation/canvas/fileTileSwap.js +87 -0
  18. package/dist/annotation/canvas/stampLayout.d.ts +1 -1
  19. package/dist/annotation/canvas/stampLayout.js +10 -8
  20. package/dist/annotation/canvas/tools/selectTool.d.ts +18 -0
  21. package/dist/annotation/canvas/tools/selectTool.js +158 -42
  22. package/dist/annotation/canvas/useAnnotationCanvasState.js +3 -1
  23. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +5 -1
  24. package/dist/exports.d.ts +1 -0
  25. package/dist/exports.js +1 -0
  26. package/dist/types/annotation.d.ts +10 -0
  27. package/package.json +1 -1
@@ -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';
@@ -11,8 +11,8 @@ import { BackgroundSvgElement } from './BackgroundSvg.js';
11
11
  // re-decodes. Returns null until loaded, or when `image` is null/undefined.
12
12
  // Hooks run unconditionally regardless of `image`. Raster only — SVG
13
13
  // backgrounds self-load in BackgroundSvgElement (parsing an SVG is cheap).
14
- export const useBackgroundSkImage = (image, resolveUrl) => {
15
- const url = useBackgroundUrl(image, resolveUrl);
14
+ export const useBackgroundSkImage = (image, resolveUrl, deferToResolver) => {
15
+ const url = useBackgroundUrl(image, resolveUrl, deferToResolver);
16
16
  return useImage(url || null);
17
17
  };
18
18
  // Null-rendering decode host: one mounted instance per background layer, so a
@@ -20,10 +20,10 @@ export const useBackgroundSkImage = (image, resolveUrl) => {
20
20
  // mapping over hooks. The consumer (the native Inner) mounts one per layer,
21
21
  // accumulates the reported images into a Record keyed by layer id, and feeds
22
22
  // that to every canvas sharing the decode (main + magnifier loupe).
23
- export const BackgroundSkImageLoader = ({ layer, resolveUrl, onImage, }) => {
23
+ export const BackgroundSkImageLoader = ({ layer, resolveUrl, deferToResolver, onImage, }) => {
24
24
  // SVG layers never draw through an SkImage (BackgroundSvgElement owns its
25
25
  // own vector pipeline), so skip the decode and report null.
26
- const image = useBackgroundSkImage(layer.format === 'svg' ? null : layer, resolveUrl);
26
+ const image = useBackgroundSkImage(layer.format === 'svg' ? null : layer, resolveUrl, deferToResolver);
27
27
  // The callback rides a ref so an inline consumer closure doesn't re-report
28
28
  // (and re-render the collector) every render.
29
29
  const onImageRef = useRef(onImage);
@@ -38,12 +38,12 @@ export const BackgroundSkImageLoader = ({ layer, resolveUrl, onImage, }) => {
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, 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.
45
45
  const provided = skImageProp !== undefined;
46
- const loaded = useBackgroundSkImage(provided || isSvg ? null : layer, resolveUrl);
46
+ const loaded = useBackgroundSkImage(provided || isSvg ? null : layer, resolveUrl, deferToResolver);
47
47
  const skImage = provided ? skImageProp : loaded;
48
48
  const t = layer.transform;
49
49
  let content = null;
@@ -53,13 +53,21 @@ export const BackgroundLayerElement = memo(({ layer, resolveUrl, skImage: skImag
53
53
  // through the browser at viewport resolution. Both draw at intrinsic
54
54
  // size — the Group below owns the placement — but the web rasterizer
55
55
  // still needs the doc rect to crop against the visible region.
56
- content = (_jsx(BackgroundSvgElement, { image: layer, dims: backgroundLayerDocRect(layer), resolveUrl: resolveUrl, viewport: viewport, screenWidth: screenWidth, screenHeight: screenHeight }));
56
+ content = (_jsx(BackgroundSvgElement, { image: layer, dims: backgroundLayerDocRect(layer), resolveUrl: resolveUrl, deferToResolver: deferToResolver, viewport: viewport, screenWidth: screenWidth, screenHeight: screenHeight }));
57
57
  }
58
58
  else if (skImage) {
59
59
  content = (_jsx(Image, { image: skImage, x: 0, y: 0, width: layer.widthPx, height: layer.heightPx, fit: "fill" }));
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 },
@@ -5,6 +5,7 @@ export interface BackgroundSvgProps {
5
5
  image: AnnotationBackgroundImage;
6
6
  dims: RectPx;
7
7
  resolveUrl?: (path: string) => Promise<string>;
8
+ deferToResolver?: boolean;
8
9
  viewport?: ViewportState;
9
10
  screenWidth?: number;
10
11
  screenHeight?: number;
@@ -116,8 +116,8 @@ const useBackgroundSkSvg = (url) => {
116
116
  }, []);
117
117
  return svg;
118
118
  };
119
- const SkSvgBackground = ({ image, resolveUrl }) => {
120
- const url = useBackgroundUrl(image, resolveUrl);
119
+ const SkSvgBackground = ({ image, resolveUrl, deferToResolver, }) => {
120
+ const url = useBackgroundUrl(image, resolveUrl, deferToResolver);
121
121
  const svg = useBackgroundSkSvg(url);
122
122
  if (!svg)
123
123
  return null;
@@ -169,8 +169,8 @@ const loadSvgImage = (xml) => new Promise((resolve, reject) => {
169
169
  };
170
170
  img.src = objectUrl;
171
171
  });
172
- const BrowserSvgBackground = ({ image, dims, resolveUrl, viewport, screenWidth, screenHeight, }) => {
173
- const url = useBackgroundUrl(image, resolveUrl);
172
+ const BrowserSvgBackground = ({ image, dims, resolveUrl, deferToResolver, viewport, screenWidth, screenHeight, }) => {
173
+ const url = useBackgroundUrl(image, resolveUrl, deferToResolver);
174
174
  const [svgText, setSvgText] = useState(null);
175
175
  useEffect(() => {
176
176
  if (!url) {
@@ -1,2 +1,3 @@
1
1
  import type { AnnotationBackgroundImage } from '../../../types/annotation.js';
2
- export declare const useBackgroundUrl: (image: AnnotationBackgroundImage | null | undefined, resolveUrl?: (path: string) => Promise<string>) => string;
2
+ export declare const initialBackgroundUrl: (image: AnnotationBackgroundImage | null | undefined, resolveUrl: ((path: string) => Promise<string>) | undefined, deferToResolver: boolean | undefined) => string;
3
+ export declare const useBackgroundUrl: (image: AnnotationBackgroundImage | null | undefined, resolveUrl?: (path: string) => Promise<string>, deferToResolver?: boolean) => string;
@@ -2,8 +2,21 @@ import { useEffect, useRef, useState } from 'react';
2
2
  // Resolve a background image's (possibly expired) storage URL. Shared by the
3
3
  // raster loader (BackgroundLayerElement / BackgroundSkImageLoader) and both
4
4
  // SVG render paths (BackgroundSvg).
5
- export const useBackgroundUrl = (image, resolveUrl) => {
6
- const [url, setUrl] = useState(image?.downloadUrl ?? '');
5
+ //
6
+ // `deferToResolver` — start from '' instead of seeding the doc's stored
7
+ // `downloadUrl` while the resolver runs. A consumer whose resolver is
8
+ // LOCAL-FIRST (a cached copy on disk answers without the network) must set
9
+ // this: the seed is handed to the image loader on the very first render, so
10
+ // it starts a full-resolution download from Storage that the local answer
11
+ // then races — wasted bandwidth at best, and the loader keeps whichever
12
+ // result lands LAST, so the remote fetch can override the local decode.
13
+ // A consumer whose resolver is network-only (web/desktop re-resolving a
14
+ // possibly-expired URL) should leave it off: there the seed is a warm start —
15
+ // the resolver normally confirms the same URL, and the early fetch is the
16
+ // image download it would have started anyway.
17
+ export const initialBackgroundUrl = (image, resolveUrl, deferToResolver) => (deferToResolver && resolveUrl ? '' : (image?.downloadUrl ?? ''));
18
+ export const useBackgroundUrl = (image, resolveUrl, deferToResolver) => {
19
+ const [url, setUrl] = useState(() => initialBackgroundUrl(image, resolveUrl, deferToResolver));
7
20
  // Hold resolveUrl in a ref so re-resolution is keyed only on the image
8
21
  // identity, not the function's. Consumers commonly pass an inline closure
9
22
  // (a new reference each render); depending on it directly would re-run a
@@ -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';