@reekon-tools/boldr-utils 1.6.9 → 1.6.11
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/canvas/AnnotationCanvasInner.d.ts +2 -0
- package/dist/canvas/AnnotationCanvasInner.js +56 -16
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +2 -0
- package/dist/canvas/AnnotationCanvasInner.native.js +53 -17
- package/dist/canvas/AnnotationCanvasSkia.d.ts +2 -1
- package/dist/canvas/AnnotationCanvasSkia.js +2 -1
- package/dist/canvas/measurementStampOverlay.d.ts +11 -0
- package/dist/canvas/measurementStampOverlay.js +1 -0
- package/dist/canvas/stampLayout.d.ts +1 -0
- package/dist/canvas/stampLayout.js +6 -0
- package/dist/canvas/tools/selectTool.js +11 -11
- package/dist/canvas/useAnnotationCanvasState.d.ts +1 -0
- package/dist/canvas/useAnnotationCanvasState.js +29 -1
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +9 -2
- package/dist/data/hooks/useAnnotationCanvasDoc.js +97 -3
- package/dist/exports.d.ts +1 -0
- package/dist/types/firestore.d.ts +1 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import type { MeasurementRef } from './measurementPicker.js';
|
|
|
5
5
|
import type { Tool } from './Tool.js';
|
|
6
6
|
import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
|
|
7
7
|
import { type ViewportState } from './viewport.js';
|
|
8
|
+
import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
|
|
8
9
|
export type { AnnotationCanvasHandle };
|
|
9
10
|
export interface AnnotationCanvasInnerProps {
|
|
10
11
|
canvas: AnnotationCanvasState;
|
|
@@ -19,6 +20,7 @@ export interface AnnotationCanvasInnerProps {
|
|
|
19
20
|
decimalTolerance?: DecimalTolerance;
|
|
20
21
|
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
21
22
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
23
|
+
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
22
24
|
stampFontSource?: unknown;
|
|
23
25
|
stampValueFontSize?: number;
|
|
24
26
|
stampLabelFontSize?: number;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useFont } from '@shopify/react-native-skia';
|
|
3
3
|
import { useCallback, useEffect, useRef, } from 'react';
|
|
4
4
|
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
5
5
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
6
|
+
import { STAMP_TILE_SIZE } from './stampLayout.js';
|
|
6
7
|
const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
|
|
7
8
|
export const AnnotationCanvasInner = (props) => {
|
|
8
9
|
const { fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, stampFontSource, stampValueFontSize = 14, stampLabelFontSize = 11, gestures, width, height, style, activeToolId, tools, } = props;
|
|
@@ -161,19 +162,58 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
161
162
|
...style,
|
|
162
163
|
};
|
|
163
164
|
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
165
|
+
const { renderMeasurementStamp, selection } = props;
|
|
166
|
+
return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
|
|
167
|
+
width,
|
|
168
|
+
height,
|
|
169
|
+
effectiveCanvas: state.effectiveCanvas,
|
|
170
|
+
worldTransform: state.worldTransform,
|
|
171
|
+
measurementsById: state.measurementsById,
|
|
172
|
+
fallbackUnit,
|
|
173
|
+
fractionalTolerance,
|
|
174
|
+
decimalTolerance,
|
|
175
|
+
resolveImageUrl,
|
|
176
|
+
valueFont,
|
|
177
|
+
labelFont,
|
|
178
|
+
hideMeasurementStamps: !!renderMeasurementStamp,
|
|
179
|
+
penDrawingStroke: state.penDrawingStroke,
|
|
180
|
+
customPreview,
|
|
181
|
+
}), renderMeasurementStamp && (_jsx("div", { style: {
|
|
182
|
+
position: 'absolute',
|
|
183
|
+
inset: 0,
|
|
184
|
+
pointerEvents: 'none',
|
|
185
|
+
}, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
|
|
186
|
+
const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
|
|
187
|
+
const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
|
|
188
|
+
const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
|
|
189
|
+
const isSelected = selection?.ids.includes(placed.id) ?? false;
|
|
190
|
+
return (_jsxs("div", { style: {
|
|
191
|
+
position: 'absolute',
|
|
192
|
+
left: 0,
|
|
193
|
+
top: 0,
|
|
194
|
+
width: size,
|
|
195
|
+
height: size,
|
|
196
|
+
transform: `translate(${cx - size / 2}px, ${cy - size / 2}px)`,
|
|
197
|
+
}, children: [renderMeasurementStamp({
|
|
198
|
+
placed,
|
|
199
|
+
measurement: state.measurementsById.get(placed.measurementId) ?? null,
|
|
200
|
+
selected: isSelected,
|
|
201
|
+
size,
|
|
202
|
+
zoom: state.viewport.zoom,
|
|
203
|
+
}), isSelected && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
|
|
204
|
+
e.stopPropagation();
|
|
205
|
+
state.ctx.commit({
|
|
206
|
+
ops: [{ op: 'removeMeasurement', id: placed.id }],
|
|
207
|
+
});
|
|
208
|
+
state.ctx.setSelection(null);
|
|
209
|
+
}, style: {
|
|
210
|
+
position: 'absolute',
|
|
211
|
+
top: -10,
|
|
212
|
+
right: -10,
|
|
213
|
+
width: 40,
|
|
214
|
+
height: 40,
|
|
215
|
+
cursor: 'pointer',
|
|
216
|
+
pointerEvents: 'auto',
|
|
217
|
+
} }))] }, placed.id));
|
|
218
|
+
}) }))] }));
|
|
179
219
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type MutableRefObject } from 'react';
|
|
2
2
|
import { type ViewStyle } from 'react-native';
|
|
3
|
+
import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
|
|
3
4
|
import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
|
|
4
5
|
import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
|
|
5
6
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
@@ -20,6 +21,7 @@ export interface AnnotationCanvasInnerProps {
|
|
|
20
21
|
decimalTolerance?: DecimalTolerance;
|
|
21
22
|
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
22
23
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
24
|
+
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
23
25
|
stampFontSource?: unknown;
|
|
24
26
|
stampValueFontSize?: number;
|
|
25
27
|
stampLabelFontSize?: number;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useFont } from '@shopify/react-native-skia';
|
|
3
3
|
import { useMemo, useRef } from 'react';
|
|
4
|
-
import { View } from 'react-native';
|
|
4
|
+
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
5
5
|
import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
|
|
6
|
+
import { STAMP_TILE_SIZE } from './stampLayout.js';
|
|
6
7
|
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
7
8
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
8
9
|
// Native fingerprint: one finger drives the active tool, two fingers
|
|
@@ -84,19 +85,54 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
84
85
|
}, [state]);
|
|
85
86
|
const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
|
|
86
87
|
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
88
|
+
const { renderMeasurementStamp, selection } = props;
|
|
89
|
+
return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
|
|
90
|
+
width,
|
|
91
|
+
height,
|
|
92
|
+
effectiveCanvas: state.effectiveCanvas,
|
|
93
|
+
worldTransform: state.worldTransform,
|
|
94
|
+
measurementsById: state.measurementsById,
|
|
95
|
+
fallbackUnit,
|
|
96
|
+
fractionalTolerance,
|
|
97
|
+
decimalTolerance,
|
|
98
|
+
resolveImageUrl,
|
|
99
|
+
valueFont,
|
|
100
|
+
labelFont,
|
|
101
|
+
hideMeasurementStamps: !!renderMeasurementStamp,
|
|
102
|
+
penDrawingStroke: state.penDrawingStroke,
|
|
103
|
+
customPreview,
|
|
104
|
+
}) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
|
|
105
|
+
const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
|
|
106
|
+
const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
|
|
107
|
+
const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
|
|
108
|
+
const isSelected = selection?.ids.includes(placed.id) ?? false;
|
|
109
|
+
return (_jsxs(View, { pointerEvents: "box-none", style: {
|
|
110
|
+
position: 'absolute',
|
|
111
|
+
left: 0,
|
|
112
|
+
top: 0,
|
|
113
|
+
width: size,
|
|
114
|
+
height: size,
|
|
115
|
+
transform: [
|
|
116
|
+
{ translateX: cx - size / 2 },
|
|
117
|
+
{ translateY: cy - size / 2 },
|
|
118
|
+
],
|
|
119
|
+
}, children: [_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: renderMeasurementStamp({
|
|
120
|
+
placed,
|
|
121
|
+
measurement: state.measurementsById.get(placed.measurementId) ?? null,
|
|
122
|
+
selected: isSelected,
|
|
123
|
+
size,
|
|
124
|
+
zoom: state.viewport.zoom,
|
|
125
|
+
}) }), isSelected && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: () => {
|
|
126
|
+
state.ctx.commit({
|
|
127
|
+
ops: [{ op: 'removeMeasurement', id: placed.id }],
|
|
128
|
+
});
|
|
129
|
+
state.ctx.setSelection(null);
|
|
130
|
+
}, style: {
|
|
131
|
+
position: 'absolute',
|
|
132
|
+
top: -8,
|
|
133
|
+
right: -8,
|
|
134
|
+
width: 36,
|
|
135
|
+
height: 36,
|
|
136
|
+
} }))] }, placed.id));
|
|
137
|
+
}) }))] }));
|
|
102
138
|
};
|
|
@@ -20,7 +20,8 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
20
20
|
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
21
21
|
valueFont: SkFont | null;
|
|
22
22
|
labelFont: SkFont | null;
|
|
23
|
+
hideMeasurementStamps?: boolean;
|
|
23
24
|
penDrawingStroke: AnnotationStroke | null;
|
|
24
25
|
customPreview?: ReactNode;
|
|
25
26
|
}
|
|
26
|
-
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, penDrawingStroke, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
27
|
+
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, hideMeasurementStamps, penDrawingStroke, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -16,4 +16,5 @@ import { StrokeElement } from './elements/StrokeElement.js';
|
|
|
16
16
|
// since the function-call pattern works identically on native we use it
|
|
17
17
|
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
18
18
|
// JSX-returning helper, not a component.
|
|
19
|
-
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, penDrawingStroke, 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(StrokeElement, { stroke: stroke }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(ShapeElement, { shape: shape, font: valueFont }, shape.id))),
|
|
19
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, hideMeasurementStamps, penDrawingStroke, 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(StrokeElement, { stroke: stroke }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(ShapeElement, { shape: shape, font: valueFont }, shape.id))), !hideMeasurementStamps &&
|
|
20
|
+
effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampElement, { placed: placed, measurement: measurementsById.get(placed.measurementId) ?? null, fallbackUnit: fallbackUnit ?? Units.Millimeters, fractionalTolerance: fractionalTolerance, decimalTolerance: decimalTolerance, valueFont: valueFont, labelFont: labelFont }, placed.id))), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), customPreview] }) }));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { PlacedMeasurementRef } from '../types/annotation.js';
|
|
3
|
+
import type { Measurement } from '../types/firestore.js';
|
|
4
|
+
export interface MeasurementStampRenderArgs {
|
|
5
|
+
placed: PlacedMeasurementRef;
|
|
6
|
+
measurement: Measurement | null;
|
|
7
|
+
selected: boolean;
|
|
8
|
+
size: number;
|
|
9
|
+
zoom: number;
|
|
10
|
+
}
|
|
11
|
+
export type RenderMeasurementStamp = (args: MeasurementStampRenderArgs) => ReactNode;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -6,3 +6,9 @@ export const STAMP_WIDTH = 120;
|
|
|
6
6
|
export const STAMP_HEIGHT = 44;
|
|
7
7
|
export const STAMP_PADDING_X = 10;
|
|
8
8
|
export const STAMP_PADDING_Y = 6;
|
|
9
|
+
// Constant SCREEN-space edge length of a placed measurement rendered as a
|
|
10
|
+
// square tile (the overlay path — see measurementStampOverlay.ts). The tile
|
|
11
|
+
// is a fixed-size pin: its on-screen size is this times `placed.scale` and
|
|
12
|
+
// does NOT change with zoom (only its position tracks the canvas). Also drives
|
|
13
|
+
// the select-tool hit box, which converts it back to doc space via the zoom.
|
|
14
|
+
export const STAMP_TILE_SIZE = 120;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { STAMP_TILE_SIZE } from '../stampLayout.js';
|
|
2
2
|
const HIT_PADDING = 6;
|
|
3
3
|
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
4
4
|
// override via `hitTest` for more precision later.
|
|
@@ -12,16 +12,16 @@ const hitStroke = (stroke, p) => {
|
|
|
12
12
|
}
|
|
13
13
|
return false;
|
|
14
14
|
};
|
|
15
|
-
const hitMeasurement = (m, p) => {
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
const hitMeasurement = (m, p, zoom = 1) => {
|
|
16
|
+
// The stamp renders as a constant *screen*-size square centered on the
|
|
17
|
+
// anchor, so its doc-space footprint shrinks as you zoom in. Convert the
|
|
18
|
+
// screen-space half-extent (+ padding) back to doc space via the zoom so
|
|
19
|
+
// the hit box always matches what's drawn.
|
|
19
20
|
const scale = m.scale ?? 1;
|
|
20
|
-
const
|
|
21
|
-
const halfH = (STAMP_HEIGHT * scale) / 2 + HIT_PADDING;
|
|
21
|
+
const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
|
|
22
22
|
const dx = Math.abs(p.x - m.anchor.x);
|
|
23
23
|
const dy = Math.abs(p.y - m.anchor.y);
|
|
24
|
-
return dx <=
|
|
24
|
+
return dx <= half && dy <= half;
|
|
25
25
|
};
|
|
26
26
|
const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
27
27
|
const abx = bx - ax;
|
|
@@ -35,11 +35,11 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
|
35
35
|
const dy = py - cy;
|
|
36
36
|
return dx * dx + dy * dy;
|
|
37
37
|
};
|
|
38
|
-
const findHit = (doc, world) => {
|
|
38
|
+
const findHit = (doc, world, zoom) => {
|
|
39
39
|
// Hit-test in z-order (top first): measurements > shapes > strokes.
|
|
40
40
|
for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
|
|
41
41
|
const m = doc.placedMeasurements[i];
|
|
42
|
-
if (hitMeasurement(m, world))
|
|
42
|
+
if (hitMeasurement(m, world, zoom))
|
|
43
43
|
return { id: m.id, kind: 'measurement' };
|
|
44
44
|
}
|
|
45
45
|
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
@@ -135,7 +135,7 @@ export const createSelectTool = () => ({
|
|
|
135
135
|
label: 'Select',
|
|
136
136
|
cursor: 'default',
|
|
137
137
|
onPointerDown(event, ctx) {
|
|
138
|
-
const hit = findHit(ctx.document, event.world);
|
|
138
|
+
const hit = findHit(ctx.document, event.world, ctx.viewport.state.zoom);
|
|
139
139
|
if (!hit) {
|
|
140
140
|
ctx.setSelection(null);
|
|
141
141
|
return { kind: 'idle' };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { applyPatch, invertPatch, } from '../types/annotation.js';
|
|
2
|
+
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../types/annotation.js';
|
|
3
3
|
import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
4
4
|
// Platform-agnostic state machine for the annotation canvas. Web and native
|
|
5
5
|
// inners share this hook; each wraps it with platform-specific event
|
|
@@ -54,6 +54,10 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
54
54
|
onSelectionChange,
|
|
55
55
|
pickMeasurement,
|
|
56
56
|
]);
|
|
57
|
+
// Live ctx for imperative handle methods (which are created in an effect and
|
|
58
|
+
// would otherwise capture a stale ctx/viewport).
|
|
59
|
+
const ctxRef = useRef(ctx);
|
|
60
|
+
ctxRef.current = ctx;
|
|
57
61
|
const dispatchPointerDown = useCallback((event) => {
|
|
58
62
|
if (!activeTool)
|
|
59
63
|
return;
|
|
@@ -136,6 +140,30 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
136
140
|
resetView() {
|
|
137
141
|
setViewport(DEFAULT_VIEWPORT);
|
|
138
142
|
},
|
|
143
|
+
placeMeasurementAtCenter(ref) {
|
|
144
|
+
const c = ctxRef.current;
|
|
145
|
+
const anchor = c.viewport.screenToWorld({
|
|
146
|
+
x: width / 2,
|
|
147
|
+
y: height / 2,
|
|
148
|
+
});
|
|
149
|
+
const placed = {
|
|
150
|
+
id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
|
|
151
|
+
layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
152
|
+
measurementPath: ref.measurementPath,
|
|
153
|
+
measurementId: ref.measurementId,
|
|
154
|
+
groupId: ref.groupId,
|
|
155
|
+
anchor,
|
|
156
|
+
labelOverride: ref.label,
|
|
157
|
+
unitOverride: ref.unit,
|
|
158
|
+
showLabel: true,
|
|
159
|
+
showValue: true,
|
|
160
|
+
createdAt: Date.now(),
|
|
161
|
+
};
|
|
162
|
+
c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
|
|
163
|
+
// Select it so the consumer can immediately move it (the consumer is
|
|
164
|
+
// responsible for switching to its move/select tool).
|
|
165
|
+
c.setSelection({ ids: [placed.id] });
|
|
166
|
+
},
|
|
139
167
|
};
|
|
140
168
|
return () => {
|
|
141
169
|
if (imperativeRef)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport } from '../../types/annotation.js';
|
|
2
|
-
import type { JobGroupScope } from '../AnnotationDataProvider.js';
|
|
1
|
+
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport, type BackgroundFit } from '../../types/annotation.js';
|
|
2
|
+
import type { ImageBlob, JobGroupScope } from '../AnnotationDataProvider.js';
|
|
3
3
|
export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
|
4
4
|
export interface UseAnnotationCanvasDocOptions {
|
|
5
5
|
scope: JobGroupScope | null;
|
|
@@ -13,6 +13,7 @@ export interface UseAnnotationCanvasDocOptions {
|
|
|
13
13
|
};
|
|
14
14
|
onFileCreated?: (fileId: string) => void;
|
|
15
15
|
onSaveError?: (error: unknown) => void;
|
|
16
|
+
captureThumbnail?: () => Promise<ImageBlob | null>;
|
|
16
17
|
debugLogging?: boolean;
|
|
17
18
|
}
|
|
18
19
|
export interface UseAnnotationCanvasDocResult {
|
|
@@ -22,5 +23,11 @@ export interface UseAnnotationCanvasDocResult {
|
|
|
22
23
|
error: Error | null;
|
|
23
24
|
saveStatus: SaveStatus;
|
|
24
25
|
save: () => Promise<void>;
|
|
26
|
+
ensureFileId: () => Promise<string>;
|
|
27
|
+
setBackgroundImage: (blob: ImageBlob, dims: {
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}, fit?: BackgroundFit) => Promise<void>;
|
|
31
|
+
clearBackgroundImage: () => Promise<void>;
|
|
25
32
|
}
|
|
26
33
|
export declare const useAnnotationCanvasDoc: (options: UseAnnotationCanvasDocOptions) => UseAnnotationCanvasDocResult;
|
|
@@ -26,9 +26,9 @@ const buildFileData = (fileType, isLabel, canvas) => ({
|
|
|
26
26
|
// no `fileId` was supplied. Composes the existing low-level hooks so it stays
|
|
27
27
|
// decoupled from any specific Firebase SDK.
|
|
28
28
|
export const useAnnotationCanvasDoc = (options) => {
|
|
29
|
-
const { scope, fileId, fallbackViewport, debounceMs = 800, createSeed, onFileCreated, onSaveError, debugLogging = false, } = options;
|
|
29
|
+
const { scope, fileId, fallbackViewport, debounceMs = 800, createSeed, onFileCreated, onSaveError, captureThumbnail, debugLogging = false, } = options;
|
|
30
30
|
const { data, loading, error } = useAnnotationDoc(scope, fileId);
|
|
31
|
-
const { create, update } = useAnnotationMutations(scope ?? EMPTY_SCOPE);
|
|
31
|
+
const { create, update, uploadImage, deleteImage } = useAnnotationMutations(scope ?? EMPTY_SCOPE);
|
|
32
32
|
const [working, setWorking] = useState(null);
|
|
33
33
|
const [saveStatus, setSaveStatus] = useState('idle');
|
|
34
34
|
// Refs let the debounced/unmount flush read the latest values without
|
|
@@ -47,6 +47,10 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
47
47
|
const createSeedRef = useRef(createSeed);
|
|
48
48
|
const onFileCreatedRef = useRef(onFileCreated);
|
|
49
49
|
const onSaveErrorRef = useRef(onSaveError);
|
|
50
|
+
const captureThumbnailRef = useRef(captureThumbnail);
|
|
51
|
+
const uploadImageRef = useRef(uploadImage);
|
|
52
|
+
// Guards against overlapping thumbnail captures (each save fires one).
|
|
53
|
+
const thumbnailSavingRef = useRef(false);
|
|
50
54
|
const debugRef = useRef(debugLogging);
|
|
51
55
|
// JSON of the canvas we last wrote, to recognize (and ignore) the snapshot
|
|
52
56
|
// echo of our own write when reconciling incoming remote changes.
|
|
@@ -61,11 +65,33 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
61
65
|
createSeedRef.current = createSeed;
|
|
62
66
|
onFileCreatedRef.current = onFileCreated;
|
|
63
67
|
onSaveErrorRef.current = onSaveError;
|
|
68
|
+
captureThumbnailRef.current = captureThumbnail;
|
|
69
|
+
uploadImageRef.current = uploadImage;
|
|
64
70
|
debugRef.current = debugLogging;
|
|
65
71
|
const setStatus = useCallback((next) => {
|
|
66
72
|
statusRef.current = next;
|
|
67
73
|
setSaveStatus(next);
|
|
68
74
|
}, []);
|
|
75
|
+
// Fire-and-forget thumbnail capture + upload, run after each successful save.
|
|
76
|
+
// Stable identity (reads refs) so it doesn't churn `flush`'s deps. Skips when
|
|
77
|
+
// no capturer is supplied or a capture is already in flight.
|
|
78
|
+
const saveThumbnail = useCallback(async (fileId) => {
|
|
79
|
+
const capture = captureThumbnailRef.current;
|
|
80
|
+
if (!capture || thumbnailSavingRef.current)
|
|
81
|
+
return;
|
|
82
|
+
thumbnailSavingRef.current = true;
|
|
83
|
+
try {
|
|
84
|
+
const blob = await capture();
|
|
85
|
+
if (blob)
|
|
86
|
+
await uploadImageRef.current(fileId, 'thumbnail', blob);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
console.warn('[useAnnotationCanvasDoc] thumbnail save failed', e);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
thumbnailSavingRef.current = false;
|
|
93
|
+
}
|
|
94
|
+
}, []);
|
|
69
95
|
// Reset working state when the target document changes so the next snapshot
|
|
70
96
|
// hydrates the new file rather than leaking the previous one. Skip the reset
|
|
71
97
|
// when the new fileId is one we just created (the caller echoing it back) —
|
|
@@ -171,6 +197,11 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
171
197
|
}
|
|
172
198
|
}
|
|
173
199
|
lastSavedJsonRef.current = json;
|
|
200
|
+
// Refresh the file's thumbnail to match what was just saved (fire and
|
|
201
|
+
// forget — never blocks or fails the save).
|
|
202
|
+
const savedId = fileIdRef.current ?? createdIdRef.current;
|
|
203
|
+
if (savedId)
|
|
204
|
+
void saveThumbnail(savedId);
|
|
174
205
|
// If new edits landed mid-flight, stay dirty and let the next debounce
|
|
175
206
|
// (or unmount) flush them.
|
|
176
207
|
const latest = workingRef.current;
|
|
@@ -189,7 +220,7 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
189
220
|
onSaveErrorRef.current?.(e);
|
|
190
221
|
setStatus('error');
|
|
191
222
|
}
|
|
192
|
-
}, [scope, setStatus]);
|
|
223
|
+
}, [scope, setStatus, saveThumbnail]);
|
|
193
224
|
const onCommit = useCallback((patch) => {
|
|
194
225
|
setWorking((prev) => (prev ? applyPatch(prev, patch) : prev));
|
|
195
226
|
setStatus('dirty');
|
|
@@ -200,6 +231,66 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
200
231
|
void flush();
|
|
201
232
|
}, debounceMs);
|
|
202
233
|
}, [debounceMs, flush, setStatus]);
|
|
234
|
+
const ensureFileId = useCallback(async () => {
|
|
235
|
+
const existing = fileIdRef.current ?? createdIdRef.current;
|
|
236
|
+
if (existing)
|
|
237
|
+
return existing;
|
|
238
|
+
// No file yet — seed an empty canvas if nothing has hydrated, then flush
|
|
239
|
+
// so the existing create branch mints the doc.
|
|
240
|
+
if (!workingRef.current) {
|
|
241
|
+
const seeded = createEmptyCanvasState(fallbackViewport);
|
|
242
|
+
workingRef.current = seeded;
|
|
243
|
+
setWorking(seeded);
|
|
244
|
+
}
|
|
245
|
+
await flush();
|
|
246
|
+
const id = fileIdRef.current ?? createdIdRef.current;
|
|
247
|
+
if (!id) {
|
|
248
|
+
throw new Error('Unable to create annotation file before background upload');
|
|
249
|
+
}
|
|
250
|
+
return id;
|
|
251
|
+
}, [flush, fallbackViewport]);
|
|
252
|
+
const setBackgroundImage = useCallback(async (blob, dims, fit = 'contain') => {
|
|
253
|
+
const id = await ensureFileId();
|
|
254
|
+
const ref = await uploadImage(id, 'background', blob);
|
|
255
|
+
onCommit({
|
|
256
|
+
ops: [
|
|
257
|
+
{
|
|
258
|
+
op: 'setViewport',
|
|
259
|
+
patch: {
|
|
260
|
+
backgroundImage: {
|
|
261
|
+
storagePath: ref.storagePath,
|
|
262
|
+
downloadUrl: ref.downloadUrl,
|
|
263
|
+
widthPx: dims.width,
|
|
264
|
+
heightPx: dims.height,
|
|
265
|
+
},
|
|
266
|
+
backgroundFit: fit,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
}, [ensureFileId, uploadImage, onCommit]);
|
|
272
|
+
const clearBackgroundImage = useCallback(async () => {
|
|
273
|
+
const bg = workingRef.current?.viewport.backgroundImage;
|
|
274
|
+
const id = fileIdRef.current ?? createdIdRef.current;
|
|
275
|
+
if (bg && id) {
|
|
276
|
+
try {
|
|
277
|
+
await deleteImage(id, bg.storagePath);
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
// Non-fatal: still clear the viewport reference even if the storage
|
|
281
|
+
// object is already gone.
|
|
282
|
+
console.warn('[useAnnotationCanvasDoc] failed to delete background image', e);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
onCommit({
|
|
286
|
+
ops: [
|
|
287
|
+
{
|
|
288
|
+
op: 'setViewport',
|
|
289
|
+
patch: { backgroundImage: undefined, backgroundFit: undefined },
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
});
|
|
293
|
+
}, [deleteImage, onCommit]);
|
|
203
294
|
// Flush any pending edits on unmount.
|
|
204
295
|
useEffect(() => () => {
|
|
205
296
|
if (timerRef.current) {
|
|
@@ -216,5 +307,8 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
216
307
|
error,
|
|
217
308
|
saveStatus,
|
|
218
309
|
save: flush,
|
|
310
|
+
ensureFileId,
|
|
311
|
+
setBackgroundImage,
|
|
312
|
+
clearBackgroundImage,
|
|
219
313
|
};
|
|
220
314
|
};
|
package/dist/exports.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type { AnnotationCanvasHandle } from './canvas/useAnnotationCanvasState.j
|
|
|
20
20
|
export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './canvas/AnnotationCanvasInner.js';
|
|
21
21
|
export type { CanvasPointerEvent, Tool, ToolContext, ToolState, } from './canvas/Tool.js';
|
|
22
22
|
export type { MeasurementRef, PickMeasurement, } from './canvas/measurementPicker.js';
|
|
23
|
+
export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './canvas/measurementStampOverlay.js';
|
|
23
24
|
export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './canvas/viewport.js';
|
|
24
25
|
export { createPenTool, type PenToolOptions } from './canvas/tools/penTool.js';
|
|
25
26
|
export { createSelectTool } from './canvas/tools/selectTool.js';
|
|
@@ -220,6 +220,7 @@ export interface Section extends Timestamps {
|
|
|
220
220
|
tableConfig: ColumnConfig[];
|
|
221
221
|
measurements: string[];
|
|
222
222
|
isTemplate?: boolean;
|
|
223
|
+
viewMode?: 'rows' | 'columns' | 'transposed';
|
|
223
224
|
}
|
|
224
225
|
export interface Group extends FirestoreDoc, Timestamps {
|
|
225
226
|
sectionId: string;
|