@reekon-tools/boldr-utils 1.6.17 → 1.6.19
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.js +7 -1
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +58 -5
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +5 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +53 -4
- package/dist/annotation/canvas/Tool.d.ts +5 -2
- package/dist/annotation/canvas/measurementGeometry.d.ts +1 -1
- package/dist/annotation/canvas/measurementGeometry.js +3 -2
- package/dist/annotation/canvas/stampLayout.d.ts +7 -1
- package/dist/annotation/canvas/stampLayout.js +66 -5
- package/dist/annotation/canvas/tools/panTool.js +1 -1
- package/dist/annotation/canvas/tools/selectTool.js +75 -9
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +1 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +9 -0
- package/dist/exports.d.ts +1 -0
- package/dist/exports.js +1 -0
- package/dist/types/annotation.d.ts +4 -0
- package/dist/types/annotation.js +6 -0
- package/dist/types/firestore.d.ts +54 -0
- package/dist/types/firestore.js +49 -0
- package/package.json +1 -1
|
@@ -9,6 +9,9 @@ import { stampTileSize } from './stampLayout.js';
|
|
|
9
9
|
// Screen-px radius of a measurement-annotation endpoint handle (matches the
|
|
10
10
|
// native HANDLE_RADIUS_PX). Divided by zoom for a constant on-screen size.
|
|
11
11
|
const HANDLE_PX = 7;
|
|
12
|
+
// Screen-px stroke width of the handle's colored ring (matches native
|
|
13
|
+
// HANDLE_RING_PX); the white-disc + ring keeps the knob legible on any line.
|
|
14
|
+
const HANDLE_RING_PX = 2;
|
|
12
15
|
const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
|
|
13
16
|
export const AnnotationCanvasInner = (props) => {
|
|
14
17
|
const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
|
|
@@ -206,13 +209,16 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
206
209
|
handleRadius: activeTool?.dragSelection
|
|
207
210
|
? HANDLE_PX / state.viewport.zoom
|
|
208
211
|
: undefined,
|
|
212
|
+
handleRingWidth: activeTool?.dragSelection
|
|
213
|
+
? HANDLE_RING_PX / state.viewport.zoom
|
|
214
|
+
: undefined,
|
|
209
215
|
customPreview,
|
|
210
216
|
}), renderMeasurementStamp && (_jsx("div", { style: {
|
|
211
217
|
position: 'absolute',
|
|
212
218
|
inset: 0,
|
|
213
219
|
pointerEvents: 'none',
|
|
214
220
|
}, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
|
|
215
|
-
const size = stampTileSize(placed);
|
|
221
|
+
const size = stampTileSize(placed, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale);
|
|
216
222
|
const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
|
|
217
223
|
const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
|
|
218
224
|
const isSelected = selection?.ids.includes(placed.id) ?? false;
|
|
@@ -15,6 +15,9 @@ const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++
|
|
|
15
15
|
// Screen-px radius of a measurement-annotation endpoint handle dot. Divided by
|
|
16
16
|
// the live zoom so the handle is a constant on-screen size.
|
|
17
17
|
const HANDLE_RADIUS_PX = 7;
|
|
18
|
+
// Screen-px stroke width of the handle's colored ring (white-disc + ring, so the
|
|
19
|
+
// knob stays legible over a line of any color). Also zoom-divided.
|
|
20
|
+
const HANDLE_RING_PX = 2;
|
|
18
21
|
// Native fingerprint: one finger drives the active tool, two fingers
|
|
19
22
|
// pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
|
|
20
23
|
// like measurement-stamp (which only listen to onPointerUp) work via tap.
|
|
@@ -315,6 +318,13 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
315
318
|
const [epDragId, setEpDragId] = useState(null);
|
|
316
319
|
const epCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5, handle: 0 });
|
|
317
320
|
const epTargetRef = useRef(null);
|
|
321
|
+
// Endpoint drag of a line/arrow SHAPE (vs. a measurement line above).
|
|
322
|
+
// `shapeEpDragId` (React state) tells the Skia layer to render that shape's
|
|
323
|
+
// line from the live endpoints; the live geometry rides the SAME `epCtx` +
|
|
324
|
+
// `liveLineP1`/`liveLineP2` shared values (only one endpoint drag is ever in
|
|
325
|
+
// flight). A separate target ref keys the commit to buildShapeEndpointPatch.
|
|
326
|
+
const [shapeEpDragId, setShapeEpDragId] = useState(null);
|
|
327
|
+
const shapeEpTargetRef = useRef(null);
|
|
318
328
|
// Live line endpoints during an endpoint drag: the grabbed handle follows the
|
|
319
329
|
// finger (dragX/dragY); the other stays put. Fed to the Skia <Line> for the
|
|
320
330
|
// dragged annotation.
|
|
@@ -338,6 +348,10 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
338
348
|
'worklet';
|
|
339
349
|
return HANDLE_RADIUS_PX / zoom.value;
|
|
340
350
|
});
|
|
351
|
+
const handleRingWidth = useDerivedValue(() => {
|
|
352
|
+
'worklet';
|
|
353
|
+
return HANDLE_RING_PX / zoom.value;
|
|
354
|
+
});
|
|
341
355
|
// Rectangle-annotation corner drag. `rectDragId` (React state) marks which
|
|
342
356
|
// annotation's rect renders from the live geometry; `rectCtx` carries the
|
|
343
357
|
// fixed (opposite) corner and the grabbed corner's start position so the
|
|
@@ -758,6 +772,27 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
758
772
|
return;
|
|
759
773
|
}
|
|
760
774
|
}
|
|
775
|
+
// Endpoint handle of a line/arrow shape — selected-element-only, and
|
|
776
|
+
// distinct from the measurement-line handle above (different storage).
|
|
777
|
+
const shapeHandle = cfg.hitTestShapeHandle?.(st.ctx.document, selId, world, zoomNow);
|
|
778
|
+
if (shapeHandle) {
|
|
779
|
+
const s = st.ctx.document.shapes.find((x) => x.id === selId);
|
|
780
|
+
const a = s?.geometry.points[0];
|
|
781
|
+
const b = s?.geometry.points[1];
|
|
782
|
+
if (a && b) {
|
|
783
|
+
epCtx.value = {
|
|
784
|
+
ax: a.x,
|
|
785
|
+
ay: a.y,
|
|
786
|
+
bx: b.x,
|
|
787
|
+
by: b.y,
|
|
788
|
+
t0: 0.5,
|
|
789
|
+
handle: shapeHandle === 'a' ? 0 : 1,
|
|
790
|
+
};
|
|
791
|
+
shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
|
|
792
|
+
setShapeEpDragId(selId);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
761
796
|
// Corner resize handle (text shapes) — also selected-element-only.
|
|
762
797
|
const resizeGeom = cfg.hitTestResizeHandle?.(st.ctx.document, selId, world, zoomNow);
|
|
763
798
|
if (resizeGeom) {
|
|
@@ -787,7 +822,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
787
822
|
return;
|
|
788
823
|
}
|
|
789
824
|
}
|
|
790
|
-
const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
|
|
825
|
+
const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
|
|
791
826
|
if (!hit) {
|
|
792
827
|
st.ctx.setSelection(null);
|
|
793
828
|
dragTargetRef.current = null;
|
|
@@ -796,7 +831,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
796
831
|
st.ctx.setSelection({ ids: [hit.id] });
|
|
797
832
|
// Grabbing a line annotation's tile slides it; otherwise group-move.
|
|
798
833
|
const grab = hit.kind === 'measurement'
|
|
799
|
-
? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow)
|
|
834
|
+
? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale)
|
|
800
835
|
: 'move';
|
|
801
836
|
if (grab === 'slide') {
|
|
802
837
|
const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
|
|
@@ -859,6 +894,18 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
859
894
|
setEpDragId(null);
|
|
860
895
|
return;
|
|
861
896
|
}
|
|
897
|
+
// Shape endpoint commit: move the grabbed endpoint by the world delta.
|
|
898
|
+
const sepT = shapeEpTargetRef.current;
|
|
899
|
+
if (sepT) {
|
|
900
|
+
if (dx !== 0 || dy !== 0) {
|
|
901
|
+
const patch = cfg.buildShapeEndpointPatch?.(st.ctx.document, sepT.id, sepT.handle, { x: dx, y: dy });
|
|
902
|
+
if (patch)
|
|
903
|
+
st.ctx.commit(patch);
|
|
904
|
+
}
|
|
905
|
+
shapeEpTargetRef.current = null;
|
|
906
|
+
setShapeEpDragId(null);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
862
909
|
// Slide commit: map the final world delta to a clamped/snapped linePos.
|
|
863
910
|
const slideT = slideTargetRef.current;
|
|
864
911
|
if (slideT) {
|
|
@@ -891,11 +938,13 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
891
938
|
dragTargetRef.current = null;
|
|
892
939
|
slideTargetRef.current = null;
|
|
893
940
|
epTargetRef.current = null;
|
|
941
|
+
shapeEpTargetRef.current = null;
|
|
894
942
|
resizeTargetRef.current = null;
|
|
895
943
|
rectTargetRef.current = null;
|
|
896
944
|
setDraggingId(null);
|
|
897
945
|
setSlidingId(null);
|
|
898
946
|
setEpDragId(null);
|
|
947
|
+
setShapeEpDragId(null);
|
|
899
948
|
setResizingId(null);
|
|
900
949
|
setRectDragId(null);
|
|
901
950
|
};
|
|
@@ -1018,6 +1067,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1018
1067
|
resizeTransform,
|
|
1019
1068
|
selectedId: selection?.ids[0] ?? null,
|
|
1020
1069
|
endpointDragId: epDragId,
|
|
1070
|
+
shapeEndpointDragId: shapeEpDragId,
|
|
1021
1071
|
liveLineP1,
|
|
1022
1072
|
liveLineP2,
|
|
1023
1073
|
rectDragId,
|
|
@@ -1033,18 +1083,21 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1033
1083
|
// measurement still shows its tile chrome but must not advertise
|
|
1034
1084
|
// draggability).
|
|
1035
1085
|
handleRadius: activeTool?.dragSelection ? handleRadius : undefined,
|
|
1086
|
+
handleRingWidth: activeTool?.dragSelection
|
|
1087
|
+
? handleRingWidth
|
|
1088
|
+
: undefined,
|
|
1036
1089
|
customPreview,
|
|
1037
1090
|
}) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
|
|
1038
1091
|
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
1039
|
-
: 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, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
|
|
1092
|
+
: 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.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
|
|
1040
1093
|
const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
|
|
1041
1094
|
state.ctx.commit({ ops });
|
|
1042
1095
|
if (!keepSelection)
|
|
1043
1096
|
state.ctx.setSelection(null);
|
|
1044
1097
|
} }, placed.id))) }))] }));
|
|
1045
1098
|
};
|
|
1046
|
-
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onStampLongPress, onRemove, }) => {
|
|
1047
|
-
const size = stampTileSize(placed);
|
|
1099
|
+
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
|
|
1100
|
+
const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
|
|
1048
1101
|
const half = size / 2;
|
|
1049
1102
|
const anchorX = placed.anchor.x;
|
|
1050
1103
|
const anchorY = placed.anchor.y;
|
|
@@ -59,6 +59,7 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
59
59
|
};
|
|
60
60
|
selectedId?: string | null;
|
|
61
61
|
endpointDragId?: string | null;
|
|
62
|
+
shapeEndpointDragId?: string | null;
|
|
62
63
|
liveLineP1?: AnimatedPoint;
|
|
63
64
|
liveLineP2?: AnimatedPoint;
|
|
64
65
|
rectDragId?: string | null;
|
|
@@ -71,7 +72,10 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
71
72
|
handleRadius?: number | {
|
|
72
73
|
value: number;
|
|
73
74
|
};
|
|
75
|
+
handleRingWidth?: number | {
|
|
76
|
+
value: number;
|
|
77
|
+
};
|
|
74
78
|
customPreview?: ReactNode;
|
|
75
79
|
}
|
|
76
|
-
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
80
|
+
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, handleRingWidth, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
77
81
|
export {};
|
|
@@ -22,6 +22,12 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
|
|
|
22
22
|
// hit-test the text resize handle, which sits on the padded box corner.
|
|
23
23
|
const SELECTION_COLOR = '#0066FF';
|
|
24
24
|
const SELECTION_STROKE = 1.5;
|
|
25
|
+
// A draggable selection handle (line endpoints, rect corners, text resize):
|
|
26
|
+
// a white disc with a colored ring, so it reads as a distinct, grabbable knob
|
|
27
|
+
// over a line of ANY color — including the default measurement-line blue, which
|
|
28
|
+
// a solid-colored handle would camouflage into. `c`/`r`/`ringWidth` may be
|
|
29
|
+
// Reanimated values (native live-drag) or plain numbers (web).
|
|
30
|
+
const Handle = ({ c, r, ringWidth, color = MEASUREMENT_HANDLE_COLOR, }) => (_jsxs(_Fragment, { children: [_jsx(Circle, { c: c, r: r, color: "#FFFFFF" }), _jsx(Circle, { c: c, r: r, color: color, style: "stroke", strokeWidth: ringWidth })] }));
|
|
25
31
|
// Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
|
|
26
32
|
const strokeBounds = (points) => {
|
|
27
33
|
if (points.length < 2)
|
|
@@ -86,7 +92,46 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
|
|
|
86
92
|
// since the function-call pattern works identically on native we use it
|
|
87
93
|
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
88
94
|
// JSX-returning helper, not a component.
|
|
89
|
-
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _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 })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) =>
|
|
95
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, handleRingWidth, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _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 })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
|
|
96
|
+
// Line/arrow shapes support endpoint editing. When selected they show
|
|
97
|
+
// grab handles at both ends; during an endpoint drag the line renders
|
|
98
|
+
// from the live endpoints (one following the finger) — the shape twin
|
|
99
|
+
// of the measurement-line endpoint drag below. Other shapes, and
|
|
100
|
+
// unselected lines, fall through to the plain ShapeElement render so
|
|
101
|
+
// their memoization is preserved.
|
|
102
|
+
if (shape.kind === 'line' || shape.kind === 'arrow') {
|
|
103
|
+
const isEndpointDrag = shape.id === shapeEndpointDragId;
|
|
104
|
+
const isSelected = shape.id === selectedId;
|
|
105
|
+
const [a, b] = shape.geometry.points;
|
|
106
|
+
if ((isEndpointDrag || isSelected) && a && b) {
|
|
107
|
+
const stroke = shape.style.stroke ?? '#000000';
|
|
108
|
+
const strokeWidth = shape.style.strokeWidth ?? 2;
|
|
109
|
+
const hasArrow = shape.kind === 'arrow' || shape.style.cap === 'arrow';
|
|
110
|
+
const handles = handleRadius != null ? (_jsxs(_Fragment, { children: [_jsx(Handle, { c: isEndpointDrag && liveLineP1 ? liveLineP1 : a, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: isEndpointDrag && liveLineP2 ? liveLineP2 : b, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR })] })) : null;
|
|
111
|
+
// Endpoint drag: render the body live from the moving endpoints
|
|
112
|
+
// (outside any group translate), suppressing the static arrowhead
|
|
113
|
+
// so it doesn't lag the finger — it reappears on commit. Selected
|
|
114
|
+
// but idle: reuse ShapeElement for the body (caps/arrowhead/dash)
|
|
115
|
+
// and overlay the handles, both wrapped so a group move tracks.
|
|
116
|
+
if (isEndpointDrag) {
|
|
117
|
+
const arrowPath = (() => {
|
|
118
|
+
if (!hasArrow)
|
|
119
|
+
return null;
|
|
120
|
+
const [apex, baseL, baseR] = arrowheadTriangle(b, a, strokeWidth);
|
|
121
|
+
const p = Skia.Path.Make();
|
|
122
|
+
p.moveTo(apex.x, apex.y);
|
|
123
|
+
p.lineTo(baseL.x, baseL.y);
|
|
124
|
+
p.lineTo(baseR.x, baseR.y);
|
|
125
|
+
p.close();
|
|
126
|
+
return p;
|
|
127
|
+
})();
|
|
128
|
+
return (_jsxs(Group, { children: [_jsx(Line, { p1: liveLineP1 ?? a, p2: liveLineP2 ?? b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(shape.style.cap), children: shape.style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) }), handles, arrowPath && (_jsx(Path, { path: arrowPath, color: stroke, style: "fill" }))] }, shape.id));
|
|
129
|
+
}
|
|
130
|
+
return (_jsxs(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: [_jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }), handles] }, shape.id));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }) }, shape.id));
|
|
134
|
+
}), effectiveCanvas.placedMeasurements.map((placed) => {
|
|
90
135
|
// Rectangle annotation: a stroked border whose center carries the
|
|
91
136
|
// tile. A corner drag renders from the live geometry (outside the
|
|
92
137
|
// group translate, like an endpoint drag); otherwise the committed
|
|
@@ -100,7 +145,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
100
145
|
return (_jsx(Rect, { x: liveRect.x, y: liveRect.y, width: liveRect.width, height: liveRect.height, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }, placed.id));
|
|
101
146
|
}
|
|
102
147
|
const n = normalizeRect(placed.rect);
|
|
103
|
-
return (_jsxs(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: [_jsx(Rect, { x: n.minX, y: n.minY, width: n.maxX - n.minX, height: n.maxY - n.minY, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(
|
|
148
|
+
return (_jsxs(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: [_jsx(Rect, { x: n.minX, y: n.minY, width: n.maxX - n.minX, height: n.maxY - n.minY, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Handle, { c: { x: n.minX, y: n.minY }, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: { x: n.maxX, y: n.minY }, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: { x: n.minX, y: n.maxY }, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: { x: n.maxX, y: n.maxY }, r: handleRadius, ringWidth: handleRingWidth })] }))] }, placed.id));
|
|
104
149
|
}
|
|
105
150
|
if (placementOf(placed) !== 'line' || !placed.line)
|
|
106
151
|
return null;
|
|
@@ -126,7 +171,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
126
171
|
p.close();
|
|
127
172
|
return p;
|
|
128
173
|
})();
|
|
129
|
-
const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(
|
|
174
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Handle, { c: p1, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: p2, r: handleRadius, ringWidth: handleRingWidth })] }))] }));
|
|
130
175
|
return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
|
|
131
176
|
}), (() => {
|
|
132
177
|
if (!selectedId)
|
|
@@ -139,6 +184,10 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
139
184
|
}
|
|
140
185
|
const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
|
|
141
186
|
if (shape) {
|
|
187
|
+
// Line/arrow shapes show endpoint handles (drawn with the shape
|
|
188
|
+
// above), not a bounding box — matching the measurement-line UX.
|
|
189
|
+
if (shape.kind === 'line' || shape.kind === 'arrow')
|
|
190
|
+
return null;
|
|
142
191
|
// Text shapes derive their box from the estimated text bounds (the
|
|
143
192
|
// stored geometry is just the top-left anchor) and add a corner
|
|
144
193
|
// resize handle; both track the live resize transform so the chrome
|
|
@@ -152,7 +201,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
152
201
|
const isResizing = shape.id === resizingId;
|
|
153
202
|
const liveTransform = isResizing ? resizeTransform : dragTransform;
|
|
154
203
|
const resizeGeom = isText ? textResizeGeometry(shape) : null;
|
|
155
|
-
return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isDragging || isResizing, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: _jsx(
|
|
204
|
+
return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isDragging || isResizing, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: _jsx(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) }))] }));
|
|
156
205
|
}
|
|
157
206
|
return null;
|
|
158
207
|
})(), 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] }) }));
|
|
@@ -22,6 +22,7 @@ export interface ToolContext {
|
|
|
22
22
|
document: AnnotationCanvasState;
|
|
23
23
|
selection: Selection | null;
|
|
24
24
|
viewport: ViewportApi;
|
|
25
|
+
tileViewportScale: number;
|
|
25
26
|
preview(patch: AnnotationDocumentPatch): void;
|
|
26
27
|
commit(patch: AnnotationDocumentPatch): void;
|
|
27
28
|
setSelection(selection: Selection | null): void;
|
|
@@ -50,15 +51,17 @@ export interface ShapeDrawConfig {
|
|
|
50
51
|
}
|
|
51
52
|
export type DragElementKind = 'stroke' | 'shape' | 'measurement';
|
|
52
53
|
export interface DragSelectionConfig {
|
|
53
|
-
hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
|
|
54
|
+
hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number): {
|
|
54
55
|
id: AnnotationElementId;
|
|
55
56
|
kind: DragElementKind;
|
|
56
57
|
} | null;
|
|
57
58
|
buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
|
|
58
|
-
classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'slide' | 'move';
|
|
59
|
+
classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): 'slide' | 'move';
|
|
59
60
|
buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
|
|
60
61
|
hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
|
|
61
62
|
buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
|
|
63
|
+
hitTestShapeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
|
|
64
|
+
buildShapeEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
|
|
62
65
|
hitTestRectCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
|
|
63
66
|
corner: RectCorner;
|
|
64
67
|
moving: Vec2;
|
|
@@ -36,7 +36,7 @@ export declare const rectCornerPoint: (rect: {
|
|
|
36
36
|
b: Vec2;
|
|
37
37
|
}, corner: RectCorner) => Vec2;
|
|
38
38
|
export declare const oppositeRectCorner: (corner: RectCorner) => RectCorner;
|
|
39
|
-
export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number) => boolean;
|
|
39
|
+
export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number, tileScaleFactor?: number, viewportScale?: number) => boolean;
|
|
40
40
|
export interface RemoveMeasurementResult {
|
|
41
41
|
ops: AnnotationPatchOp[];
|
|
42
42
|
keepSelection: boolean;
|
|
@@ -109,14 +109,15 @@ const segmentDistSq = (p, a, b) => {
|
|
|
109
109
|
// stamp tile around the anchor, the line body of a line annotation, or the
|
|
110
110
|
// border ring of a rectangle annotation (interiors stay transparent to hits so
|
|
111
111
|
// elements inside remain reachable).
|
|
112
|
-
export const hitPlacedMeasurement = (m, p, zoom = 1) => {
|
|
112
|
+
export const hitPlacedMeasurement = (m, p, zoom = 1, tileScaleFactor = 1, viewportScale = 1) => {
|
|
113
113
|
// The stamp renders as a constant *screen*-size square centered on the
|
|
114
114
|
// anchor, so its doc-space footprint shrinks as you zoom in. Convert the
|
|
115
115
|
// screen-space half-extent (+ padding) back to doc space via the zoom so
|
|
116
116
|
// the hit box always matches what's drawn.
|
|
117
117
|
// Unassociated inputs use the smaller input-tile footprint (#6); the helper
|
|
118
118
|
// folds in scale + the associated/blank size so the hit box matches the draw.
|
|
119
|
-
const half = (stampTileSize(m) / 2 + STAMP_HIT_PADDING) /
|
|
119
|
+
const half = (stampTileSize(m, tileScaleFactor, viewportScale) / 2 + STAMP_HIT_PADDING) /
|
|
120
|
+
zoom;
|
|
120
121
|
const dx = Math.abs(p.x - m.anchor.x);
|
|
121
122
|
const dy = Math.abs(p.y - m.anchor.y);
|
|
122
123
|
if (dx <= half && dy <= half)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { PlacedMeasurementRef } from '../../types/annotation.js';
|
|
2
2
|
export declare const STAMP_TILE_SIZE = 96;
|
|
3
3
|
export declare const STAMP_INPUT_TILE_SIZE = 56;
|
|
4
|
+
export declare const DEFAULT_TILE_SCALE = 1;
|
|
5
|
+
export declare const TILE_SCALE_MIN = 0.4;
|
|
6
|
+
export declare const TILE_SCALE_MAX = 2;
|
|
7
|
+
export declare const clampTileScale: (v: number) => number;
|
|
8
|
+
export declare const renderedDocWidthAtFit: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
|
|
9
|
+
export declare const viewportTileScale: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
|
|
4
10
|
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
|
|
5
|
-
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale"
|
|
11
|
+
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
|
|
@@ -15,13 +15,74 @@ export const STAMP_TILE_SIZE = 96;
|
|
|
15
15
|
// inputs read as lightweight tap targets; associated tiles keep STAMP_TILE_SIZE
|
|
16
16
|
// so existing saved annotations are visually unchanged. The single knob for #6.
|
|
17
17
|
export const STAMP_INPUT_TILE_SIZE = 56;
|
|
18
|
+
// Document-wide tile scale factor (AnnotationCanvasState.tileScaleFactor): one
|
|
19
|
+
// knob that shrinks/grows EVERY measurement tile on the canvas at once, on top
|
|
20
|
+
// of each tile's own `scale`. Lets a user pull tiles down on a dense drawing
|
|
21
|
+
// where lines crowd together, or bump them up on a sparse one. Like `scale` it
|
|
22
|
+
// is purely a screen-space multiplier — it does NOT change with zoom. Absent ===
|
|
23
|
+
// DEFAULT_TILE_SCALE (visually identical to documents written before the knob
|
|
24
|
+
// existed).
|
|
25
|
+
export const DEFAULT_TILE_SCALE = 1;
|
|
26
|
+
export const TILE_SCALE_MIN = 0.4;
|
|
27
|
+
export const TILE_SCALE_MAX = 2;
|
|
28
|
+
// Clamp a tile-scale-factor candidate to the supported range. The single guard
|
|
29
|
+
// for the value before it lands in the document (slider input, restored docs).
|
|
30
|
+
export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
|
|
31
|
+
// --- Viewport-relative tile sizing ------------------------------------------
|
|
32
|
+
// A bare-pixel tile is the same size on every device, but the SAME document is
|
|
33
|
+
// fit into wildly different canvases (a phone window vs a desktop pane), so the
|
|
34
|
+
// drawing renders much larger on desktop and a fixed-px tile reads as a tiny
|
|
35
|
+
// fraction of it. To keep a tile a CONSISTENT fraction of the drawing across
|
|
36
|
+
// platforms — while staying independent of the user's live zoom — the tile
|
|
37
|
+
// footprint is multiplied by `viewportTileScale`, which tracks how large the
|
|
38
|
+
// document renders when fit to the canvas (NOT the live zoom).
|
|
39
|
+
// Rendered document width (screen px) at which a tile uses its unscaled base
|
|
40
|
+
// size. Calibrated to a large phone's width so phone canvases land at scale 1
|
|
41
|
+
// (mobile visually unchanged); larger canvases scale up proportionally.
|
|
42
|
+
const TILE_VIEWPORT_REFERENCE_PX = 430;
|
|
43
|
+
// Clamp so phone-sized canvases never shrink tiles below base (lower = 1) and
|
|
44
|
+
// very large monitors don't produce runaway tiles (upper = 4).
|
|
45
|
+
const TILE_VIEWPORT_SCALE_MIN = 1;
|
|
46
|
+
const TILE_VIEWPORT_SCALE_MAX = 4;
|
|
47
|
+
// Screen-px width the document occupies when fit to the canvas:
|
|
48
|
+
// docW * fitZoom, fitZoom = min(canvasW/docW, canvasH/docH)
|
|
49
|
+
// = min(canvasW, docW * canvasH / docH)
|
|
50
|
+
// This — not the raw canvas size — governs the tile's fraction of the drawing,
|
|
51
|
+
// so the same document at the same canvas aspect yields the same value on web
|
|
52
|
+
// and native. Falls back to canvasW when doc dimensions are unknown.
|
|
53
|
+
export const renderedDocWidthAtFit = (canvasW, canvasH, docW, docH) => {
|
|
54
|
+
if (!(docW > 0) || !(docH > 0) || !(canvasW > 0) || !(canvasH > 0)) {
|
|
55
|
+
return canvasW > 0 ? canvasW : TILE_VIEWPORT_REFERENCE_PX;
|
|
56
|
+
}
|
|
57
|
+
return Math.min(canvasW, (docW * canvasH) / docH);
|
|
58
|
+
};
|
|
59
|
+
// Multiplier folded into the tile footprint so tiles are a consistent fraction
|
|
60
|
+
// of the rendered drawing on any canvas. Zoom-INDEPENDENT (a function of canvas
|
|
61
|
+
// + doc dimensions only), so the tile still never changes size as the user
|
|
62
|
+
// pinches/wheels. Defaults to 1 (callers without canvas dimensions are
|
|
63
|
+
// unaffected — e.g. legacy tests).
|
|
64
|
+
export const viewportTileScale = (canvasW, canvasH, docW, docH) => {
|
|
65
|
+
const s = renderedDocWidthAtFit(canvasW, canvasH, docW, docH) /
|
|
66
|
+
TILE_VIEWPORT_REFERENCE_PX;
|
|
67
|
+
return s < TILE_VIEWPORT_SCALE_MIN
|
|
68
|
+
? TILE_VIEWPORT_SCALE_MIN
|
|
69
|
+
: s > TILE_VIEWPORT_SCALE_MAX
|
|
70
|
+
? TILE_VIEWPORT_SCALE_MAX
|
|
71
|
+
: s;
|
|
72
|
+
};
|
|
18
73
|
// A placed measurement is an unassociated input until a measurement reference
|
|
19
74
|
// is attached (id or path). Such stamps use STAMP_INPUT_TILE_SIZE.
|
|
20
75
|
export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
|
|
21
76
|
// Screen-space edge length for a placed stamp: the compact input size while
|
|
22
77
|
// unassociated, full size once a measurement is attached, then scaled by the
|
|
23
|
-
// per-stamp `scale
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
78
|
+
// per-stamp `scale`, the document-wide `tileScaleFactor`, and the
|
|
79
|
+
// `viewportTileScale` (so tiles read at a consistent fraction of the drawing on
|
|
80
|
+
// any canvas). The ONE source of truth for tile footprint — render overlay,
|
|
81
|
+
// hit-test, and slide-grab classification all call this so the drawn tile and
|
|
82
|
+
// its touch box always agree. `tileScaleFactor` is the canvas-level knob
|
|
83
|
+
// (default 1, from `AnnotationCanvasState.tileScaleFactor`); `viewportScale`
|
|
84
|
+
// (default 1) is the per-canvas multiplier from `viewportTileScale`.
|
|
85
|
+
export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
|
|
86
|
+
(m.scale ?? 1) *
|
|
87
|
+
tileScaleFactor *
|
|
88
|
+
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)) {
|
|
54
|
+
if (hitPlacedMeasurement(m, event.world, zoom, ctx.document.tileScaleFactor, ctx.tileViewportScale)) {
|
|
55
55
|
ctx.setSelection({ ids: [m.id] });
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
@@ -37,11 +37,11 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
|
37
37
|
const dy = py - cy;
|
|
38
38
|
return dx * dx + dy * dy;
|
|
39
39
|
};
|
|
40
|
-
const findHit = (doc, world, zoom) => {
|
|
40
|
+
const findHit = (doc, world, zoom, viewportTileScale = 1) => {
|
|
41
41
|
// Hit-test in z-order (top first): measurements > shapes > strokes.
|
|
42
42
|
for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
|
|
43
43
|
const m = doc.placedMeasurements[i];
|
|
44
|
-
if (hitPlacedMeasurement(m, world, zoom)) {
|
|
44
|
+
if (hitPlacedMeasurement(m, world, zoom, doc.tileScaleFactor, viewportTileScale)) {
|
|
45
45
|
return { id: m.id, kind: 'measurement' };
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -128,12 +128,16 @@ const translatePatch = (elementKind, id, doc, delta) => {
|
|
|
128
128
|
// via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
|
|
129
129
|
// Grabbing the tile of a line annotation slides it along the line; everything
|
|
130
130
|
// else (bare stamps, grabs on the line body) is a group move.
|
|
131
|
-
const classifyGrab = (doc, id, world, zoom) => {
|
|
131
|
+
const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1) => {
|
|
132
132
|
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
133
133
|
if (!m)
|
|
134
134
|
return 'move';
|
|
135
|
-
// Same footprint the tile is drawn at (smaller for unassociated inputs, #6
|
|
136
|
-
|
|
135
|
+
// Same footprint the tile is drawn at (smaller for unassociated inputs, #6;
|
|
136
|
+
// folds in the document-wide tile scale + viewport scale so slide-grab
|
|
137
|
+
// matches the draw).
|
|
138
|
+
const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
|
|
139
|
+
HIT_PADDING) /
|
|
140
|
+
zoom;
|
|
137
141
|
const onTile = Math.abs(world.x - m.anchor.x) <= half &&
|
|
138
142
|
Math.abs(world.y - m.anchor.y) <= half;
|
|
139
143
|
return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
|
|
@@ -190,6 +194,48 @@ const endpointPatch = (doc, id, handle, delta) => {
|
|
|
190
194
|
const anchor = recomputeAnchor(line, 'line', linePosOf(m), m.anchor);
|
|
191
195
|
return { ops: [{ op: 'updateMeasurement', id, patch: { line, anchor } }] };
|
|
192
196
|
};
|
|
197
|
+
// Which endpoint handle of a (selected) line/arrow SHAPE is under `world`.
|
|
198
|
+
// Shape lines store their endpoints as geometry.points[0]/[1] (unlike
|
|
199
|
+
// measurement lines, which carry them on `line.a/b` alongside a tile). Prefers
|
|
200
|
+
// the nearer endpoint when both are within range.
|
|
201
|
+
const findShapeHandleHit = (doc, id, world, zoom) => {
|
|
202
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
203
|
+
if (!s || (s.kind !== 'line' && s.kind !== 'arrow'))
|
|
204
|
+
return null;
|
|
205
|
+
const [a, b] = s.geometry.points;
|
|
206
|
+
if (!a || !b)
|
|
207
|
+
return null;
|
|
208
|
+
const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
|
|
209
|
+
const da = (world.x - a.x) ** 2 + (world.y - a.y) ** 2;
|
|
210
|
+
const db = (world.x - b.x) ** 2 + (world.y - b.y) ** 2;
|
|
211
|
+
if (da <= r2 && da <= db)
|
|
212
|
+
return 'a';
|
|
213
|
+
if (db <= r2)
|
|
214
|
+
return 'b';
|
|
215
|
+
return null;
|
|
216
|
+
};
|
|
217
|
+
// Move one endpoint of a line/arrow shape by a world delta (resize/rotate).
|
|
218
|
+
// Rewrites the whole geometry (updateShape merges shallowly at the top level,
|
|
219
|
+
// so `closed` and any other geometry fields must be carried through).
|
|
220
|
+
const shapeEndpointPatch = (doc, id, handle, delta) => {
|
|
221
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
222
|
+
if (!s || (s.kind !== 'line' && s.kind !== 'arrow'))
|
|
223
|
+
return null;
|
|
224
|
+
const [a, b] = s.geometry.points;
|
|
225
|
+
if (!a || !b)
|
|
226
|
+
return null;
|
|
227
|
+
const na = handle === 'a' ? { x: a.x + delta.x, y: a.y + delta.y } : a;
|
|
228
|
+
const nb = handle === 'b' ? { x: b.x + delta.x, y: b.y + delta.y } : b;
|
|
229
|
+
return {
|
|
230
|
+
ops: [
|
|
231
|
+
{
|
|
232
|
+
op: 'updateShape',
|
|
233
|
+
id,
|
|
234
|
+
patch: { geometry: { ...s.geometry, points: [na, nb] } },
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
};
|
|
193
239
|
// Which corner handle of a (selected) rectangle annotation is under `world`.
|
|
194
240
|
// Prefers the nearest corner when several are within range (small rects).
|
|
195
241
|
const findRectCornerHit = (doc, id, world, zoom) => {
|
|
@@ -272,7 +318,11 @@ const resizePatch = (doc, id, delta) => {
|
|
|
272
318
|
// Patch for the current drag mode (web pointer path), from a world-space delta.
|
|
273
319
|
const dragPatch = (s, doc, delta, zoom) => {
|
|
274
320
|
if (s.mode === 'endpoint' && s.handle) {
|
|
275
|
-
|
|
321
|
+
// Shape lines and measurement lines store endpoints differently, so the
|
|
322
|
+
// patch builder is keyed on which kind is being dragged.
|
|
323
|
+
return s.elementKind === 'shape'
|
|
324
|
+
? shapeEndpointPatch(doc, s.id, s.handle, delta)
|
|
325
|
+
: endpointPatch(doc, s.id, s.handle, delta);
|
|
276
326
|
}
|
|
277
327
|
if (s.mode === 'slide')
|
|
278
328
|
return slidePatch(doc, s.id, delta, zoom);
|
|
@@ -292,7 +342,7 @@ export const createSelectTool = () => ({
|
|
|
292
342
|
// reusing the same hit-test and translate logic the pointer handlers below
|
|
293
343
|
// use for web — one source of truth.
|
|
294
344
|
dragSelection: {
|
|
295
|
-
hitTest: (doc, world, zoom) => findHit(doc, world, zoom),
|
|
345
|
+
hitTest: (doc, world, zoom, viewportTileScale) => findHit(doc, world, zoom, viewportTileScale),
|
|
296
346
|
buildTranslatePatch: (doc, id, kind, delta) => {
|
|
297
347
|
const op = translatePatch(kind, id, doc, delta);
|
|
298
348
|
return op ? { ops: [op] } : null;
|
|
@@ -301,6 +351,8 @@ export const createSelectTool = () => ({
|
|
|
301
351
|
buildSlidePatch: slidePatch,
|
|
302
352
|
hitTestHandle: findHandleHit,
|
|
303
353
|
buildEndpointPatch: endpointPatch,
|
|
354
|
+
hitTestShapeHandle: findShapeHandleHit,
|
|
355
|
+
buildShapeEndpointPatch: shapeEndpointPatch,
|
|
304
356
|
hitTestResizeHandle: findResizeHandleHit,
|
|
305
357
|
buildResizePatch: resizePatch,
|
|
306
358
|
hitTestRectCorner: findRectCornerHit,
|
|
@@ -328,6 +380,19 @@ export const createSelectTool = () => ({
|
|
|
328
380
|
delta: { x: 0, y: 0 },
|
|
329
381
|
};
|
|
330
382
|
}
|
|
383
|
+
const shapeHandle = findShapeHandleHit(ctx.document, selId, world, zoom);
|
|
384
|
+
if (shapeHandle) {
|
|
385
|
+
ctx.setSelection({ ids: [selId] });
|
|
386
|
+
return {
|
|
387
|
+
kind: 'dragging',
|
|
388
|
+
id: selId,
|
|
389
|
+
elementKind: 'shape',
|
|
390
|
+
mode: 'endpoint',
|
|
391
|
+
handle: shapeHandle,
|
|
392
|
+
start: world,
|
|
393
|
+
delta: { x: 0, y: 0 },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
331
396
|
if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
|
|
332
397
|
return {
|
|
333
398
|
kind: 'dragging',
|
|
@@ -352,14 +417,15 @@ export const createSelectTool = () => ({
|
|
|
352
417
|
};
|
|
353
418
|
}
|
|
354
419
|
}
|
|
355
|
-
const hit = findHit(ctx.document, world, zoom);
|
|
420
|
+
const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale);
|
|
356
421
|
if (!hit) {
|
|
357
422
|
ctx.setSelection(null);
|
|
358
423
|
return { kind: 'idle' };
|
|
359
424
|
}
|
|
360
425
|
ctx.setSelection({ ids: [hit.id] });
|
|
361
426
|
const mode = hit.kind === 'measurement' &&
|
|
362
|
-
classifyGrab(ctx.document, hit.id, world, zoom) ===
|
|
427
|
+
classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
|
|
428
|
+
'slide'
|
|
363
429
|
? 'slide'
|
|
364
430
|
: 'move';
|
|
365
431
|
return {
|
|
@@ -49,6 +49,7 @@ export interface AnnotationCanvasStateApi {
|
|
|
49
49
|
activeTool: Tool | null;
|
|
50
50
|
toolState: ToolState;
|
|
51
51
|
ctx: ToolContext;
|
|
52
|
+
tileViewportScale: number;
|
|
52
53
|
penDrawingStroke: AnnotationStroke | null;
|
|
53
54
|
customPreviewState: ToolState;
|
|
54
55
|
dispatchPointerDown(event: CanvasPointerEvent): void;
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
3
|
import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
4
4
|
import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
5
|
+
import { viewportTileScale } from './stampLayout.js';
|
|
5
6
|
// Platform-agnostic state machine for the annotation canvas. Web and native
|
|
6
7
|
// inners share this hook; each wraps it with platform-specific event
|
|
7
8
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
@@ -21,10 +22,16 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
21
22
|
return map;
|
|
22
23
|
}, [measurements]);
|
|
23
24
|
const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
|
|
25
|
+
// How large the document renders when fit to this canvas, as a tile-footprint
|
|
26
|
+
// multiplier (zoom-independent). Keeps tiles a consistent fraction of the
|
|
27
|
+
// drawing on a phone vs a desktop pane. Used by the overlays (drawn size) and
|
|
28
|
+
// the tools (hit box) so both agree.
|
|
29
|
+
const tileViewportScale = useMemo(() => viewportTileScale(width, height, canvas.viewport.width, canvas.viewport.height), [width, height, canvas.viewport.width, canvas.viewport.height]);
|
|
24
30
|
const ctx = useMemo(() => ({
|
|
25
31
|
document: canvas,
|
|
26
32
|
selection,
|
|
27
33
|
viewport: viewportApi,
|
|
34
|
+
tileViewportScale,
|
|
28
35
|
preview(patch) {
|
|
29
36
|
setPreviewPatch(patch);
|
|
30
37
|
},
|
|
@@ -56,6 +63,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
56
63
|
canvas,
|
|
57
64
|
selection,
|
|
58
65
|
viewportApi,
|
|
66
|
+
tileViewportScale,
|
|
59
67
|
onCommit,
|
|
60
68
|
onSelectionChange,
|
|
61
69
|
pickMeasurement,
|
|
@@ -361,6 +369,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
361
369
|
activeTool,
|
|
362
370
|
toolState,
|
|
363
371
|
ctx,
|
|
372
|
+
tileViewportScale,
|
|
364
373
|
penDrawingStroke,
|
|
365
374
|
customPreviewState: toolState,
|
|
366
375
|
dispatchPointerDown,
|
package/dist/exports.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
|
|
|
21
21
|
export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
|
|
22
22
|
export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
|
|
23
23
|
export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
|
|
24
|
+
export { STAMP_TILE_SIZE, STAMP_INPUT_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
|
|
24
25
|
export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
|
|
25
26
|
export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
|
|
26
27
|
export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
|
package/dist/exports.js
CHANGED
|
@@ -21,6 +21,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
|
|
|
21
21
|
export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
|
|
22
22
|
export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
|
|
23
23
|
export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
|
|
24
|
+
export { STAMP_TILE_SIZE, STAMP_INPUT_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
|
|
24
25
|
export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
|
|
25
26
|
export { createPenTool, } from './annotation/canvas/tools/penTool.js';
|
|
26
27
|
export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
|
|
@@ -103,6 +103,7 @@ export interface AnnotationCanvasState {
|
|
|
103
103
|
strokes: AnnotationStroke[];
|
|
104
104
|
shapes: AnnotationShape[];
|
|
105
105
|
placedMeasurements: PlacedMeasurementRef[];
|
|
106
|
+
tileScaleFactor?: number;
|
|
106
107
|
externalPayloadPath?: string;
|
|
107
108
|
}
|
|
108
109
|
export type AnnotationElement = (AnnotationStroke & {
|
|
@@ -148,6 +149,9 @@ export type AnnotationPatchOp = {
|
|
|
148
149
|
} | {
|
|
149
150
|
op: 'setViewport';
|
|
150
151
|
patch: Partial<AnnotationViewport>;
|
|
152
|
+
} | {
|
|
153
|
+
op: 'setTileScaleFactor';
|
|
154
|
+
value: number;
|
|
151
155
|
} | {
|
|
152
156
|
op: 'setLayers';
|
|
153
157
|
layers: AnnotationLayer[];
|
package/dist/types/annotation.js
CHANGED
|
@@ -67,6 +67,8 @@ const applyOp = (state, op) => {
|
|
|
67
67
|
};
|
|
68
68
|
case 'setViewport':
|
|
69
69
|
return { ...state, viewport: { ...state.viewport, ...op.patch } };
|
|
70
|
+
case 'setTileScaleFactor':
|
|
71
|
+
return { ...state, tileScaleFactor: op.value };
|
|
70
72
|
case 'setLayers':
|
|
71
73
|
return { ...state, layers: op.layers };
|
|
72
74
|
}
|
|
@@ -141,6 +143,10 @@ const invertOp = (before, op) => {
|
|
|
141
143
|
}
|
|
142
144
|
return { op: 'setViewport', patch: inversePatch };
|
|
143
145
|
}
|
|
146
|
+
case 'setTileScaleFactor':
|
|
147
|
+
// Restore the prior factor; absent → an explicit 1 (visually identical to
|
|
148
|
+
// absent), keeping the op's `value: number` contract.
|
|
149
|
+
return { op: 'setTileScaleFactor', value: before.tileScaleFactor ?? 1 };
|
|
144
150
|
case 'setLayers':
|
|
145
151
|
return { op: 'setLayers', layers: before.layers };
|
|
146
152
|
}
|
|
@@ -217,6 +217,7 @@ export interface Job extends FirestoreDoc, Timestamps {
|
|
|
217
217
|
address?: string;
|
|
218
218
|
description?: string;
|
|
219
219
|
starred?: boolean;
|
|
220
|
+
lastModified: Date;
|
|
220
221
|
}
|
|
221
222
|
export interface Section extends Timestamps {
|
|
222
223
|
id: string;
|
|
@@ -426,6 +427,53 @@ export declare enum DecimalTolerance {
|
|
|
426
427
|
Hundredth = "0.01",
|
|
427
428
|
Thousandth = "0.001"
|
|
428
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Trade/industry a user works in. Collected during registration to personalize
|
|
432
|
+
* the experience. String values are stable identifiers safe for persistence and
|
|
433
|
+
* analytics; they double as i18n key suffixes (e.g. `trades.iron_worker`).
|
|
434
|
+
*/
|
|
435
|
+
export declare enum UserTrades {
|
|
436
|
+
Woodworking = "woodworking",
|
|
437
|
+
Plumbing = "plumbing",
|
|
438
|
+
Electrical = "electrical",
|
|
439
|
+
Structural = "structural",
|
|
440
|
+
Framing = "framing",
|
|
441
|
+
Carpenter = "carpenter",
|
|
442
|
+
IronWorker = "iron_worker",
|
|
443
|
+
Joiners = "joiners",
|
|
444
|
+
HVAC = "hvac",
|
|
445
|
+
Painting = "painting",
|
|
446
|
+
GC = "gc",
|
|
447
|
+
IT = "it",
|
|
448
|
+
Surveying = "surveying",
|
|
449
|
+
Pipefitter = "pipe_fitter",
|
|
450
|
+
Drywall = "drywall",
|
|
451
|
+
Welding = "welding",
|
|
452
|
+
Flooring = "flooring",
|
|
453
|
+
Decking = "decking",
|
|
454
|
+
Tile = "tile",
|
|
455
|
+
DIY = "diy",
|
|
456
|
+
Other = "other"
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Role a user holds within their trade. Collected during registration. String
|
|
460
|
+
* values double as i18n key suffixes (e.g. `roles.project_manager`).
|
|
461
|
+
*/
|
|
462
|
+
export declare enum UserRoles {
|
|
463
|
+
Apprentice = "apprentice",
|
|
464
|
+
Journeyman = "journeyman",
|
|
465
|
+
Owner = "owner",
|
|
466
|
+
ProjectManager = "project_manager",
|
|
467
|
+
Supervisor = "supervisor",
|
|
468
|
+
Foreman = "foreman",
|
|
469
|
+
Superintendent = "superintendent",
|
|
470
|
+
Estimator = "estimator",
|
|
471
|
+
OfficeManager = "office_manager",
|
|
472
|
+
Operator = "operator",
|
|
473
|
+
Laborer = "laborer",
|
|
474
|
+
Engineer = "engineer",
|
|
475
|
+
Safety = "safety"
|
|
476
|
+
}
|
|
429
477
|
export interface UserDocument extends FirestoreDoc, Timestamps {
|
|
430
478
|
defaultOrganization: string;
|
|
431
479
|
displayName: string;
|
|
@@ -439,6 +487,12 @@ export interface UserDocument extends FirestoreDoc, Timestamps {
|
|
|
439
487
|
printLabelSize: string;
|
|
440
488
|
printLogoFileId: string;
|
|
441
489
|
devices?: Record<string, string>;
|
|
490
|
+
/** Four-digit birth year. Optional — not required during registration. */
|
|
491
|
+
birthYear?: number;
|
|
492
|
+
/** The trade/industry the user works in. Captured during registration. */
|
|
493
|
+
industry?: UserTrades;
|
|
494
|
+
/** The user's role within their trade. Optional. */
|
|
495
|
+
role?: UserRoles;
|
|
442
496
|
}
|
|
443
497
|
export interface UserComment extends FirestoreDoc, Timestamps {
|
|
444
498
|
comment: string;
|
package/dist/types/firestore.js
CHANGED
|
@@ -123,3 +123,52 @@ export var DecimalTolerance;
|
|
|
123
123
|
DecimalTolerance["Hundredth"] = "0.01";
|
|
124
124
|
DecimalTolerance["Thousandth"] = "0.001";
|
|
125
125
|
})(DecimalTolerance || (DecimalTolerance = {}));
|
|
126
|
+
/**
|
|
127
|
+
* Trade/industry a user works in. Collected during registration to personalize
|
|
128
|
+
* the experience. String values are stable identifiers safe for persistence and
|
|
129
|
+
* analytics; they double as i18n key suffixes (e.g. `trades.iron_worker`).
|
|
130
|
+
*/
|
|
131
|
+
export var UserTrades;
|
|
132
|
+
(function (UserTrades) {
|
|
133
|
+
UserTrades["Woodworking"] = "woodworking";
|
|
134
|
+
UserTrades["Plumbing"] = "plumbing";
|
|
135
|
+
UserTrades["Electrical"] = "electrical";
|
|
136
|
+
UserTrades["Structural"] = "structural";
|
|
137
|
+
UserTrades["Framing"] = "framing";
|
|
138
|
+
UserTrades["Carpenter"] = "carpenter";
|
|
139
|
+
UserTrades["IronWorker"] = "iron_worker";
|
|
140
|
+
UserTrades["Joiners"] = "joiners";
|
|
141
|
+
UserTrades["HVAC"] = "hvac";
|
|
142
|
+
UserTrades["Painting"] = "painting";
|
|
143
|
+
UserTrades["GC"] = "gc";
|
|
144
|
+
UserTrades["IT"] = "it";
|
|
145
|
+
UserTrades["Surveying"] = "surveying";
|
|
146
|
+
UserTrades["Pipefitter"] = "pipe_fitter";
|
|
147
|
+
UserTrades["Drywall"] = "drywall";
|
|
148
|
+
UserTrades["Welding"] = "welding";
|
|
149
|
+
UserTrades["Flooring"] = "flooring";
|
|
150
|
+
UserTrades["Decking"] = "decking";
|
|
151
|
+
UserTrades["Tile"] = "tile";
|
|
152
|
+
UserTrades["DIY"] = "diy";
|
|
153
|
+
UserTrades["Other"] = "other";
|
|
154
|
+
})(UserTrades || (UserTrades = {}));
|
|
155
|
+
/**
|
|
156
|
+
* Role a user holds within their trade. Collected during registration. String
|
|
157
|
+
* values double as i18n key suffixes (e.g. `roles.project_manager`).
|
|
158
|
+
*/
|
|
159
|
+
export var UserRoles;
|
|
160
|
+
(function (UserRoles) {
|
|
161
|
+
UserRoles["Apprentice"] = "apprentice";
|
|
162
|
+
UserRoles["Journeyman"] = "journeyman";
|
|
163
|
+
UserRoles["Owner"] = "owner";
|
|
164
|
+
UserRoles["ProjectManager"] = "project_manager";
|
|
165
|
+
UserRoles["Supervisor"] = "supervisor";
|
|
166
|
+
UserRoles["Foreman"] = "foreman";
|
|
167
|
+
UserRoles["Superintendent"] = "superintendent";
|
|
168
|
+
UserRoles["Estimator"] = "estimator";
|
|
169
|
+
UserRoles["OfficeManager"] = "office_manager";
|
|
170
|
+
UserRoles["Operator"] = "operator";
|
|
171
|
+
UserRoles["Laborer"] = "laborer";
|
|
172
|
+
UserRoles["Engineer"] = "engineer";
|
|
173
|
+
UserRoles["Safety"] = "safety";
|
|
174
|
+
})(UserRoles || (UserRoles = {}));
|