@reekon-tools/boldr-utils 1.6.29 → 1.6.31
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 +29 -5
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +21 -17
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +4 -3
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +3 -3
- package/dist/annotation/canvas/Tool.d.ts +4 -3
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +67 -12
- package/dist/annotation/canvas/stampLayout.d.ts +2 -1
- package/dist/annotation/canvas/stampLayout.js +23 -11
- package/dist/annotation/canvas/tools/panTool.js +1 -1
- package/dist/annotation/canvas/tools/selectTool.js +13 -13
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +5 -1
- package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -2
- package/dist/annotation/data/AnnotationDataProvider.d.ts +20 -9
- package/dist/annotation/data/AnnotationDataProvider.js +8 -0
- package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +10 -10
- package/dist/annotation/data/InMemoryAnnotationProvider.js +30 -4
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.d.ts +2 -2
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +10 -3
- package/dist/annotation/data/hooks/useAnnotationDoc.d.ts +2 -2
- package/dist/annotation/data/hooks/useAnnotationDoc.js +3 -8
- package/dist/annotation/data/hooks/useAnnotationList.d.ts +2 -2
- package/dist/annotation/data/hooks/useAnnotationList.js +3 -1
- package/dist/annotation/data/hooks/useAnnotationMutations.d.ts +3 -3
- package/dist/annotation/data/hooks/useAnnotationMutations.js +9 -5
- package/dist/exports.d.ts +2 -2
- package/dist/exports.js +2 -2
- package/dist/types/annotation.d.ts +4 -0
- package/dist/types/annotation.js +14 -4
- package/dist/types/firestore.d.ts +6 -1
- package/package.json +1 -1
|
@@ -37,6 +37,7 @@ export interface AnnotationCanvasInnerProps {
|
|
|
37
37
|
style?: CSSProperties;
|
|
38
38
|
magnifierTopOffset?: number;
|
|
39
39
|
imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
|
|
40
|
+
snapshotRef?: MutableRefObject<(() => Uint8Array | null) | null>;
|
|
40
41
|
}
|
|
41
42
|
export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
|
|
42
43
|
export interface GestureConfig {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
|
|
2
|
+
import { Skia, useCanvasRef, useFont, useTypeface, } from '@shopify/react-native-skia';
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, } from 'react';
|
|
4
4
|
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
5
5
|
import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
|
|
@@ -21,7 +21,25 @@ const LONG_PRESS_MS = 500;
|
|
|
21
21
|
const LONG_PRESS_SLOP_PX = 10;
|
|
22
22
|
const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
|
|
23
23
|
export const AnnotationCanvasInner = (props) => {
|
|
24
|
-
const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
|
|
24
|
+
const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, snapshotRef, } = props;
|
|
25
|
+
// Snapshot plumbing for thumbnail capture: hold the <Canvas> ref and publish
|
|
26
|
+
// a capture fn on the consumer's snapshotRef. encodeToBytes() yields PNG.
|
|
27
|
+
const canvasRef = useCanvasRef();
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!snapshotRef)
|
|
30
|
+
return;
|
|
31
|
+
snapshotRef.current = () => {
|
|
32
|
+
const image = canvasRef.current?.makeImageSnapshot();
|
|
33
|
+
if (!image)
|
|
34
|
+
return null;
|
|
35
|
+
const bytes = image.encodeToBytes();
|
|
36
|
+
image.dispose?.();
|
|
37
|
+
return bytes ?? null;
|
|
38
|
+
};
|
|
39
|
+
return () => {
|
|
40
|
+
snapshotRef.current = null;
|
|
41
|
+
};
|
|
42
|
+
}, [snapshotRef, canvasRef]);
|
|
25
43
|
const wheelMode = gestures?.wheel ?? 'auto';
|
|
26
44
|
const panTriggers = gestures?.panTriggers ?? DEFAULT_PAN_TRIGGERS;
|
|
27
45
|
const allowSpacePan = panTriggers.includes('space');
|
|
@@ -42,7 +60,12 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
42
60
|
provider.registerFont(textTypeface, TEXT_FONT_FAMILY);
|
|
43
61
|
return provider;
|
|
44
62
|
}, [textTypeface]);
|
|
45
|
-
|
|
63
|
+
// This is the web renderer, so the canvas reads web's tile-scale knob (web
|
|
64
|
+
// and mobile persist separate values — see resolveTileScaleFactor).
|
|
65
|
+
const state = useAnnotationCanvasState({
|
|
66
|
+
...props,
|
|
67
|
+
tileScalePlatform: 'web',
|
|
68
|
+
});
|
|
46
69
|
const containerRef = useRef(null);
|
|
47
70
|
const panGestureRef = useRef(null);
|
|
48
71
|
const spaceDownRef = useRef(false);
|
|
@@ -261,7 +284,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
261
284
|
const world = state.ctx.viewport.screenToWorld(screen);
|
|
262
285
|
const placed = [...state.effectiveCanvas.placedMeasurements]
|
|
263
286
|
.reverse()
|
|
264
|
-
.find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.
|
|
287
|
+
.find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.tileScaleFactor, state.tileViewportScale));
|
|
265
288
|
if (placed)
|
|
266
289
|
onMeasurementStampDoubleTap(placed);
|
|
267
290
|
}, [onMeasurementStampDoubleTap, state]);
|
|
@@ -271,6 +294,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
271
294
|
effectiveCanvas: state.effectiveCanvas,
|
|
272
295
|
worldTransform: state.worldTransform,
|
|
273
296
|
resolveImageUrl,
|
|
297
|
+
canvasRef,
|
|
274
298
|
valueFont,
|
|
275
299
|
textFontMgr,
|
|
276
300
|
penDrawingStroke: state.penDrawingStroke,
|
|
@@ -293,7 +317,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
293
317
|
inset: 0,
|
|
294
318
|
pointerEvents: 'none',
|
|
295
319
|
}, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
|
|
296
|
-
const size = stampTileSize(placed, state.
|
|
320
|
+
const size = stampTileSize(placed, state.tileScaleFactor, state.tileViewportScale);
|
|
297
321
|
const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
|
|
298
322
|
const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
|
|
299
323
|
const isSelected = selection?.ids.includes(placed.id) ?? false;
|
|
@@ -38,5 +38,6 @@ export interface AnnotationCanvasInnerProps {
|
|
|
38
38
|
style?: ViewStyle;
|
|
39
39
|
magnifierTopOffset?: number;
|
|
40
40
|
imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
|
|
41
|
+
snapshotRef?: MutableRefObject<(() => Uint8Array | null) | null>;
|
|
41
42
|
}
|
|
42
43
|
export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -58,7 +58,13 @@ const LOUPE_CROSSHAIR_ARM = 9; // half-length of each crosshair arm, points
|
|
|
58
58
|
export const AnnotationCanvasInner = (props) => {
|
|
59
59
|
const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, } = props;
|
|
60
60
|
const valueFont = useFont(stampFontSource, stampValueFontSize);
|
|
61
|
-
|
|
61
|
+
// This is the native (mobile) renderer, so the canvas reads mobile's
|
|
62
|
+
// tile-scale knob (web and mobile persist separate values — see
|
|
63
|
+
// resolveTileScaleFactor).
|
|
64
|
+
const state = useAnnotationCanvasState({
|
|
65
|
+
...props,
|
|
66
|
+
tileScalePlatform: 'mobile',
|
|
67
|
+
});
|
|
62
68
|
// Decode the background image ONCE here and share it with both the main
|
|
63
69
|
// canvas and the magnifier loupe. If each canvas loaded its own, the loupe
|
|
64
70
|
// would flash its blank base every gesture while it re-resolved + re-decoded.
|
|
@@ -746,7 +752,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
746
752
|
return;
|
|
747
753
|
const world = st.ctx.viewport.screenToWorld(screen);
|
|
748
754
|
const zoomNow = st.ctx.viewport.state.zoom;
|
|
749
|
-
const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
|
|
755
|
+
const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
|
|
750
756
|
if (!hit || hit.kind !== 'measurement' || hit.id !== prevSelectedId) {
|
|
751
757
|
return;
|
|
752
758
|
}
|
|
@@ -1012,7 +1018,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1012
1018
|
// so a selected tile isn't stuck until you deselect it).
|
|
1013
1019
|
const selId = st.ctx.selection?.ids[0];
|
|
1014
1020
|
if (selId &&
|
|
1015
|
-
!cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale)) {
|
|
1021
|
+
!cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)) {
|
|
1016
1022
|
const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
|
|
1017
1023
|
if (handle) {
|
|
1018
1024
|
const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
|
|
@@ -1112,7 +1118,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1112
1118
|
return;
|
|
1113
1119
|
}
|
|
1114
1120
|
}
|
|
1115
|
-
const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
|
|
1121
|
+
const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
|
|
1116
1122
|
if (!hit) {
|
|
1117
1123
|
st.ctx.setSelection(null);
|
|
1118
1124
|
dragTargetRef.current = null;
|
|
@@ -1121,7 +1127,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1121
1127
|
st.ctx.setSelection({ ids: [hit.id] });
|
|
1122
1128
|
// Grabbing a line annotation's tile slides it; otherwise group-move.
|
|
1123
1129
|
const grab = hit.kind === 'measurement'
|
|
1124
|
-
? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale)
|
|
1130
|
+
? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)
|
|
1125
1131
|
: 'move';
|
|
1126
1132
|
if (grab === 'slide') {
|
|
1127
1133
|
const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
|
|
@@ -1423,19 +1429,17 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1423
1429
|
const loupeWrap = (content) => (_jsxs(_Fragment, { children: [_jsxs(Group, { clip: loupeClip, children: [_jsx(Rect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, color: LOUPE_BG_COLOR }), content] }), _jsx(RoundedRect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, r: LOUPE_RADIUS, color: LOUPE_BORDER_COLOR, style: "stroke", strokeWidth: LOUPE_BORDER_WIDTH }), _jsx(Line, { p1: { x: loupeCx - LOUPE_CROSSHAIR_ARM, y: loupeCy }, p2: { x: loupeCx + LOUPE_CROSSHAIR_ARM, y: loupeCy }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 }), _jsx(Line, { p1: { x: loupeCx, y: loupeCy - LOUPE_CROSSHAIR_ARM }, p2: { x: loupeCx, y: loupeCy + LOUPE_CROSSHAIR_ARM }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 })] }));
|
|
1424
1430
|
return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
|
|
1425
1431
|
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
1426
|
-
: (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.
|
|
1427
|
-
|
|
1428
|
-
const
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1432
|
+
: (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
|
|
1433
|
+
? () => {
|
|
1434
|
+
const defaultRemove = () => {
|
|
1435
|
+
const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
|
|
1436
|
+
state.ctx.commit({ ops });
|
|
1437
|
+
if (!keepSelection)
|
|
1438
|
+
state.ctx.setSelection(null);
|
|
1439
|
+
};
|
|
1434
1440
|
onMeasurementStampRemove(placed, defaultRemove);
|
|
1435
|
-
return;
|
|
1436
1441
|
}
|
|
1437
|
-
|
|
1438
|
-
} }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
|
|
1442
|
+
: undefined }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
|
|
1439
1443
|
...skiaProps,
|
|
1440
1444
|
worldTransform: loupeTransform,
|
|
1441
1445
|
wrapContent: loupeWrap,
|
|
@@ -1517,7 +1521,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
|
|
|
1517
1521
|
selected,
|
|
1518
1522
|
size,
|
|
1519
1523
|
zoom: zoomSnapshot,
|
|
1520
|
-
}) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
|
|
1524
|
+
}) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && onRemove && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
|
|
1521
1525
|
position: 'absolute',
|
|
1522
1526
|
top: 0,
|
|
1523
1527
|
right: 0,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
|
|
2
|
-
import type { ReactNode } from 'react';
|
|
1
|
+
import { type CanvasRef, type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
|
|
2
|
+
import type { ReactNode, RefObject } from 'react';
|
|
3
3
|
import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
|
|
4
4
|
type AnimatedPoint = {
|
|
5
5
|
x: number;
|
|
@@ -88,6 +88,7 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
88
88
|
};
|
|
89
89
|
customPreview?: ReactNode;
|
|
90
90
|
wrapContent?: (content: ReactNode) => ReactNode;
|
|
91
|
+
canvasRef?: RefObject<CanvasRef | null>;
|
|
91
92
|
}
|
|
92
|
-
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
93
|
+
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
93
94
|
export {};
|
|
@@ -15,7 +15,7 @@ import { StrokeElement } from './elements/StrokeElement.js';
|
|
|
15
15
|
// against the document; the rectangle border stays thinner (it outlines a
|
|
16
16
|
// region rather than pointing at one).
|
|
17
17
|
const MEASUREMENT_LINE_COLOR = '#0066FF';
|
|
18
|
-
const MEASUREMENT_LINE_WIDTH =
|
|
18
|
+
const MEASUREMENT_LINE_WIDTH = 16;
|
|
19
19
|
const MEASUREMENT_RECT_WIDTH = 2;
|
|
20
20
|
// Endpoint handle color — constant selection chrome, independent of the (now
|
|
21
21
|
// editable) line color so the handles stay legible on any line color.
|
|
@@ -78,7 +78,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
|
|
|
78
78
|
// since the function-call pattern works identically on native we use it
|
|
79
79
|
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
80
80
|
// JSX-returning helper, not a component.
|
|
81
|
-
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }) => {
|
|
81
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, canvasRef, }) => {
|
|
82
82
|
const world = (_jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl, skImage: backgroundSkImage })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
|
|
83
83
|
// Line/arrow shapes support endpoint editing. When selected they show
|
|
84
84
|
// grab handles at both ends; during an endpoint drag the line renders
|
|
@@ -220,5 +220,5 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
220
220
|
}
|
|
221
221
|
return null;
|
|
222
222
|
})(), 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] }));
|
|
223
|
-
return (_jsx(Canvas, { style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
|
|
223
|
+
return (_jsx(Canvas, { ref: canvasRef, style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
|
|
224
224
|
};
|
|
@@ -23,6 +23,7 @@ export interface ToolContext {
|
|
|
23
23
|
selection: Selection | null;
|
|
24
24
|
viewport: ViewportApi;
|
|
25
25
|
tileViewportScale: number;
|
|
26
|
+
tileScaleFactor: number;
|
|
26
27
|
preview(patch: AnnotationDocumentPatch): void;
|
|
27
28
|
commit(patch: AnnotationDocumentPatch): void;
|
|
28
29
|
setSelection(selection: Selection | null): void;
|
|
@@ -53,12 +54,12 @@ export interface ShapeDrawConfig {
|
|
|
53
54
|
}
|
|
54
55
|
export type DragElementKind = 'stroke' | 'shape' | 'measurement';
|
|
55
56
|
export interface DragSelectionConfig {
|
|
56
|
-
hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number): {
|
|
57
|
+
hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): {
|
|
57
58
|
id: AnnotationElementId;
|
|
58
59
|
kind: DragElementKind;
|
|
59
60
|
} | null;
|
|
60
61
|
buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
|
|
61
|
-
classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): 'slide' | 'move';
|
|
62
|
+
classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): 'slide' | 'move';
|
|
62
63
|
buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
|
|
63
64
|
hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
|
|
64
65
|
buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
|
|
@@ -76,7 +77,7 @@ export interface DragSelectionConfig {
|
|
|
76
77
|
fixed: Vec2;
|
|
77
78
|
} | null;
|
|
78
79
|
buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
|
|
79
|
-
isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): boolean;
|
|
80
|
+
isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): boolean;
|
|
80
81
|
hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
|
|
81
82
|
buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
|
|
82
83
|
}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Image, useImage } from '@shopify/react-native-skia';
|
|
2
|
+
import { Group, Image, ImageSVG, Skia, useImage, } from '@shopify/react-native-skia';
|
|
3
3
|
import { memo, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { imageDocRect } from '../viewport.js';
|
|
5
|
-
// Resolve a background image's (possibly expired) storage URL
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
// magnifier overlay) — otherwise each canvas mounts its own loader and flashes
|
|
9
|
-
// a blank while it re-resolves + re-decodes. Returns null until loaded, or when
|
|
10
|
-
// `image` is null/undefined. Hooks run unconditionally regardless of `image`.
|
|
11
|
-
export const useBackgroundSkImage = (image, resolveUrl) => {
|
|
5
|
+
// Resolve a background image's (possibly expired) storage URL. Shared by the
|
|
6
|
+
// raster and SVG loaders below.
|
|
7
|
+
const useBackgroundUrl = (image, resolveUrl) => {
|
|
12
8
|
const [url, setUrl] = useState(image?.downloadUrl ?? '');
|
|
13
9
|
// Hold resolveUrl in a ref so re-resolution is keyed only on the image
|
|
14
10
|
// identity, not the function's. Consumers commonly pass an inline closure
|
|
@@ -33,17 +29,76 @@ export const useBackgroundSkImage = (image, resolveUrl) => {
|
|
|
33
29
|
return () => {
|
|
34
30
|
cancelled = true;
|
|
35
31
|
};
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
33
|
}, [image?.downloadUrl, image?.storagePath]);
|
|
34
|
+
return url;
|
|
35
|
+
};
|
|
36
|
+
// Null-safe SVG loader. NOT RN Skia's useSVG: that hook throws synchronously
|
|
37
|
+
// on a null/undefined source (despite its DataSourceParam type), so it can't
|
|
38
|
+
// be called unconditionally from a component that usually renders raster
|
|
39
|
+
// backgrounds. An empty url resolves to null (nothing to draw yet).
|
|
40
|
+
const useBackgroundSvg = (url) => {
|
|
41
|
+
const [svg, setSvg] = useState(null);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!url) {
|
|
44
|
+
setSvg(null);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
(async () => {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(url);
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
const next = Skia.SVG.MakeFromString(text);
|
|
53
|
+
if (!cancelled)
|
|
54
|
+
setSvg(next);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
console.warn('[BackgroundImageElement] failed to load SVG background', e);
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
return () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
};
|
|
63
|
+
}, [url]);
|
|
64
|
+
return svg;
|
|
65
|
+
};
|
|
66
|
+
// Resolve a background image's URL and decode it to an SkImage. Exported so a
|
|
67
|
+
// consumer can hoist the decode ABOVE the canvas and share one SkImage across
|
|
68
|
+
// multiple canvases (e.g. the main canvas + a magnifier overlay) — otherwise
|
|
69
|
+
// each canvas mounts its own loader and flashes a blank while it re-resolves +
|
|
70
|
+
// re-decodes. Returns null until loaded, or when `image` is null/undefined.
|
|
71
|
+
// Hooks run unconditionally regardless of `image`. Raster only — SVG
|
|
72
|
+
// backgrounds self-load in the element below (parsing an SVG is cheap).
|
|
73
|
+
export const useBackgroundSkImage = (image, resolveUrl) => {
|
|
74
|
+
const url = useBackgroundUrl(image, resolveUrl);
|
|
37
75
|
return useImage(url || null);
|
|
38
76
|
};
|
|
39
77
|
export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, }) => {
|
|
40
|
-
|
|
41
|
-
//
|
|
78
|
+
const isSvg = image.format === 'svg';
|
|
79
|
+
// Self-load only when no pre-decoded image is provided; the hooks still run
|
|
80
|
+
// unconditionally (with a null source, so they no-op) to satisfy hook rules.
|
|
42
81
|
const provided = skImageProp !== undefined;
|
|
43
|
-
const loaded = useBackgroundSkImage(provided ? null : image, resolveUrl);
|
|
82
|
+
const loaded = useBackgroundSkImage(provided || isSvg ? null : image, resolveUrl);
|
|
83
|
+
const svgUrl = useBackgroundUrl(isSvg ? image : null, resolveUrl);
|
|
84
|
+
const svg = useBackgroundSvg(isSvg ? svgUrl : '');
|
|
44
85
|
const skImage = provided ? skImageProp : loaded;
|
|
86
|
+
const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
|
|
87
|
+
if (isSvg) {
|
|
88
|
+
if (!svg)
|
|
89
|
+
return null;
|
|
90
|
+
// Draw the SVG at its stamped intrinsic size inside a scale transform
|
|
91
|
+
// (rather than relying on ImageSVG's own fitting, which only applies to
|
|
92
|
+
// sources with a viewBox). The vector source re-rasterizes per frame, so
|
|
93
|
+
// it stays crisp at any canvas zoom.
|
|
94
|
+
return (_jsx(Group, { transform: [
|
|
95
|
+
{ translateX: dims.x },
|
|
96
|
+
{ translateY: dims.y },
|
|
97
|
+
{ scaleX: dims.width / image.widthPx },
|
|
98
|
+
{ scaleY: dims.height / image.heightPx },
|
|
99
|
+
], children: _jsx(ImageSVG, { svg: svg, x: 0, y: 0, width: image.widthPx, height: image.heightPx }) }));
|
|
100
|
+
}
|
|
45
101
|
if (!skImage)
|
|
46
102
|
return null;
|
|
47
|
-
const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
|
|
48
103
|
return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
|
|
49
104
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { PlacedMeasurementRef } from '../../types/annotation.js';
|
|
1
|
+
import type { AnnotationCanvasState, PlacedMeasurementRef, TileScalePlatform } from '../../types/annotation.js';
|
|
2
2
|
export declare const STAMP_TILE_SIZE = 96;
|
|
3
3
|
export declare const DEFAULT_TILE_SCALE = 1;
|
|
4
4
|
export declare const TILE_SCALE_MIN = 0.4;
|
|
5
5
|
export declare const TILE_SCALE_MAX = 2;
|
|
6
|
+
export declare const resolveTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile">, platform: TileScalePlatform) => number;
|
|
6
7
|
export declare const clampTileScale: (v: number) => number;
|
|
7
8
|
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
|
|
8
9
|
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
|
|
@@ -12,16 +12,28 @@
|
|
|
12
12
|
// user who set the tile-scale slider against blank tiles saw their tiles
|
|
13
13
|
// balloon once populated (Asana 1216233394800417).
|
|
14
14
|
export const STAMP_TILE_SIZE = 96;
|
|
15
|
-
// Document-wide tile scale factor
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
15
|
+
// Document-wide tile scale factor: one knob that shrinks/grows EVERY
|
|
16
|
+
// measurement tile on the canvas at once, on top of each tile's own `scale`.
|
|
17
|
+
// Lets a user pull tiles down on a dense drawing where lines crowd together,
|
|
18
|
+
// or bump them up on a sparse one. Like `scale` it is purely a screen-space
|
|
19
|
+
// multiplier — it does NOT change with zoom. Absent === DEFAULT_TILE_SCALE
|
|
20
|
+
// (visually identical to documents written before the knob existed).
|
|
21
|
+
// Web and mobile persist separate knobs (AnnotationCanvasState.tileScaleFactor
|
|
22
|
+
// vs .tileScaleFactorMobile) — read them through resolveTileScaleFactor below.
|
|
22
23
|
export const DEFAULT_TILE_SCALE = 1;
|
|
23
24
|
export const TILE_SCALE_MIN = 0.4;
|
|
24
25
|
export const TILE_SCALE_MAX = 2;
|
|
26
|
+
// Resolve the document-wide tile-scale knob for a platform. Web and mobile
|
|
27
|
+
// persist separate values because a scale calibrated against a desktop window
|
|
28
|
+
// is wrong on a phone screen (and vice versa): web reads `tileScaleFactor`
|
|
29
|
+
// (also the legacy shared field from before the split); mobile reads its own
|
|
30
|
+
// `tileScaleFactorMobile` and falls back to the shared field, so a pre-split
|
|
31
|
+
// document keeps its current mobile look until a mobile user first moves the
|
|
32
|
+
// slider. The ONE read path for these fields — renderers, hit-testing, and
|
|
33
|
+
// the slider UIs must all resolve through here so they agree.
|
|
34
|
+
export const resolveTileScaleFactor = (canvas, platform) => (platform === 'mobile'
|
|
35
|
+
? (canvas.tileScaleFactorMobile ?? canvas.tileScaleFactor)
|
|
36
|
+
: canvas.tileScaleFactor) ?? DEFAULT_TILE_SCALE;
|
|
25
37
|
// Clamp a tile-scale-factor candidate to the supported range. The single guard
|
|
26
38
|
// for the value before it lands in the document (slider input, restored docs).
|
|
27
39
|
export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
|
|
@@ -54,8 +66,8 @@ export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath
|
|
|
54
66
|
// value. The ONE source of truth for tile footprint — render overlay,
|
|
55
67
|
// hit-test, and slide-grab classification all call this so the drawn tile and
|
|
56
68
|
// its touch box always agree. `tileScaleFactor` is the document-wide knob
|
|
57
|
-
// (default 1,
|
|
58
|
-
//
|
|
59
|
-
// is always 1 — the old per-canvas
|
|
60
|
-
// above). Independent of zoom.
|
|
69
|
+
// (default 1), driven by the "Tile size" slider and already resolved for the
|
|
70
|
+
// caller's platform via resolveTileScaleFactor above. `viewportScale` is
|
|
71
|
+
// retained for call compatibility and is always 1 — the old per-canvas
|
|
72
|
+
// multiplier was retired (see the note above). Independent of zoom.
|
|
61
73
|
export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => STAMP_TILE_SIZE * (m.scale ?? 1) * tileScaleFactor * viewportScale;
|
|
@@ -51,7 +51,7 @@ export const createPanTool = (options = {}) => ({
|
|
|
51
51
|
const measurements = ctx.document.placedMeasurements;
|
|
52
52
|
for (let i = measurements.length - 1; i >= 0; i--) {
|
|
53
53
|
const m = measurements[i];
|
|
54
|
-
if (hitPlacedMeasurement(m, event.world, zoom, ctx.
|
|
54
|
+
if (hitPlacedMeasurement(m, event.world, zoom, ctx.tileScaleFactor, ctx.tileViewportScale)) {
|
|
55
55
|
ctx.setSelection({ ids: [m.id] });
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
@@ -56,11 +56,14 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
|
56
56
|
const dy = py - cy;
|
|
57
57
|
return dx * dx + dy * dy;
|
|
58
58
|
};
|
|
59
|
-
const findHit = (doc, world, zoom, viewportTileScale = 1
|
|
59
|
+
const findHit = (doc, world, zoom, viewportTileScale = 1,
|
|
60
|
+
// Platform-resolved tile-scale knob (ctx.tileScaleFactor). Defaults to the
|
|
61
|
+
// doc's web/legacy field so un-threaded callers keep the pre-split behavior.
|
|
62
|
+
tileScaleFactor = doc.tileScaleFactor) => {
|
|
60
63
|
// Hit-test in z-order (top first): measurements > shapes > strokes.
|
|
61
64
|
for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
|
|
62
65
|
const m = doc.placedMeasurements[i];
|
|
63
|
-
if (hitPlacedMeasurement(m, world, zoom,
|
|
66
|
+
if (hitPlacedMeasurement(m, world, zoom, tileScaleFactor, viewportTileScale)) {
|
|
64
67
|
return { id: m.id, kind: 'measurement' };
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -150,12 +153,11 @@ const translatePatch = (elementKind, id, doc, delta) => {
|
|
|
150
153
|
// is selected; without this, a handle sitting under the tile (a rectangle
|
|
151
154
|
// tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
|
|
152
155
|
// and the tile becomes unmovable until deselected.
|
|
153
|
-
const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
|
|
156
|
+
const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
|
|
154
157
|
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
155
158
|
if (!m)
|
|
156
159
|
return false;
|
|
157
|
-
const half = (stampTileSize(m,
|
|
158
|
-
HIT_PADDING) /
|
|
160
|
+
const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
|
|
159
161
|
zoom;
|
|
160
162
|
return (Math.abs(world.x - m.anchor.x) <= half &&
|
|
161
163
|
Math.abs(world.y - m.anchor.y) <= half);
|
|
@@ -164,15 +166,14 @@ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
|
|
|
164
166
|
// via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
|
|
165
167
|
// Grabbing the tile of a line annotation slides it along the line; everything
|
|
166
168
|
// else (bare stamps, grabs on the line body) is a group move.
|
|
167
|
-
const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1) => {
|
|
169
|
+
const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
|
|
168
170
|
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
169
171
|
if (!m)
|
|
170
172
|
return 'move';
|
|
171
173
|
// Same footprint the tile is drawn at (smaller for unassociated inputs, #6;
|
|
172
174
|
// folds in the document-wide tile scale + viewport scale so slide-grab
|
|
173
175
|
// matches the draw).
|
|
174
|
-
const half = (stampTileSize(m,
|
|
175
|
-
HIT_PADDING) /
|
|
176
|
+
const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
|
|
176
177
|
zoom;
|
|
177
178
|
const onTile = Math.abs(world.x - m.anchor.x) <= half &&
|
|
178
179
|
Math.abs(world.y - m.anchor.y) <= half;
|
|
@@ -480,7 +481,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
480
481
|
// reusing the same hit-test and translate logic the pointer handlers below
|
|
481
482
|
// use for web — one source of truth.
|
|
482
483
|
dragSelection: {
|
|
483
|
-
hitTest: (doc, world, zoom, viewportTileScale) => findHit(doc, world, zoom, viewportTileScale),
|
|
484
|
+
hitTest: (doc, world, zoom, viewportTileScale, tileScaleFactor) => findHit(doc, world, zoom, viewportTileScale, tileScaleFactor),
|
|
484
485
|
buildTranslatePatch: (doc, id, kind, delta) => {
|
|
485
486
|
const op = translatePatch(kind, id, doc, delta);
|
|
486
487
|
return op ? { ops: [op] } : null;
|
|
@@ -515,7 +516,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
515
516
|
// tile stays draggable (otherwise it can only be moved after deselecting).
|
|
516
517
|
const selId = ctx.selection?.ids[0];
|
|
517
518
|
if (selId &&
|
|
518
|
-
!isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
|
|
519
|
+
!isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor)) {
|
|
519
520
|
const handle = findHandleHit(ctx.document, selId, world, zoom);
|
|
520
521
|
if (handle) {
|
|
521
522
|
ctx.setSelection({ ids: [selId] });
|
|
@@ -579,7 +580,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
579
580
|
};
|
|
580
581
|
}
|
|
581
582
|
}
|
|
582
|
-
const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale);
|
|
583
|
+
const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor);
|
|
583
584
|
if (!hit) {
|
|
584
585
|
ctx.setSelection(null);
|
|
585
586
|
// Nothing under the pointer: with panOnEmptyDrag the gesture pans the
|
|
@@ -600,8 +601,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
600
601
|
: null;
|
|
601
602
|
ctx.setSelection({ ids: [hit.id] });
|
|
602
603
|
const mode = hit.kind === 'measurement' &&
|
|
603
|
-
classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
|
|
604
|
-
'slide'
|
|
604
|
+
classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor) === 'slide'
|
|
605
605
|
? 'slide'
|
|
606
606
|
: 'move';
|
|
607
607
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Measurement } from '../../types/firestore.js';
|
|
2
|
-
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type Vec2 } from '../../types/annotation.js';
|
|
2
|
+
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type TileScalePlatform, type Vec2 } from '../../types/annotation.js';
|
|
3
3
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
4
|
import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
|
|
5
5
|
import { type ViewportState } from './viewport.js';
|
|
@@ -15,6 +15,8 @@ export interface AnnotationCanvasHandle {
|
|
|
15
15
|
defaultLengthDoc?: number;
|
|
16
16
|
}): void;
|
|
17
17
|
setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
|
|
18
|
+
centerOnLine(id: AnnotationElementId): void;
|
|
19
|
+
removeMeasurementValue(id: AnnotationElementId): void;
|
|
18
20
|
associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
|
|
19
21
|
bindColumn(id: AnnotationElementId, binding: {
|
|
20
22
|
groupId: string;
|
|
@@ -39,6 +41,7 @@ export interface UseAnnotationCanvasStateProps {
|
|
|
39
41
|
width: number;
|
|
40
42
|
height: number;
|
|
41
43
|
initialViewport?: ViewportState;
|
|
44
|
+
tileScalePlatform?: TileScalePlatform;
|
|
42
45
|
imperativeRef?: {
|
|
43
46
|
current: AnnotationCanvasHandle | null;
|
|
44
47
|
};
|
|
@@ -58,6 +61,7 @@ export interface AnnotationCanvasStateApi {
|
|
|
58
61
|
toolState: ToolState;
|
|
59
62
|
ctx: ToolContext;
|
|
60
63
|
tileViewportScale: number;
|
|
64
|
+
tileScaleFactor: number;
|
|
61
65
|
penDrawingStroke: AnnotationStroke | null;
|
|
62
66
|
customPreviewState: ToolState;
|
|
63
67
|
dispatchPointerDown(event: CanvasPointerEvent): void;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
|
+
import { resolveTileScaleFactor } from './stampLayout.js';
|
|
3
4
|
import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
4
|
-
import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
5
|
+
import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
5
6
|
// The viewport that frames the document's content on screen. When a background
|
|
6
7
|
// image is present we fit its rendered rect (so the whole image shows, filling
|
|
7
8
|
// the viewport regardless of its pixel resolution — never the native-resolution
|
|
@@ -22,7 +23,7 @@ const computeContentFit = (canvas, width, height) => {
|
|
|
22
23
|
// inners share this hook; each wraps it with platform-specific event
|
|
23
24
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
24
25
|
export const useAnnotationCanvasState = (props) => {
|
|
25
|
-
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, imperativeRef, } = props;
|
|
26
|
+
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, imperativeRef, } = props;
|
|
26
27
|
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
27
28
|
const [toolState, setToolState] = useState(undefined);
|
|
28
29
|
const [previewPatch, setPreviewPatch] = useState(null);
|
|
@@ -63,11 +64,17 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
63
64
|
// (hit box) thread it; keeping them in lockstep at 1 means both still agree.
|
|
64
65
|
// (See stampLayout.ts for why the old per-canvas multiplier was retired.)
|
|
65
66
|
const tileViewportScale = 1;
|
|
67
|
+
// The platform's tile-scale knob, resolved once from the effective canvas so
|
|
68
|
+
// tools (hit boxes) and overlays (drawn size) share one value. Uses the
|
|
69
|
+
// effective (preview-applied) canvas for parity with what is on screen; the
|
|
70
|
+
// slider only ever commits, so this matches ctx.document in practice.
|
|
71
|
+
const tileScaleFactor = resolveTileScaleFactor(effectiveCanvas, tileScalePlatform ?? 'web');
|
|
66
72
|
const ctx = useMemo(() => ({
|
|
67
73
|
document: canvas,
|
|
68
74
|
selection,
|
|
69
75
|
viewport: viewportApi,
|
|
70
76
|
tileViewportScale,
|
|
77
|
+
tileScaleFactor,
|
|
71
78
|
preview(patch) {
|
|
72
79
|
setPreviewPatch(patch);
|
|
73
80
|
},
|
|
@@ -100,6 +107,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
100
107
|
selection,
|
|
101
108
|
viewportApi,
|
|
102
109
|
tileViewportScale,
|
|
110
|
+
tileScaleFactor,
|
|
103
111
|
onCommit,
|
|
104
112
|
onSelectionChange,
|
|
105
113
|
pickMeasurement,
|
|
@@ -331,6 +339,46 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
331
339
|
ops: [{ op: 'updateMeasurement', id, patch: { placement: 'none' } }],
|
|
332
340
|
});
|
|
333
341
|
},
|
|
342
|
+
centerOnLine(id) {
|
|
343
|
+
const c = ctxRef.current;
|
|
344
|
+
const m = c.document.placedMeasurements.find((x) => x.id === id);
|
|
345
|
+
if (!m)
|
|
346
|
+
return;
|
|
347
|
+
// Same default-line synthesis as setAnnotationType('line') so centering
|
|
348
|
+
// a floating tile also gives it a line to sit on.
|
|
349
|
+
let line = m.line;
|
|
350
|
+
if (!line) {
|
|
351
|
+
const len = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
|
|
352
|
+
line = {
|
|
353
|
+
a: { x: m.anchor.x - len / 2, y: m.anchor.y },
|
|
354
|
+
b: { x: m.anchor.x + len / 2, y: m.anchor.y },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
c.commit({
|
|
358
|
+
ops: [
|
|
359
|
+
{
|
|
360
|
+
op: 'updateMeasurement',
|
|
361
|
+
id,
|
|
362
|
+
patch: {
|
|
363
|
+
placement: 'line',
|
|
364
|
+
line,
|
|
365
|
+
linePos: 0.5,
|
|
366
|
+
anchor: recomputeAnchor(line, 'line', 0.5, m.anchor),
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
removeMeasurementValue(id) {
|
|
373
|
+
const c = ctxRef.current;
|
|
374
|
+
const m = c.document.placedMeasurements.find((x) => x.id === id);
|
|
375
|
+
if (!m)
|
|
376
|
+
return;
|
|
377
|
+
const { ops, keepSelection } = buildRemoveMeasurementOps(m);
|
|
378
|
+
c.commit({ ops });
|
|
379
|
+
if (!keepSelection)
|
|
380
|
+
c.setSelection(null);
|
|
381
|
+
},
|
|
334
382
|
associateMeasurement(id, ref) {
|
|
335
383
|
const c = ctxRef.current;
|
|
336
384
|
c.commit({
|
|
@@ -342,6 +390,11 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
342
390
|
measurementId: ref.measurementId,
|
|
343
391
|
measurementPath: ref.measurementPath,
|
|
344
392
|
groupId: ref.groupId,
|
|
393
|
+
// A direct association replaces any FORM-column binding — the
|
|
394
|
+
// tile now resolves through the measurement, not
|
|
395
|
+
// group.columns[columnId]. Cleared keys serialize away at the
|
|
396
|
+
// persistence boundary; undo restores the prior binding.
|
|
397
|
+
columnId: undefined,
|
|
345
398
|
labelOverride: ref.label,
|
|
346
399
|
unitOverride: ref.unit,
|
|
347
400
|
},
|
|
@@ -443,6 +496,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
443
496
|
toolState,
|
|
444
497
|
ctx,
|
|
445
498
|
tileViewportScale,
|
|
499
|
+
tileScaleFactor,
|
|
446
500
|
penDrawingStroke,
|
|
447
501
|
customPreviewState: toolState,
|
|
448
502
|
dispatchPointerDown,
|
|
@@ -7,6 +7,14 @@ export interface JobScope {
|
|
|
7
7
|
export interface JobGroupScope extends JobScope {
|
|
8
8
|
groupId: string;
|
|
9
9
|
}
|
|
10
|
+
export interface TemplateScope {
|
|
11
|
+
kind: 'template';
|
|
12
|
+
orgId?: string;
|
|
13
|
+
templateId: string;
|
|
14
|
+
}
|
|
15
|
+
export type AnnotationScope = JobGroupScope | TemplateScope;
|
|
16
|
+
export declare const isTemplateScope: (s: AnnotationScope) => s is TemplateScope;
|
|
17
|
+
export declare const annotationScopeKey: (s: AnnotationScope | null) => string;
|
|
10
18
|
export type Unsubscribe = () => void;
|
|
11
19
|
export type FieldOp = {
|
|
12
20
|
kind: 'serverTimestamp';
|
|
@@ -26,6 +34,9 @@ export declare const isFieldOp: (v: unknown) => v is FieldOp;
|
|
|
26
34
|
export type Patch<T> = {
|
|
27
35
|
[K in keyof T]?: T[K] | FieldOp;
|
|
28
36
|
};
|
|
37
|
+
export type AnnotationFilePatch = Patch<AnnotationFile> & {
|
|
38
|
+
[dotPath: `fileData.${string}`]: unknown;
|
|
39
|
+
};
|
|
29
40
|
export interface ImageBlob {
|
|
30
41
|
data: Blob | ArrayBuffer | string;
|
|
31
42
|
contentType: string;
|
|
@@ -51,15 +62,15 @@ export interface AnnotationFileSummary {
|
|
|
51
62
|
createdBy?: AnnotationFile['createdBy'];
|
|
52
63
|
}
|
|
53
64
|
export interface AnnotationDataProvider {
|
|
54
|
-
create(scope:
|
|
55
|
-
get(scope:
|
|
56
|
-
update(scope:
|
|
57
|
-
delete(scope:
|
|
58
|
-
subscribe(scope:
|
|
59
|
-
list(scope:
|
|
65
|
+
create(scope: AnnotationScope, seed: Partial<AnnotationFile>): Promise<string>;
|
|
66
|
+
get(scope: AnnotationScope, fileId: string): Promise<AnnotationFile | null>;
|
|
67
|
+
update(scope: AnnotationScope, fileId: string, patch: AnnotationFilePatch): Promise<void>;
|
|
68
|
+
delete(scope: AnnotationScope, fileId: string): Promise<void>;
|
|
69
|
+
subscribe(scope: AnnotationScope, fileId: string, onNext: (doc: AnnotationFile | null) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
70
|
+
list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
60
71
|
subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
61
72
|
subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
62
|
-
uploadImage(scope:
|
|
63
|
-
getImageUrl(scope:
|
|
64
|
-
deleteImage(scope:
|
|
73
|
+
uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
|
|
74
|
+
getImageUrl(scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
|
|
75
|
+
deleteImage(scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
|
|
65
76
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export const isTemplateScope = (s) => 'kind' in s && s.kind === 'template';
|
|
2
|
+
// Stable string identity for a scope, for React hook dep arrays — scope
|
|
3
|
+
// objects are typically rebuilt every render.
|
|
4
|
+
export const annotationScopeKey = (s) => s == null
|
|
5
|
+
? ''
|
|
6
|
+
: isTemplateScope(s)
|
|
7
|
+
? `template/${s.orgId ?? ''}/${s.templateId}`
|
|
8
|
+
: `job/${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
|
|
1
9
|
export const isFieldOp = (v) => !!v &&
|
|
2
10
|
typeof v === 'object' &&
|
|
3
11
|
'kind' in v &&
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Measurement } from '../../types/firestore.js';
|
|
2
|
-
import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFileSummary, type
|
|
2
|
+
import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type ImageBlob, type JobGroupScope, type JobScope, type Unsubscribe, type UploadedImageRef } from './AnnotationDataProvider.js';
|
|
3
3
|
export declare class InMemoryAnnotationProvider implements AnnotationDataProvider {
|
|
4
4
|
private docs;
|
|
5
5
|
private measurements;
|
|
@@ -10,17 +10,17 @@ export declare class InMemoryAnnotationProvider implements AnnotationDataProvide
|
|
|
10
10
|
private jobMeasurementListeners;
|
|
11
11
|
private nextId;
|
|
12
12
|
setMeasurements(scope: JobGroupScope, measurements: Measurement[]): void;
|
|
13
|
-
create(scope:
|
|
14
|
-
get(scope:
|
|
15
|
-
update(scope:
|
|
16
|
-
delete(scope:
|
|
17
|
-
subscribe(scope:
|
|
18
|
-
list(scope:
|
|
13
|
+
create(scope: AnnotationScope, seed: Partial<AnnotationFile>): Promise<string>;
|
|
14
|
+
get(scope: AnnotationScope, fileId: string): Promise<AnnotationFile | null>;
|
|
15
|
+
update(scope: AnnotationScope, fileId: string, patch: AnnotationFilePatch): Promise<void>;
|
|
16
|
+
delete(scope: AnnotationScope, fileId: string): Promise<void>;
|
|
17
|
+
subscribe(scope: AnnotationScope, fileId: string, onNext: (doc: AnnotationFile | null) => void): Unsubscribe;
|
|
18
|
+
list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void): Unsubscribe;
|
|
19
19
|
subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
|
|
20
20
|
subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
|
|
21
|
-
uploadImage(scope:
|
|
22
|
-
getImageUrl(_scope:
|
|
23
|
-
deleteImage(_scope:
|
|
21
|
+
uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
|
|
22
|
+
getImageUrl(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
|
|
23
|
+
deleteImage(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
|
|
24
24
|
private getBucket;
|
|
25
25
|
private notifyDoc;
|
|
26
26
|
private notifyList;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { FileUploadType } from '../../types/firestore.js';
|
|
2
|
-
import { isFieldOp, } from './AnnotationDataProvider.js';
|
|
3
|
-
const scopeKey =
|
|
4
|
-
|
|
2
|
+
import { annotationScopeKey, isFieldOp, } from './AnnotationDataProvider.js';
|
|
3
|
+
const scopeKey = annotationScopeKey;
|
|
4
|
+
// Prefix of annotationScopeKey for job scopes, so collectJobMeasurements can
|
|
5
|
+
// match group keys by startsWith.
|
|
6
|
+
const jobKey = (s) => `job/${s.orgId}/${s.projectId}/${s.jobId}`;
|
|
5
7
|
const summarize = (file) => ({
|
|
6
8
|
id: file.id,
|
|
7
9
|
name: file.name,
|
|
@@ -37,6 +39,30 @@ const resolveFieldOp = (op) => {
|
|
|
37
39
|
return undefined;
|
|
38
40
|
}
|
|
39
41
|
};
|
|
42
|
+
// Firestore-style dotted field paths: 'fileData.canvas' merges into the
|
|
43
|
+
// nested map (creating intermediate maps as needed) instead of replacing the
|
|
44
|
+
// whole top-level field — mirrors what the real SDKs do with dotted keys.
|
|
45
|
+
const applyDotPaths = (base, resolved) => {
|
|
46
|
+
const next = { ...base };
|
|
47
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
48
|
+
if (!key.includes('.')) {
|
|
49
|
+
next[key] = value;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const parts = key.split('.');
|
|
53
|
+
let target = next;
|
|
54
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
55
|
+
const existing = target[parts[i]];
|
|
56
|
+
target[parts[i]] =
|
|
57
|
+
existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
58
|
+
? { ...existing }
|
|
59
|
+
: {};
|
|
60
|
+
target = target[parts[i]];
|
|
61
|
+
}
|
|
62
|
+
target[parts[parts.length - 1]] = value;
|
|
63
|
+
}
|
|
64
|
+
return next;
|
|
65
|
+
};
|
|
40
66
|
// Simple test/dev provider. Stores documents and image blobs in memory and
|
|
41
67
|
// fans out subscription notifications synchronously. Not designed for
|
|
42
68
|
// performance — designed for predictable behavior in tests and Storybook.
|
|
@@ -86,7 +112,7 @@ export class InMemoryAnnotationProvider {
|
|
|
86
112
|
if (!prev)
|
|
87
113
|
throw new Error(`Annotation file ${fileId} not found`);
|
|
88
114
|
const resolved = applyFieldOps(patch);
|
|
89
|
-
const next =
|
|
115
|
+
const next = applyDotPaths(prev, resolved);
|
|
90
116
|
bucket.set(fileId, next);
|
|
91
117
|
this.notifyDoc(scope, fileId);
|
|
92
118
|
this.notifyList(scope);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport, type BackgroundFit } from '../../../types/annotation.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { AnnotationScope, ImageBlob } from '../AnnotationDataProvider.js';
|
|
3
3
|
export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
|
4
4
|
export interface UseAnnotationCanvasDocOptions {
|
|
5
|
-
scope:
|
|
5
|
+
scope: AnnotationScope | null;
|
|
6
6
|
fileId: string | null;
|
|
7
7
|
fallbackViewport?: Partial<AnnotationViewport>;
|
|
8
8
|
debounceMs?: number;
|
|
@@ -223,10 +223,17 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
223
223
|
}
|
|
224
224
|
else {
|
|
225
225
|
const doc = dataRef.current;
|
|
226
|
+
const fileType = doc?.fileData.fileType ?? createSeedRef.current?.fileType ?? 'sketch';
|
|
227
|
+
const isLabel = doc?.fileData.isLabel ?? createSeedRef.current?.isLabel;
|
|
228
|
+
// Dotted field paths so the write MERGES into fileData instead of
|
|
229
|
+
// replacing the map — the doc may carry sibling fileData keys the
|
|
230
|
+
// canvas doesn't own (a calculator file's columns/tableConfig/
|
|
231
|
+
// isCompleted, …) which a whole-map write would silently delete.
|
|
226
232
|
await updateRef.current(id, {
|
|
227
|
-
fileData:
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
'fileData.fileType': fileType,
|
|
234
|
+
...(isLabel !== undefined ? { 'fileData.isLabel': isLabel } : {}),
|
|
235
|
+
'fileData.canvas': canvasPayload,
|
|
236
|
+
'fileData.canvasRev': canvasRev,
|
|
230
237
|
});
|
|
231
238
|
if (debug) {
|
|
232
239
|
console.log('[useAnnotationCanvasDoc] updated file', id);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AnnotationFile, type AnnotationScope } from '../AnnotationDataProvider.js';
|
|
2
2
|
export interface UseAnnotationDocResult {
|
|
3
3
|
data: AnnotationFile | null;
|
|
4
4
|
loading: boolean;
|
|
5
5
|
error: Error | null;
|
|
6
6
|
}
|
|
7
|
-
export declare const useAnnotationDoc: (scope:
|
|
7
|
+
export declare const useAnnotationDoc: (scope: AnnotationScope | null, fileId: string | null) => UseAnnotationDocResult;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { useAnnotationData } from '../AnnotationDataContext.js';
|
|
3
|
+
import { annotationScopeKey, } from '../AnnotationDataProvider.js';
|
|
3
4
|
export const useAnnotationDoc = (scope, fileId) => {
|
|
4
5
|
const provider = useAnnotationData();
|
|
5
6
|
const [data, setData] = useState(null);
|
|
@@ -21,13 +22,7 @@ export const useAnnotationDoc = (scope, fileId) => {
|
|
|
21
22
|
setLoading(false);
|
|
22
23
|
});
|
|
23
24
|
return unsubscribe;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
scope?.orgId,
|
|
27
|
-
scope?.projectId,
|
|
28
|
-
scope?.jobId,
|
|
29
|
-
scope?.groupId,
|
|
30
|
-
fileId,
|
|
31
|
-
]);
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
26
|
+
}, [provider, annotationScopeKey(scope), fileId]);
|
|
32
27
|
return { data, loading, error };
|
|
33
28
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AnnotationFileSummary, type AnnotationScope } from '../AnnotationDataProvider.js';
|
|
2
2
|
export interface UseAnnotationListResult {
|
|
3
3
|
files: AnnotationFileSummary[];
|
|
4
4
|
loading: boolean;
|
|
5
5
|
error: Error | null;
|
|
6
6
|
}
|
|
7
|
-
export declare const useAnnotationList: (scope:
|
|
7
|
+
export declare const useAnnotationList: (scope: AnnotationScope | null) => UseAnnotationListResult;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { useAnnotationData } from '../AnnotationDataContext.js';
|
|
3
|
+
import { annotationScopeKey, } from '../AnnotationDataProvider.js';
|
|
3
4
|
export const useAnnotationList = (scope) => {
|
|
4
5
|
const provider = useAnnotationData();
|
|
5
6
|
const [files, setFiles] = useState([]);
|
|
@@ -21,6 +22,7 @@ export const useAnnotationList = (scope) => {
|
|
|
21
22
|
setLoading(false);
|
|
22
23
|
});
|
|
23
24
|
return unsubscribe;
|
|
24
|
-
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
26
|
+
}, [provider, annotationScopeKey(scope)]);
|
|
25
27
|
return { files, loading, error };
|
|
26
28
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AnnotationFile, type AnnotationFilePatch, type AnnotationScope, type ImageBlob, type UploadedImageRef } from '../AnnotationDataProvider.js';
|
|
2
2
|
export interface AnnotationMutations {
|
|
3
3
|
create(seed: Partial<AnnotationFile>): Promise<string>;
|
|
4
|
-
update(fileId: string, patch:
|
|
4
|
+
update(fileId: string, patch: AnnotationFilePatch): Promise<void>;
|
|
5
5
|
remove(fileId: string): Promise<void>;
|
|
6
6
|
uploadImage(fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
|
|
7
7
|
deleteImage(fileId: string, storagePath: string): Promise<void>;
|
|
8
8
|
}
|
|
9
|
-
export declare const useAnnotationMutations: (scope:
|
|
9
|
+
export declare const useAnnotationMutations: (scope: AnnotationScope) => AnnotationMutations;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { useCallback, useMemo } from 'react';
|
|
2
2
|
import { useAnnotationData } from '../AnnotationDataContext.js';
|
|
3
|
+
import { annotationScopeKey, } from '../AnnotationDataProvider.js';
|
|
3
4
|
export const useAnnotationMutations = (scope) => {
|
|
4
5
|
const provider = useAnnotationData();
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
6
|
+
const scopeKey = annotationScopeKey(scope);
|
|
7
|
+
/* eslint-disable react-hooks/exhaustive-deps */
|
|
8
|
+
const create = useCallback((seed) => provider.create(scope, seed), [provider, scopeKey]);
|
|
9
|
+
const update = useCallback((fileId, patch) => provider.update(scope, fileId, patch), [provider, scopeKey]);
|
|
10
|
+
const remove = useCallback((fileId) => provider.delete(scope, fileId), [provider, scopeKey]);
|
|
11
|
+
const uploadImage = useCallback((fileId, role, blob) => provider.uploadImage(scope, fileId, role, blob), [provider, scopeKey]);
|
|
12
|
+
const deleteImage = useCallback((fileId, storagePath) => provider.deleteImage(scope, fileId, storagePath), [provider, scopeKey]);
|
|
13
|
+
/* eslint-enable react-hooks/exhaustive-deps */
|
|
10
14
|
return useMemo(() => ({ create, update, remove, uploadImage, deleteImage }), [create, update, remove, uploadImage, deleteImage]);
|
|
11
15
|
};
|
package/dist/exports.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export * from './types/annotation.js';
|
|
|
9
9
|
export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, generateToleranceGradient, createDefaultToleranceThresholds, DEFAULT_TOLERANCE_COLORS, type ToleranceThreshold, type ToleranceConfig, } from './utils/tolerance.js';
|
|
10
10
|
export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
|
|
11
11
|
export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
|
|
12
|
-
export { isFieldOp, type AnnotationDataProvider, type AnnotationFile, type AnnotationFileSummary, type FieldOp, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type Unsubscribe, type UploadedImageRef, } from './annotation/data/AnnotationDataProvider.js';
|
|
12
|
+
export { annotationScopeKey, isFieldOp, isTemplateScope, type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type FieldOp, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type TemplateScope, type Unsubscribe, type UploadedImageRef, } from './annotation/data/AnnotationDataProvider.js';
|
|
13
13
|
export { AnnotationDataProviderContext, useAnnotationData, type AnnotationDataProviderProps, } from './annotation/data/AnnotationDataContext.js';
|
|
14
14
|
export { useAnnotationDoc, type UseAnnotationDocResult, } from './annotation/data/hooks/useAnnotationDoc.js';
|
|
15
15
|
export { useAnnotationList, type UseAnnotationListResult, } from './annotation/data/hooks/useAnnotationList.js';
|
|
@@ -22,7 +22,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
|
|
|
22
22
|
export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
|
|
23
23
|
export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
|
|
24
24
|
export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
|
|
25
|
-
export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
|
|
25
|
+
export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
|
|
26
26
|
export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
|
|
27
27
|
export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
|
|
28
28
|
export { createSelectTool, type SelectToolOptions, } from './annotation/canvas/tools/selectTool.js';
|
package/dist/exports.js
CHANGED
|
@@ -14,7 +14,7 @@ export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, gen
|
|
|
14
14
|
export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
|
|
15
15
|
export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
|
|
16
16
|
// Annotation data layer (SDK-neutral; apps provide their own provider).
|
|
17
|
-
export { isFieldOp, } from './annotation/data/AnnotationDataProvider.js';
|
|
17
|
+
export { annotationScopeKey, isFieldOp, isTemplateScope, } from './annotation/data/AnnotationDataProvider.js';
|
|
18
18
|
export { AnnotationDataProviderContext, useAnnotationData, } from './annotation/data/AnnotationDataContext.js';
|
|
19
19
|
export { useAnnotationDoc, } from './annotation/data/hooks/useAnnotationDoc.js';
|
|
20
20
|
export { useAnnotationList, } from './annotation/data/hooks/useAnnotationList.js';
|
|
@@ -22,7 +22,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
|
|
|
22
22
|
export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
|
|
23
23
|
export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
|
|
24
24
|
export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
|
|
25
|
-
export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
|
|
25
|
+
export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
|
|
26
26
|
export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
|
|
27
27
|
export { createPenTool, } from './annotation/canvas/tools/penTool.js';
|
|
28
28
|
export { createSelectTool, } from './annotation/canvas/tools/selectTool.js';
|
|
@@ -93,6 +93,7 @@ export interface AnnotationBackgroundImage {
|
|
|
93
93
|
downloadUrl: string;
|
|
94
94
|
widthPx: number;
|
|
95
95
|
heightPx: number;
|
|
96
|
+
format?: 'raster' | 'svg';
|
|
96
97
|
}
|
|
97
98
|
export interface AnnotationViewport {
|
|
98
99
|
width: number;
|
|
@@ -100,6 +101,7 @@ export interface AnnotationViewport {
|
|
|
100
101
|
backgroundImage?: AnnotationBackgroundImage;
|
|
101
102
|
backgroundFit?: BackgroundFit;
|
|
102
103
|
}
|
|
104
|
+
export type TileScalePlatform = 'web' | 'mobile';
|
|
103
105
|
export interface AnnotationCanvasState {
|
|
104
106
|
schemaVersion: 1;
|
|
105
107
|
layers: AnnotationLayer[];
|
|
@@ -108,6 +110,7 @@ export interface AnnotationCanvasState {
|
|
|
108
110
|
shapes: AnnotationShape[];
|
|
109
111
|
placedMeasurements: PlacedMeasurementRef[];
|
|
110
112
|
tileScaleFactor?: number;
|
|
113
|
+
tileScaleFactorMobile?: number;
|
|
111
114
|
externalPayloadPath?: string;
|
|
112
115
|
}
|
|
113
116
|
export type AnnotationElement = (AnnotationStroke & {
|
|
@@ -156,6 +159,7 @@ export type AnnotationPatchOp = {
|
|
|
156
159
|
} | {
|
|
157
160
|
op: 'setTileScaleFactor';
|
|
158
161
|
value: number;
|
|
162
|
+
platform?: TileScalePlatform;
|
|
159
163
|
} | {
|
|
160
164
|
op: 'setLayers';
|
|
161
165
|
layers: AnnotationLayer[];
|
package/dist/types/annotation.js
CHANGED
|
@@ -74,7 +74,9 @@ const applyOp = (state, op) => {
|
|
|
74
74
|
case 'setViewport':
|
|
75
75
|
return { ...state, viewport: { ...state.viewport, ...op.patch } };
|
|
76
76
|
case 'setTileScaleFactor':
|
|
77
|
-
return
|
|
77
|
+
return op.platform === 'mobile'
|
|
78
|
+
? { ...state, tileScaleFactorMobile: op.value }
|
|
79
|
+
: { ...state, tileScaleFactor: op.value };
|
|
78
80
|
case 'setLayers':
|
|
79
81
|
return { ...state, layers: op.layers };
|
|
80
82
|
}
|
|
@@ -150,9 +152,17 @@ const invertOp = (before, op) => {
|
|
|
150
152
|
return { op: 'setViewport', patch: inversePatch };
|
|
151
153
|
}
|
|
152
154
|
case 'setTileScaleFactor':
|
|
153
|
-
// Restore the prior factor
|
|
154
|
-
//
|
|
155
|
-
|
|
155
|
+
// Restore the prior EFFECTIVE factor for the same platform knob. Mobile
|
|
156
|
+
// falls back to the shared/web value when it has no own value yet (the
|
|
157
|
+
// pre-split doc case — mirrors resolveTileScaleFactor); absent → an
|
|
158
|
+
// explicit 1 (visually identical), keeping the `value: number` contract.
|
|
159
|
+
return {
|
|
160
|
+
op: 'setTileScaleFactor',
|
|
161
|
+
value: (op.platform === 'mobile'
|
|
162
|
+
? (before.tileScaleFactorMobile ?? before.tileScaleFactor)
|
|
163
|
+
: before.tileScaleFactor) ?? 1,
|
|
164
|
+
...(op.platform && { platform: op.platform }),
|
|
165
|
+
};
|
|
156
166
|
case 'setLayers':
|
|
157
167
|
return { op: 'setLayers', layers: before.layers };
|
|
158
168
|
}
|
|
@@ -126,6 +126,11 @@ export interface CalculatorFileData {
|
|
|
126
126
|
diagramFileId: string | null;
|
|
127
127
|
columns: Record<string, any>;
|
|
128
128
|
isCompleted: boolean;
|
|
129
|
+
canvas?: AnnotationCanvasState;
|
|
130
|
+
canvasRev?: {
|
|
131
|
+
clientId: string;
|
|
132
|
+
seq: number;
|
|
133
|
+
};
|
|
129
134
|
}
|
|
130
135
|
export interface LayoutGroupFileData {
|
|
131
136
|
layoutCount?: number;
|
|
@@ -184,7 +189,7 @@ export type FileUpload = FileUploadBase & ({
|
|
|
184
189
|
fileData?: undefined;
|
|
185
190
|
} | {
|
|
186
191
|
type: FileUploadType.Template;
|
|
187
|
-
fileData?:
|
|
192
|
+
fileData?: AnnotationFileData;
|
|
188
193
|
} | {
|
|
189
194
|
type: FileUploadType.Label;
|
|
190
195
|
fileData: Label;
|