@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.
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +2 -1
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +116 -7
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +22 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +89 -13
- package/dist/annotation/canvas/Tool.d.ts +4 -1
- package/dist/annotation/canvas/backgroundLayers.d.ts +20 -1
- package/dist/annotation/canvas/backgroundLayers.js +94 -3
- package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +2 -1
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +10 -2
- package/dist/annotation/canvas/fileTileSwap.d.ts +12 -0
- package/dist/annotation/canvas/fileTileSwap.js +87 -0
- package/dist/annotation/canvas/stampLayout.d.ts +1 -1
- package/dist/annotation/canvas/stampLayout.js +10 -8
- package/dist/annotation/canvas/tools/selectTool.d.ts +18 -0
- package/dist/annotation/canvas/tools/selectTool.js +158 -42
- package/dist/annotation/canvas/useAnnotationCanvasState.js +3 -1
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +5 -1
- package/dist/exports.d.ts +1 -0
- package/dist/exports.js +1 -0
- package/dist/types/annotation.d.ts +10 -0
- package/package.json +1 -1
|
@@ -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) =>
|
|
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
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// transform the same way the layer itself does
|
|
255
|
-
// (backgroundLayerIsLocked) keeps the outline —
|
|
256
|
-
// the host's chrome offers the lock toggle — but
|
|
257
|
-
//
|
|
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
|
-
|
|
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
|
|
73
|
-
// the
|
|
74
|
-
|
|
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"
|
|
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
|
|
101
|
-
// knob (Asana 1217570500895545),
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
472
|
-
//
|
|
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:
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
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:
|
|
526
|
-
handle:
|
|
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
|
|
536
|
-
// rect
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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).
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
// scaleX/scaleY
|
|
562
|
-
//
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|