@reekon-tools/boldr-utils 1.6.15 → 1.6.18
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 +18 -2
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +3 -2
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +1 -1
- package/dist/annotation/canvas/elements/ShapeElement.d.ts +4 -2
- package/dist/annotation/canvas/elements/ShapeElement.js +36 -11
- package/dist/canvas/AnnotationCanvas.d.ts +11 -0
- package/dist/canvas/AnnotationCanvas.js +10 -0
- package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
- package/dist/canvas/AnnotationCanvas.native.js +6 -0
- package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
- package/dist/canvas/AnnotationCanvasInner.js +219 -0
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
- package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
- package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
- package/dist/canvas/AnnotationCanvasSkia.js +20 -0
- package/dist/canvas/Tool.d.ts +38 -0
- package/dist/canvas/Tool.js +1 -0
- package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
- package/dist/canvas/elements/BackgroundImageElement.js +37 -0
- package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
- package/dist/canvas/elements/MeasurementStampElement.js +30 -0
- package/dist/canvas/elements/ShapeElement.d.ts +7 -0
- package/dist/canvas/elements/ShapeElement.js +62 -0
- package/dist/canvas/elements/StrokeElement.d.ts +7 -0
- package/dist/canvas/elements/StrokeElement.js +18 -0
- package/dist/canvas/measurementPicker.d.ts +10 -0
- package/dist/canvas/measurementPicker.js +1 -0
- package/dist/canvas/measurementStampOverlay.d.ts +11 -0
- package/dist/canvas/measurementStampOverlay.js +1 -0
- package/dist/canvas/pointerAdapter.d.ts +3 -0
- package/dist/canvas/pointerAdapter.js +19 -0
- package/dist/canvas/stampLayout.d.ts +5 -0
- package/dist/canvas/stampLayout.js +14 -0
- package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
- package/dist/canvas/tools/measurementStampTool.js +37 -0
- package/dist/canvas/tools/panTool.d.ts +5 -0
- package/dist/canvas/tools/panTool.js +25 -0
- package/dist/canvas/tools/penTool.d.ts +13 -0
- package/dist/canvas/tools/penTool.js +68 -0
- package/dist/canvas/tools/selectTool.d.ts +2 -0
- package/dist/canvas/tools/selectTool.js +182 -0
- package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
- package/dist/canvas/useAnnotationCanvasState.js +210 -0
- package/dist/canvas/viewport.d.ts +16 -0
- package/dist/canvas/viewport.js +54 -0
- package/dist/data/AnnotationDataContext.d.ts +8 -0
- package/dist/data/AnnotationDataContext.js +11 -0
- package/dist/data/AnnotationDataProvider.d.ts +65 -0
- package/dist/data/AnnotationDataProvider.js +4 -0
- package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
- package/dist/data/InMemoryAnnotationProvider.js +197 -0
- package/dist/data/canvasPersistence.d.ts +3 -0
- package/dist/data/canvasPersistence.js +26 -0
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
- package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
- package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
- package/dist/data/hooks/useAnnotationDoc.js +33 -0
- package/dist/data/hooks/useAnnotationList.d.ts +7 -0
- package/dist/data/hooks/useAnnotationList.js +26 -0
- package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
- package/dist/data/hooks/useAnnotationMutations.js +11 -0
- package/dist/hooks/useParseMeasurement.d.ts +4 -0
- package/dist/hooks/useParseMeasurement.js +14 -0
- package/dist/types/firestore.d.ts +1 -0
- package/dist/utils/evaluateFormula.d.ts +20 -0
- package/dist/utils/evaluateFormula.js +31 -0
- package/package.json +1 -1
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +0 -12
- package/dist/annotation/canvas/tools/measurementLineTool.js +0 -95
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { STAMP_TILE_SIZE } from '../stampLayout.js';
|
|
2
|
+
const HIT_PADDING = 6;
|
|
3
|
+
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
4
|
+
// override via `hitTest` for more precision later.
|
|
5
|
+
const hitStroke = (stroke, p) => {
|
|
6
|
+
const r = stroke.width / 2 + HIT_PADDING;
|
|
7
|
+
const r2 = r * r;
|
|
8
|
+
for (let i = 0; i < stroke.points.length - 2; i += 2) {
|
|
9
|
+
if (segmentDistanceSq(p.x, p.y, stroke.points[i], stroke.points[i + 1], stroke.points[i + 2], stroke.points[i + 3]) <= r2) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
};
|
|
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.
|
|
20
|
+
const scale = m.scale ?? 1;
|
|
21
|
+
const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
|
|
22
|
+
const dx = Math.abs(p.x - m.anchor.x);
|
|
23
|
+
const dy = Math.abs(p.y - m.anchor.y);
|
|
24
|
+
return dx <= half && dy <= half;
|
|
25
|
+
};
|
|
26
|
+
const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
27
|
+
const abx = bx - ax;
|
|
28
|
+
const aby = by - ay;
|
|
29
|
+
const lenSq = abx * abx + aby * aby;
|
|
30
|
+
let t = lenSq === 0 ? 0 : ((px - ax) * abx + (py - ay) * aby) / lenSq;
|
|
31
|
+
t = Math.max(0, Math.min(1, t));
|
|
32
|
+
const cx = ax + t * abx;
|
|
33
|
+
const cy = ay + t * aby;
|
|
34
|
+
const dx = px - cx;
|
|
35
|
+
const dy = py - cy;
|
|
36
|
+
return dx * dx + dy * dy;
|
|
37
|
+
};
|
|
38
|
+
const findHit = (doc, world, zoom) => {
|
|
39
|
+
// Hit-test in z-order (top first): measurements > shapes > strokes.
|
|
40
|
+
for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
|
|
41
|
+
const m = doc.placedMeasurements[i];
|
|
42
|
+
if (hitMeasurement(m, world, zoom))
|
|
43
|
+
return { id: m.id, kind: 'measurement' };
|
|
44
|
+
}
|
|
45
|
+
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
46
|
+
// Default shape hit test: bounding box of the shape's points + padding.
|
|
47
|
+
const s = doc.shapes[i];
|
|
48
|
+
const pts = s.geometry.points;
|
|
49
|
+
if (pts.length === 0)
|
|
50
|
+
continue;
|
|
51
|
+
let minX = pts[0].x;
|
|
52
|
+
let maxX = pts[0].x;
|
|
53
|
+
let minY = pts[0].y;
|
|
54
|
+
let maxY = pts[0].y;
|
|
55
|
+
for (let j = 1; j < pts.length; j++) {
|
|
56
|
+
const p = pts[j];
|
|
57
|
+
if (p.x < minX)
|
|
58
|
+
minX = p.x;
|
|
59
|
+
if (p.x > maxX)
|
|
60
|
+
maxX = p.x;
|
|
61
|
+
if (p.y < minY)
|
|
62
|
+
minY = p.y;
|
|
63
|
+
if (p.y > maxY)
|
|
64
|
+
maxY = p.y;
|
|
65
|
+
}
|
|
66
|
+
if (world.x >= minX - HIT_PADDING &&
|
|
67
|
+
world.x <= maxX + HIT_PADDING &&
|
|
68
|
+
world.y >= minY - HIT_PADDING &&
|
|
69
|
+
world.y <= maxY + HIT_PADDING) {
|
|
70
|
+
return { id: s.id, kind: 'shape' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (let i = doc.strokes.length - 1; i >= 0; i--) {
|
|
74
|
+
const s = doc.strokes[i];
|
|
75
|
+
if (hitStroke(s, world))
|
|
76
|
+
return { id: s.id, kind: 'stroke' };
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
const translatePatch = (elementKind, id, doc, delta) => {
|
|
81
|
+
if (elementKind === 'measurement') {
|
|
82
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
83
|
+
if (!m)
|
|
84
|
+
return null;
|
|
85
|
+
return {
|
|
86
|
+
op: 'updateMeasurement',
|
|
87
|
+
id,
|
|
88
|
+
patch: {
|
|
89
|
+
anchor: { x: m.anchor.x + delta.x, y: m.anchor.y + delta.y },
|
|
90
|
+
leader: m.leader
|
|
91
|
+
? {
|
|
92
|
+
from: {
|
|
93
|
+
x: m.leader.from.x + delta.x,
|
|
94
|
+
y: m.leader.from.y + delta.y,
|
|
95
|
+
},
|
|
96
|
+
to: {
|
|
97
|
+
x: m.leader.to.x + delta.x,
|
|
98
|
+
y: m.leader.to.y + delta.y,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
: undefined,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (elementKind === 'shape') {
|
|
106
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
107
|
+
if (!s)
|
|
108
|
+
return null;
|
|
109
|
+
return {
|
|
110
|
+
op: 'updateShape',
|
|
111
|
+
id,
|
|
112
|
+
patch: {
|
|
113
|
+
geometry: {
|
|
114
|
+
...s.geometry,
|
|
115
|
+
points: s.geometry.points.map((p) => ({
|
|
116
|
+
x: p.x + delta.x,
|
|
117
|
+
y: p.y + delta.y,
|
|
118
|
+
})),
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const stroke = doc.strokes.find((x) => x.id === id);
|
|
124
|
+
if (!stroke)
|
|
125
|
+
return null;
|
|
126
|
+
const points = stroke.points.slice();
|
|
127
|
+
for (let i = 0; i < points.length; i += 2) {
|
|
128
|
+
points[i] = points[i] + delta.x;
|
|
129
|
+
points[i + 1] = points[i + 1] + delta.y;
|
|
130
|
+
}
|
|
131
|
+
return { op: 'updateStroke', id, patch: { points } };
|
|
132
|
+
};
|
|
133
|
+
export const createSelectTool = () => ({
|
|
134
|
+
id: 'select',
|
|
135
|
+
label: 'Select',
|
|
136
|
+
cursor: 'default',
|
|
137
|
+
onPointerDown(event, ctx) {
|
|
138
|
+
const hit = findHit(ctx.document, event.world, ctx.viewport.state.zoom);
|
|
139
|
+
if (!hit) {
|
|
140
|
+
ctx.setSelection(null);
|
|
141
|
+
return { kind: 'idle' };
|
|
142
|
+
}
|
|
143
|
+
ctx.setSelection({ ids: [hit.id] });
|
|
144
|
+
return {
|
|
145
|
+
kind: 'dragging',
|
|
146
|
+
id: hit.id,
|
|
147
|
+
elementKind: hit.kind,
|
|
148
|
+
start: event.world,
|
|
149
|
+
delta: { x: 0, y: 0 },
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
onPointerMove(event, ctx, state) {
|
|
153
|
+
const s = state;
|
|
154
|
+
if (s?.kind !== 'dragging')
|
|
155
|
+
return s;
|
|
156
|
+
const delta = { x: event.world.x - s.start.x, y: event.world.y - s.start.y };
|
|
157
|
+
const op = translatePatch(s.elementKind, s.id, ctx.document, delta);
|
|
158
|
+
if (op)
|
|
159
|
+
ctx.preview({ ops: [op] });
|
|
160
|
+
return { ...s, delta };
|
|
161
|
+
},
|
|
162
|
+
onPointerUp(_event, ctx, state) {
|
|
163
|
+
const s = state;
|
|
164
|
+
if (s?.kind !== 'dragging')
|
|
165
|
+
return;
|
|
166
|
+
if (s.delta.x === 0 && s.delta.y === 0)
|
|
167
|
+
return;
|
|
168
|
+
const op = translatePatch(s.elementKind, s.id, ctx.document, s.delta);
|
|
169
|
+
if (op)
|
|
170
|
+
ctx.commit({ ops: [op] });
|
|
171
|
+
},
|
|
172
|
+
onCancel(_state, ctx) {
|
|
173
|
+
ctx.preview({ ops: [] });
|
|
174
|
+
},
|
|
175
|
+
hitTest(element, p) {
|
|
176
|
+
if (element.kind === 'measurement')
|
|
177
|
+
return hitMeasurement(element, p);
|
|
178
|
+
if (element.kind === 'stroke')
|
|
179
|
+
return hitStroke(element, p);
|
|
180
|
+
return false;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Measurement } from '../types/firestore.js';
|
|
2
|
+
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationStroke, type Selection, type Vec2 } from '../types/annotation.js';
|
|
3
|
+
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
|
+
import type { CanvasPointerEvent, Tool, ToolContext, ToolState } from './Tool.js';
|
|
5
|
+
import { type ViewportState } from './viewport.js';
|
|
6
|
+
export interface AnnotationCanvasHandle {
|
|
7
|
+
undo(): void;
|
|
8
|
+
redo(): void;
|
|
9
|
+
canUndo(): boolean;
|
|
10
|
+
canRedo(): boolean;
|
|
11
|
+
zoomToFit(): void;
|
|
12
|
+
resetView(): void;
|
|
13
|
+
placeMeasurementAtCenter(ref: MeasurementRef): void;
|
|
14
|
+
}
|
|
15
|
+
export interface UseAnnotationCanvasStateProps {
|
|
16
|
+
canvas: AnnotationCanvasState;
|
|
17
|
+
onCommit(patch: AnnotationDocumentPatch): void;
|
|
18
|
+
tools: Tool[];
|
|
19
|
+
activeToolId: string;
|
|
20
|
+
selection: Selection | null;
|
|
21
|
+
onSelectionChange(selection: Selection | null): void;
|
|
22
|
+
measurements?: Measurement[];
|
|
23
|
+
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
initialViewport?: ViewportState;
|
|
27
|
+
imperativeRef?: {
|
|
28
|
+
current: AnnotationCanvasHandle | null;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface AnnotationCanvasStateApi {
|
|
32
|
+
effectiveCanvas: AnnotationCanvasState;
|
|
33
|
+
worldTransform: Array<{
|
|
34
|
+
scale: number;
|
|
35
|
+
} | {
|
|
36
|
+
translateX: number;
|
|
37
|
+
} | {
|
|
38
|
+
translateY: number;
|
|
39
|
+
}>;
|
|
40
|
+
viewport: ViewportState;
|
|
41
|
+
measurementsById: Map<string, Measurement>;
|
|
42
|
+
activeTool: Tool | null;
|
|
43
|
+
toolState: ToolState;
|
|
44
|
+
ctx: ToolContext;
|
|
45
|
+
penDrawingStroke: AnnotationStroke | null;
|
|
46
|
+
customPreviewState: ToolState;
|
|
47
|
+
dispatchPointerDown(event: CanvasPointerEvent): void;
|
|
48
|
+
dispatchPointerMove(event: CanvasPointerEvent): void;
|
|
49
|
+
dispatchPointerUp(event: CanvasPointerEvent): void;
|
|
50
|
+
dispatchPointerCancel(): void;
|
|
51
|
+
pan(deltaScreen: Vec2): void;
|
|
52
|
+
zoom(focalScreen: Vec2, nextZoom: number): void;
|
|
53
|
+
}
|
|
54
|
+
export declare const useAnnotationCanvasState: (props: UseAnnotationCanvasStateProps) => AnnotationCanvasStateApi;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../types/annotation.js';
|
|
3
|
+
import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
4
|
+
// Platform-agnostic state machine for the annotation canvas. Web and native
|
|
5
|
+
// inners share this hook; each wraps it with platform-specific event
|
|
6
|
+
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
7
|
+
export const useAnnotationCanvasState = (props) => {
|
|
8
|
+
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, width, height, initialViewport, imperativeRef, } = props;
|
|
9
|
+
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
10
|
+
const [toolState, setToolState] = useState(undefined);
|
|
11
|
+
const [previewPatch, setPreviewPatch] = useState(null);
|
|
12
|
+
const undoStackRef = useRef([]);
|
|
13
|
+
const redoStackRef = useRef([]);
|
|
14
|
+
const activePointerIdRef = useRef(null);
|
|
15
|
+
const activeTool = useMemo(() => tools.find((t) => t.id === activeToolId) ?? null, [tools, activeToolId]);
|
|
16
|
+
const effectiveCanvas = useMemo(() => (previewPatch ? applyPatch(canvas, previewPatch) : canvas), [canvas, previewPatch]);
|
|
17
|
+
const measurementsById = useMemo(() => {
|
|
18
|
+
const map = new Map();
|
|
19
|
+
measurements?.forEach((m) => map.set(m.id, m));
|
|
20
|
+
return map;
|
|
21
|
+
}, [measurements]);
|
|
22
|
+
const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
|
|
23
|
+
const ctx = useMemo(() => ({
|
|
24
|
+
document: canvas,
|
|
25
|
+
selection,
|
|
26
|
+
viewport: viewportApi,
|
|
27
|
+
preview(patch) {
|
|
28
|
+
setPreviewPatch(patch);
|
|
29
|
+
},
|
|
30
|
+
commit(patch) {
|
|
31
|
+
const inverse = invertPatch(canvas, patch);
|
|
32
|
+
undoStackRef.current.push({ forward: patch, inverse });
|
|
33
|
+
redoStackRef.current = [];
|
|
34
|
+
setPreviewPatch(null);
|
|
35
|
+
onCommit(patch);
|
|
36
|
+
},
|
|
37
|
+
setSelection(s) {
|
|
38
|
+
onSelectionChange(s);
|
|
39
|
+
},
|
|
40
|
+
requestPickMeasurement() {
|
|
41
|
+
return pickMeasurement ? pickMeasurement() : Promise.resolve(null);
|
|
42
|
+
},
|
|
43
|
+
applyPan(deltaScreen) {
|
|
44
|
+
setViewport((v) => panBy(v, deltaScreen));
|
|
45
|
+
},
|
|
46
|
+
applyZoom(focalScreen, nextZoom) {
|
|
47
|
+
setViewport((v) => zoomAt(v, focalScreen, nextZoom));
|
|
48
|
+
},
|
|
49
|
+
}), [
|
|
50
|
+
canvas,
|
|
51
|
+
selection,
|
|
52
|
+
viewportApi,
|
|
53
|
+
onCommit,
|
|
54
|
+
onSelectionChange,
|
|
55
|
+
pickMeasurement,
|
|
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;
|
|
61
|
+
const dispatchPointerDown = useCallback((event) => {
|
|
62
|
+
if (!activeTool)
|
|
63
|
+
return;
|
|
64
|
+
activePointerIdRef.current = event.pointerId;
|
|
65
|
+
const next = activeTool.onPointerDown?.(event, ctx, toolState);
|
|
66
|
+
if (next !== undefined)
|
|
67
|
+
setToolState(next);
|
|
68
|
+
}, [activeTool, ctx, toolState]);
|
|
69
|
+
const dispatchPointerMove = useCallback((event) => {
|
|
70
|
+
if (!activeTool)
|
|
71
|
+
return;
|
|
72
|
+
if (activePointerIdRef.current !== null &&
|
|
73
|
+
event.pointerId !== activePointerIdRef.current) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const next = activeTool.onPointerMove?.(event, ctx, toolState);
|
|
77
|
+
if (next !== undefined)
|
|
78
|
+
setToolState(next);
|
|
79
|
+
}, [activeTool, ctx, toolState]);
|
|
80
|
+
const dispatchPointerUp = useCallback((event) => {
|
|
81
|
+
if (!activeTool)
|
|
82
|
+
return;
|
|
83
|
+
if (activePointerIdRef.current !== null &&
|
|
84
|
+
event.pointerId !== activePointerIdRef.current) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
activeTool.onPointerUp?.(event, ctx, toolState);
|
|
88
|
+
activePointerIdRef.current = null;
|
|
89
|
+
setToolState(undefined);
|
|
90
|
+
}, [activeTool, ctx, toolState]);
|
|
91
|
+
const dispatchPointerCancel = useCallback(() => {
|
|
92
|
+
if (activeTool)
|
|
93
|
+
activeTool.onCancel?.(toolState, ctx);
|
|
94
|
+
activePointerIdRef.current = null;
|
|
95
|
+
setToolState(undefined);
|
|
96
|
+
setPreviewPatch(null);
|
|
97
|
+
}, [activeTool, ctx, toolState]);
|
|
98
|
+
const pan = useCallback((deltaScreen) => {
|
|
99
|
+
setViewport((v) => panBy(v, deltaScreen));
|
|
100
|
+
}, []);
|
|
101
|
+
const zoom = useCallback((focalScreen, nextZoom) => {
|
|
102
|
+
setViewport((v) => zoomAt(v, focalScreen, nextZoom));
|
|
103
|
+
}, []);
|
|
104
|
+
// Imperative API mirror — set via prop so it survives WithSkiaWeb's lazy
|
|
105
|
+
// boundary on web.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!imperativeRef)
|
|
108
|
+
return;
|
|
109
|
+
imperativeRef.current = {
|
|
110
|
+
undo() {
|
|
111
|
+
const entry = undoStackRef.current.pop();
|
|
112
|
+
if (!entry)
|
|
113
|
+
return;
|
|
114
|
+
redoStackRef.current.push(entry);
|
|
115
|
+
onCommit(entry.inverse);
|
|
116
|
+
},
|
|
117
|
+
redo() {
|
|
118
|
+
const entry = redoStackRef.current.pop();
|
|
119
|
+
if (!entry)
|
|
120
|
+
return;
|
|
121
|
+
undoStackRef.current.push(entry);
|
|
122
|
+
onCommit(entry.forward);
|
|
123
|
+
},
|
|
124
|
+
canUndo() {
|
|
125
|
+
return undoStackRef.current.length > 0;
|
|
126
|
+
},
|
|
127
|
+
canRedo() {
|
|
128
|
+
return redoStackRef.current.length > 0;
|
|
129
|
+
},
|
|
130
|
+
zoomToFit() {
|
|
131
|
+
setViewport(() => {
|
|
132
|
+
const docW = canvas.viewport.width;
|
|
133
|
+
const docH = canvas.viewport.height;
|
|
134
|
+
const z = Math.min(width / docW, height / docH);
|
|
135
|
+
const offsetX = (width - docW * z) / 2;
|
|
136
|
+
const offsetY = (height - docH * z) / 2;
|
|
137
|
+
return { zoom: z, pan: { x: -offsetX / z, y: -offsetY / z } };
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
resetView() {
|
|
141
|
+
setViewport(DEFAULT_VIEWPORT);
|
|
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
|
+
},
|
|
167
|
+
};
|
|
168
|
+
return () => {
|
|
169
|
+
if (imperativeRef)
|
|
170
|
+
imperativeRef.current = null;
|
|
171
|
+
};
|
|
172
|
+
}, [
|
|
173
|
+
imperativeRef,
|
|
174
|
+
onCommit,
|
|
175
|
+
canvas.viewport.width,
|
|
176
|
+
canvas.viewport.height,
|
|
177
|
+
width,
|
|
178
|
+
height,
|
|
179
|
+
]);
|
|
180
|
+
const worldTransform = useMemo(() => [
|
|
181
|
+
{ scale: viewport.zoom },
|
|
182
|
+
{ translateX: -viewport.pan.x },
|
|
183
|
+
{ translateY: -viewport.pan.y },
|
|
184
|
+
], [viewport]);
|
|
185
|
+
// Detect the built-in pen tool's in-flight state shape so the canvas can
|
|
186
|
+
// render the preview (the tool factory is Skia-free and can't render).
|
|
187
|
+
const penDrawingStroke = toolState &&
|
|
188
|
+
typeof toolState === 'object' &&
|
|
189
|
+
'kind' in toolState &&
|
|
190
|
+
toolState.kind === 'pen-drawing'
|
|
191
|
+
? toolState.stroke
|
|
192
|
+
: null;
|
|
193
|
+
return {
|
|
194
|
+
effectiveCanvas,
|
|
195
|
+
worldTransform,
|
|
196
|
+
viewport,
|
|
197
|
+
measurementsById,
|
|
198
|
+
activeTool,
|
|
199
|
+
toolState,
|
|
200
|
+
ctx,
|
|
201
|
+
penDrawingStroke,
|
|
202
|
+
customPreviewState: toolState,
|
|
203
|
+
dispatchPointerDown,
|
|
204
|
+
dispatchPointerMove,
|
|
205
|
+
dispatchPointerUp,
|
|
206
|
+
dispatchPointerCancel,
|
|
207
|
+
pan,
|
|
208
|
+
zoom,
|
|
209
|
+
};
|
|
210
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Vec2 } from '../types/annotation.js';
|
|
2
|
+
export interface ViewportState {
|
|
3
|
+
zoom: number;
|
|
4
|
+
pan: Vec2;
|
|
5
|
+
}
|
|
6
|
+
export interface ViewportApi {
|
|
7
|
+
state: ViewportState;
|
|
8
|
+
screenToWorld(screen: Vec2): Vec2;
|
|
9
|
+
worldToScreen(world: Vec2): Vec2;
|
|
10
|
+
worldDistanceToScreen(distance: number): number;
|
|
11
|
+
}
|
|
12
|
+
export declare const createViewportApi: (state: ViewportState) => ViewportApi;
|
|
13
|
+
export declare const DEFAULT_VIEWPORT: ViewportState;
|
|
14
|
+
export declare const fitToScreen: (docWidth: number, docHeight: number, screenWidth: number, screenHeight: number, padding?: number) => ViewportState;
|
|
15
|
+
export declare const zoomAt: (state: ViewportState, focalScreen: Vec2, nextZoom: number) => ViewportState;
|
|
16
|
+
export declare const panBy: (state: ViewportState, deltaScreen: Vec2) => ViewportState;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const createViewportApi = (state) => ({
|
|
2
|
+
state,
|
|
3
|
+
screenToWorld: ({ x, y }) => ({
|
|
4
|
+
x: x / state.zoom + state.pan.x,
|
|
5
|
+
y: y / state.zoom + state.pan.y,
|
|
6
|
+
}),
|
|
7
|
+
worldToScreen: ({ x, y }) => ({
|
|
8
|
+
x: (x - state.pan.x) * state.zoom,
|
|
9
|
+
y: (y - state.pan.y) * state.zoom,
|
|
10
|
+
}),
|
|
11
|
+
worldDistanceToScreen: (d) => d * state.zoom,
|
|
12
|
+
});
|
|
13
|
+
export const DEFAULT_VIEWPORT = {
|
|
14
|
+
zoom: 1,
|
|
15
|
+
pan: { x: 0, y: 0 },
|
|
16
|
+
};
|
|
17
|
+
// Returns a viewport that fits `docWidth x docHeight` into `screenWidth x
|
|
18
|
+
// screenHeight` with optional padding (screen-space pixels).
|
|
19
|
+
export const fitToScreen = (docWidth, docHeight, screenWidth, screenHeight, padding = 16) => {
|
|
20
|
+
const availableW = Math.max(1, screenWidth - padding * 2);
|
|
21
|
+
const availableH = Math.max(1, screenHeight - padding * 2);
|
|
22
|
+
const zoom = Math.min(availableW / docWidth, availableH / docHeight);
|
|
23
|
+
const renderedW = docWidth * zoom;
|
|
24
|
+
const renderedH = docHeight * zoom;
|
|
25
|
+
const offsetX = (screenWidth - renderedW) / 2;
|
|
26
|
+
const offsetY = (screenHeight - renderedH) / 2;
|
|
27
|
+
return {
|
|
28
|
+
zoom,
|
|
29
|
+
pan: { x: -offsetX / zoom, y: -offsetY / zoom },
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
// Zoom toward a focal screen point so the world point under the cursor stays
|
|
33
|
+
// fixed. Used for wheel-zoom on web and pinch on native.
|
|
34
|
+
export const zoomAt = (state, focalScreen, nextZoom) => {
|
|
35
|
+
const clampedZoom = Math.max(0.05, Math.min(50, nextZoom));
|
|
36
|
+
const focalWorld = {
|
|
37
|
+
x: focalScreen.x / state.zoom + state.pan.x,
|
|
38
|
+
y: focalScreen.y / state.zoom + state.pan.y,
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
zoom: clampedZoom,
|
|
42
|
+
pan: {
|
|
43
|
+
x: focalWorld.x - focalScreen.x / clampedZoom,
|
|
44
|
+
y: focalWorld.y - focalScreen.y / clampedZoom,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export const panBy = (state, deltaScreen) => ({
|
|
49
|
+
zoom: state.zoom,
|
|
50
|
+
pan: {
|
|
51
|
+
x: state.pan.x - deltaScreen.x / state.zoom,
|
|
52
|
+
y: state.pan.y - deltaScreen.y / state.zoom,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { AnnotationDataProvider } from './AnnotationDataProvider.js';
|
|
3
|
+
export interface AnnotationDataProviderProps {
|
|
4
|
+
value: AnnotationDataProvider;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export declare const AnnotationDataProviderContext: ({ value, children, }: AnnotationDataProviderProps) => import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export declare const useAnnotationData: () => AnnotationDataProvider;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
const AnnotationDataContext = createContext(null);
|
|
4
|
+
export const AnnotationDataProviderContext = ({ value, children, }) => (_jsx(AnnotationDataContext.Provider, { value: value, children: children }));
|
|
5
|
+
export const useAnnotationData = () => {
|
|
6
|
+
const provider = useContext(AnnotationDataContext);
|
|
7
|
+
if (!provider) {
|
|
8
|
+
throw new Error('useAnnotationData must be used inside <AnnotationDataProviderContext>');
|
|
9
|
+
}
|
|
10
|
+
return provider;
|
|
11
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FileUpload, FileUploadType, Measurement } from '../types/firestore.js';
|
|
2
|
+
export interface JobScope {
|
|
3
|
+
orgId: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
jobId: string;
|
|
6
|
+
}
|
|
7
|
+
export interface JobGroupScope extends JobScope {
|
|
8
|
+
groupId: string;
|
|
9
|
+
}
|
|
10
|
+
export type Unsubscribe = () => void;
|
|
11
|
+
export type FieldOp = {
|
|
12
|
+
kind: 'serverTimestamp';
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'arrayUnion';
|
|
15
|
+
values: unknown[];
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'arrayRemove';
|
|
18
|
+
values: unknown[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'increment';
|
|
21
|
+
by: number;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'delete';
|
|
24
|
+
};
|
|
25
|
+
export declare const isFieldOp: (v: unknown) => v is FieldOp;
|
|
26
|
+
export type Patch<T> = {
|
|
27
|
+
[K in keyof T]?: T[K] | FieldOp;
|
|
28
|
+
};
|
|
29
|
+
export interface ImageBlob {
|
|
30
|
+
data: Blob | ArrayBuffer | string;
|
|
31
|
+
contentType: string;
|
|
32
|
+
filename?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface UploadedImageRef {
|
|
35
|
+
storagePath: string;
|
|
36
|
+
downloadUrl: string;
|
|
37
|
+
width?: number;
|
|
38
|
+
height?: number;
|
|
39
|
+
bytes?: number;
|
|
40
|
+
}
|
|
41
|
+
export type AnnotationFile = Extract<FileUpload, {
|
|
42
|
+
type: FileUploadType.Canvas;
|
|
43
|
+
}>;
|
|
44
|
+
export interface AnnotationFileSummary {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
thumbnailUrl: string | null;
|
|
48
|
+
fileType: 'sketch' | 'document';
|
|
49
|
+
isLabel?: boolean;
|
|
50
|
+
createdAt?: Date;
|
|
51
|
+
createdBy?: AnnotationFile['createdBy'];
|
|
52
|
+
}
|
|
53
|
+
export interface AnnotationDataProvider {
|
|
54
|
+
create(scope: JobGroupScope, seed: Partial<AnnotationFile>): Promise<string>;
|
|
55
|
+
get(scope: JobGroupScope, fileId: string): Promise<AnnotationFile | null>;
|
|
56
|
+
update(scope: JobGroupScope, fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
|
|
57
|
+
delete(scope: JobGroupScope, fileId: string): Promise<void>;
|
|
58
|
+
subscribe(scope: JobGroupScope, fileId: string, onNext: (doc: AnnotationFile | null) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
59
|
+
list(scope: JobGroupScope, onNext: (files: AnnotationFileSummary[]) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
60
|
+
subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
61
|
+
subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
|
|
62
|
+
uploadImage(scope: JobGroupScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
|
|
63
|
+
getImageUrl(scope: JobGroupScope, fileId: string, storagePath: string): Promise<string>;
|
|
64
|
+
deleteImage(scope: JobGroupScope, fileId: string, storagePath: string): Promise<void>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Measurement } from '../types/firestore.js';
|
|
2
|
+
import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFileSummary, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type Unsubscribe, type UploadedImageRef } from './AnnotationDataProvider.js';
|
|
3
|
+
export declare class InMemoryAnnotationProvider implements AnnotationDataProvider {
|
|
4
|
+
private docs;
|
|
5
|
+
private measurements;
|
|
6
|
+
private images;
|
|
7
|
+
private docListeners;
|
|
8
|
+
private listListeners;
|
|
9
|
+
private groupMeasurementListeners;
|
|
10
|
+
private jobMeasurementListeners;
|
|
11
|
+
private nextId;
|
|
12
|
+
setMeasurements(scope: JobGroupScope, measurements: Measurement[]): void;
|
|
13
|
+
create(scope: JobGroupScope, seed: Partial<AnnotationFile>): Promise<string>;
|
|
14
|
+
get(scope: JobGroupScope, fileId: string): Promise<AnnotationFile | null>;
|
|
15
|
+
update(scope: JobGroupScope, fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
|
|
16
|
+
delete(scope: JobGroupScope, fileId: string): Promise<void>;
|
|
17
|
+
subscribe(scope: JobGroupScope, fileId: string, onNext: (doc: AnnotationFile | null) => void): Unsubscribe;
|
|
18
|
+
list(scope: JobGroupScope, onNext: (files: AnnotationFileSummary[]) => void): Unsubscribe;
|
|
19
|
+
subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
|
|
20
|
+
subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
|
|
21
|
+
uploadImage(scope: JobGroupScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
|
|
22
|
+
getImageUrl(_scope: JobGroupScope, fileId: string, storagePath: string): Promise<string>;
|
|
23
|
+
deleteImage(_scope: JobGroupScope, fileId: string, storagePath: string): Promise<void>;
|
|
24
|
+
private getBucket;
|
|
25
|
+
private notifyDoc;
|
|
26
|
+
private notifyList;
|
|
27
|
+
private notifyGroupMeasurements;
|
|
28
|
+
private notifyJobMeasurements;
|
|
29
|
+
private collectJobMeasurements;
|
|
30
|
+
}
|