@reekon-tools/boldr-utils 1.6.4 → 1.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +37 -0
- package/dist/canvas/AnnotationCanvasInner.js +179 -0
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +33 -0
- package/dist/canvas/AnnotationCanvasInner.native.js +102 -0
- package/dist/canvas/AnnotationCanvasSkia.d.ts +26 -0
- package/dist/canvas/AnnotationCanvasSkia.js +19 -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/pointerAdapter.d.ts +3 -0
- package/dist/canvas/pointerAdapter.js +19 -0
- package/dist/canvas/stampLayout.d.ts +4 -0
- package/dist/canvas/stampLayout.js +8 -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 +53 -0
- package/dist/canvas/useAnnotationCanvasState.js +182 -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/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/exports.d.ts +25 -0
- package/dist/exports.js +26 -0
- package/dist/index.d.ts +2 -8
- package/dist/index.js +6 -8
- package/dist/index.native.d.ts +5 -0
- package/dist/index.native.js +6 -0
- package/dist/types/annotation.d.ts +139 -0
- package/dist/types/annotation.js +147 -0
- package/dist/types/firestore.d.ts +4 -15
- package/dist/types/firestore.js +0 -2
- package/dist/utils/groups.d.ts +4 -0
- package/dist/utils/groups.js +3 -0
- package/dist/utils/micrometersToUnit.d.ts +0 -1
- package/dist/utils/micrometersToUnit.js +1 -24
- package/dist/utils/tolerance.d.ts +0 -15
- package/dist/utils/tolerance.js +0 -41
- package/package.json +33 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SkPath } from '@shopify/react-native-skia';
|
|
2
|
+
import type { AnnotationStroke } from '../../types/annotation.js';
|
|
3
|
+
export declare const pointsToSkPath: (points: number[]) => SkPath;
|
|
4
|
+
export interface StrokeElementProps {
|
|
5
|
+
stroke: AnnotationStroke;
|
|
6
|
+
}
|
|
7
|
+
export declare const StrokeElement: ({ stroke }: StrokeElementProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Path, Skia } from '@shopify/react-native-skia';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
export const pointsToSkPath = (points) => {
|
|
5
|
+
const path = Skia.Path.Make();
|
|
6
|
+
if (points.length < 2)
|
|
7
|
+
return path;
|
|
8
|
+
path.moveTo(points[0], points[1]);
|
|
9
|
+
for (let i = 2; i < points.length; i += 2) {
|
|
10
|
+
path.lineTo(points[i], points[i + 1]);
|
|
11
|
+
}
|
|
12
|
+
return path;
|
|
13
|
+
};
|
|
14
|
+
export const StrokeElement = ({ stroke }) => {
|
|
15
|
+
const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
|
|
16
|
+
const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
|
|
17
|
+
return (_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: "round", strokeJoin: "round", opacity: opacity }));
|
|
18
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Units } from '../types/firestore.js';
|
|
2
|
+
export interface MeasurementRef {
|
|
3
|
+
measurementId: string;
|
|
4
|
+
measurementPath: string;
|
|
5
|
+
groupId: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
value?: number;
|
|
8
|
+
unit?: Units;
|
|
9
|
+
}
|
|
10
|
+
export type PickMeasurement = () => Promise<MeasurementRef | null>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Web DOM PointerEvent → CanvasPointerEvent. The native variant will live
|
|
2
|
+
// in pointerAdapter.native.ts and will translate gesture-handler events.
|
|
3
|
+
export const domEventToCanvasPointerEvent = (event, canvas, viewport) => {
|
|
4
|
+
const rect = canvas.getBoundingClientRect();
|
|
5
|
+
const screen = {
|
|
6
|
+
x: event.clientX - rect.left,
|
|
7
|
+
y: event.clientY - rect.top,
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
pointerId: event.pointerId,
|
|
11
|
+
screen,
|
|
12
|
+
world: viewport.screenToWorld(screen),
|
|
13
|
+
pressure: event.pressure || undefined,
|
|
14
|
+
shiftKey: event.shiftKey,
|
|
15
|
+
altKey: event.altKey,
|
|
16
|
+
metaKey: event.metaKey,
|
|
17
|
+
ctrlKey: event.ctrlKey,
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Shared layout constants for the placed measurement stamp. Lives in a
|
|
2
|
+
// Skia-free module so both render (MeasurementStampElement) and hit-test
|
|
3
|
+
// (selectTool) can import it without dragging @shopify/react-native-skia
|
|
4
|
+
// into the consumer's static import graph.
|
|
5
|
+
export const STAMP_WIDTH = 120;
|
|
6
|
+
export const STAMP_HEIGHT = 44;
|
|
7
|
+
export const STAMP_PADDING_X = 10;
|
|
8
|
+
export const STAMP_PADDING_Y = 6;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface MeasurementStampToolOptions {
|
|
4
|
+
autoSwitchToSelect?: boolean;
|
|
5
|
+
onPlaced?: (ref: PlacedMeasurementRef) => void;
|
|
6
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
7
|
+
selectToolId?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const createMeasurementStampTool: (options?: MeasurementStampToolOptions) => Tool;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../types/annotation.js';
|
|
2
|
+
let counter = 0;
|
|
3
|
+
const makeId = () => `measurement-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
4
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
5
|
+
export const createMeasurementStampTool = (options = {}) => {
|
|
6
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
7
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
8
|
+
return {
|
|
9
|
+
id: 'measurement',
|
|
10
|
+
label: 'Place measurement',
|
|
11
|
+
cursor: 'copy',
|
|
12
|
+
onPointerUp(event, ctx) {
|
|
13
|
+
// Tap-to-place. Open the consumer's picker and commit on selection.
|
|
14
|
+
void ctx.requestPickMeasurement().then((ref) => {
|
|
15
|
+
if (!ref)
|
|
16
|
+
return;
|
|
17
|
+
const placed = {
|
|
18
|
+
id: makeId(),
|
|
19
|
+
layerId: firstLayerId(ctx.document),
|
|
20
|
+
measurementPath: ref.measurementPath,
|
|
21
|
+
measurementId: ref.measurementId,
|
|
22
|
+
groupId: ref.groupId,
|
|
23
|
+
anchor: event.world,
|
|
24
|
+
labelOverride: ref.label,
|
|
25
|
+
unitOverride: ref.unit,
|
|
26
|
+
showLabel: true,
|
|
27
|
+
showValue: true,
|
|
28
|
+
createdAt: Date.now(),
|
|
29
|
+
};
|
|
30
|
+
ctx.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
|
|
31
|
+
options.onPlaced?.(placed);
|
|
32
|
+
if (autoSwitchToSelect)
|
|
33
|
+
options.onAutoSwitch?.(selectToolId);
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Built-in "Hand" tool. When active, any drag pans the viewport. Tools that
|
|
2
|
+
// want one-off pan triggers (middle-mouse, space-drag) get those via the
|
|
3
|
+
// AnnotationCanvas `gestures` prop without needing to switch tools.
|
|
4
|
+
export const createPanTool = (options = {}) => ({
|
|
5
|
+
id: 'pan',
|
|
6
|
+
label: 'Hand',
|
|
7
|
+
cursor: options.cursor ?? 'grab',
|
|
8
|
+
onPointerDown(event, _ctx, _state) {
|
|
9
|
+
return { kind: 'panning', lastScreen: event.screen };
|
|
10
|
+
},
|
|
11
|
+
onPointerMove(event, ctx, state) {
|
|
12
|
+
const s = state;
|
|
13
|
+
if (s?.kind !== 'panning')
|
|
14
|
+
return s;
|
|
15
|
+
const delta = {
|
|
16
|
+
x: event.screen.x - s.lastScreen.x,
|
|
17
|
+
y: event.screen.y - s.lastScreen.y,
|
|
18
|
+
};
|
|
19
|
+
ctx.applyPan(delta);
|
|
20
|
+
return { kind: 'panning', lastScreen: event.screen };
|
|
21
|
+
},
|
|
22
|
+
onPointerUp(_event, _ctx, _state) {
|
|
23
|
+
// No commit — viewport state is canvas-internal, not persisted.
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AnnotationStroke } from '../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface PenToolOptions {
|
|
4
|
+
color?: string;
|
|
5
|
+
width?: number;
|
|
6
|
+
minSampleDistance?: number;
|
|
7
|
+
variant?: 'pen' | 'marker' | 'highlighter';
|
|
8
|
+
}
|
|
9
|
+
export interface PenDrawingState {
|
|
10
|
+
kind: 'pen-drawing';
|
|
11
|
+
stroke: AnnotationStroke;
|
|
12
|
+
}
|
|
13
|
+
export declare const createPenTool: (options?: PenToolOptions) => Tool;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../types/annotation.js';
|
|
2
|
+
const distSq = (ax, ay, bx, by) => {
|
|
3
|
+
const dx = ax - bx;
|
|
4
|
+
const dy = ay - by;
|
|
5
|
+
return dx * dx + dy * dy;
|
|
6
|
+
};
|
|
7
|
+
let counter = 0;
|
|
8
|
+
const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
9
|
+
export const createPenTool = (options = {}) => {
|
|
10
|
+
const color = options.color ?? '#111827';
|
|
11
|
+
const width = options.width ?? 2;
|
|
12
|
+
const variant = options.variant ?? 'pen';
|
|
13
|
+
const minSampleDistance = options.minSampleDistance ?? 1.5;
|
|
14
|
+
const minSampleDistanceSq = minSampleDistance * minSampleDistance;
|
|
15
|
+
return {
|
|
16
|
+
id: variant,
|
|
17
|
+
label: variant === 'pen' ? 'Pen' : variant === 'marker' ? 'Marker' : 'Highlighter',
|
|
18
|
+
cursor: 'crosshair',
|
|
19
|
+
onPointerDown(event, ctx) {
|
|
20
|
+
const stroke = {
|
|
21
|
+
id: makeId('stroke'),
|
|
22
|
+
layerId: ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
23
|
+
tool: variant,
|
|
24
|
+
color,
|
|
25
|
+
width,
|
|
26
|
+
points: [event.world.x, event.world.y],
|
|
27
|
+
pressure: event.pressure !== undefined ? [event.pressure] : undefined,
|
|
28
|
+
createdAt: Date.now(),
|
|
29
|
+
};
|
|
30
|
+
return { kind: 'pen-drawing', stroke };
|
|
31
|
+
},
|
|
32
|
+
onPointerMove(event, _ctx, state) {
|
|
33
|
+
const s = state;
|
|
34
|
+
if (s?.kind !== 'pen-drawing')
|
|
35
|
+
return s;
|
|
36
|
+
const len = s.stroke.points.length;
|
|
37
|
+
const lastX = s.stroke.points[len - 2];
|
|
38
|
+
const lastY = s.stroke.points[len - 1];
|
|
39
|
+
if (distSq(lastX, lastY, event.world.x, event.world.y) < minSampleDistanceSq) {
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: 'pen-drawing',
|
|
44
|
+
stroke: {
|
|
45
|
+
...s.stroke,
|
|
46
|
+
points: [...s.stroke.points, event.world.x, event.world.y],
|
|
47
|
+
pressure: s.stroke.pressure && event.pressure !== undefined
|
|
48
|
+
? [...s.stroke.pressure, event.pressure]
|
|
49
|
+
: s.stroke.pressure,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
onPointerUp(_event, ctx, state) {
|
|
54
|
+
const s = state;
|
|
55
|
+
if (s?.kind !== 'pen-drawing')
|
|
56
|
+
return;
|
|
57
|
+
// Need at least two distinct samples to be a stroke.
|
|
58
|
+
if (s.stroke.points.length < 4)
|
|
59
|
+
return;
|
|
60
|
+
ctx.commit({ ops: [{ op: 'addStroke', stroke: s.stroke }] });
|
|
61
|
+
},
|
|
62
|
+
// No renderPreview. The canvas inner detects PenDrawingState in
|
|
63
|
+
// toolState and renders the in-flight stroke directly with its own
|
|
64
|
+
// (Skia-aware) StrokeElement. Keeping tools Skia-free is what lets
|
|
65
|
+
// consumers import them statically without breaking WithSkiaWeb's
|
|
66
|
+
// lazy-load ordering on web.
|
|
67
|
+
};
|
|
68
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { STAMP_HEIGHT, STAMP_WIDTH } 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) => {
|
|
16
|
+
// Stamp rectangle is fixed-size, centered on the anchor and scaled by
|
|
17
|
+
// `placed.scale`. Match the hit box exactly to that rectangle (plus a
|
|
18
|
+
// small padding so the click target feels generous).
|
|
19
|
+
const scale = m.scale ?? 1;
|
|
20
|
+
const halfW = (STAMP_WIDTH * scale) / 2 + HIT_PADDING;
|
|
21
|
+
const halfH = (STAMP_HEIGHT * scale) / 2 + HIT_PADDING;
|
|
22
|
+
const dx = Math.abs(p.x - m.anchor.x);
|
|
23
|
+
const dy = Math.abs(p.y - m.anchor.y);
|
|
24
|
+
return dx <= halfW && dy <= halfH;
|
|
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) => {
|
|
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))
|
|
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);
|
|
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,53 @@
|
|
|
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
|
+
}
|
|
14
|
+
export interface UseAnnotationCanvasStateProps {
|
|
15
|
+
canvas: AnnotationCanvasState;
|
|
16
|
+
onCommit(patch: AnnotationDocumentPatch): void;
|
|
17
|
+
tools: Tool[];
|
|
18
|
+
activeToolId: string;
|
|
19
|
+
selection: Selection | null;
|
|
20
|
+
onSelectionChange(selection: Selection | null): void;
|
|
21
|
+
measurements?: Measurement[];
|
|
22
|
+
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
initialViewport?: ViewportState;
|
|
26
|
+
imperativeRef?: {
|
|
27
|
+
current: AnnotationCanvasHandle | null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface AnnotationCanvasStateApi {
|
|
31
|
+
effectiveCanvas: AnnotationCanvasState;
|
|
32
|
+
worldTransform: Array<{
|
|
33
|
+
scale: number;
|
|
34
|
+
} | {
|
|
35
|
+
translateX: number;
|
|
36
|
+
} | {
|
|
37
|
+
translateY: number;
|
|
38
|
+
}>;
|
|
39
|
+
viewport: ViewportState;
|
|
40
|
+
measurementsById: Map<string, Measurement>;
|
|
41
|
+
activeTool: Tool | null;
|
|
42
|
+
toolState: ToolState;
|
|
43
|
+
ctx: ToolContext;
|
|
44
|
+
penDrawingStroke: AnnotationStroke | null;
|
|
45
|
+
customPreviewState: ToolState;
|
|
46
|
+
dispatchPointerDown(event: CanvasPointerEvent): void;
|
|
47
|
+
dispatchPointerMove(event: CanvasPointerEvent): void;
|
|
48
|
+
dispatchPointerUp(event: CanvasPointerEvent): void;
|
|
49
|
+
dispatchPointerCancel(): void;
|
|
50
|
+
pan(deltaScreen: Vec2): void;
|
|
51
|
+
zoom(focalScreen: Vec2, nextZoom: number): void;
|
|
52
|
+
}
|
|
53
|
+
export declare const useAnnotationCanvasState: (props: UseAnnotationCanvasStateProps) => AnnotationCanvasStateApi;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { applyPatch, invertPatch, } 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
|
+
const dispatchPointerDown = useCallback((event) => {
|
|
58
|
+
if (!activeTool)
|
|
59
|
+
return;
|
|
60
|
+
activePointerIdRef.current = event.pointerId;
|
|
61
|
+
const next = activeTool.onPointerDown?.(event, ctx, toolState);
|
|
62
|
+
if (next !== undefined)
|
|
63
|
+
setToolState(next);
|
|
64
|
+
}, [activeTool, ctx, toolState]);
|
|
65
|
+
const dispatchPointerMove = useCallback((event) => {
|
|
66
|
+
if (!activeTool)
|
|
67
|
+
return;
|
|
68
|
+
if (activePointerIdRef.current !== null &&
|
|
69
|
+
event.pointerId !== activePointerIdRef.current) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const next = activeTool.onPointerMove?.(event, ctx, toolState);
|
|
73
|
+
if (next !== undefined)
|
|
74
|
+
setToolState(next);
|
|
75
|
+
}, [activeTool, ctx, toolState]);
|
|
76
|
+
const dispatchPointerUp = useCallback((event) => {
|
|
77
|
+
if (!activeTool)
|
|
78
|
+
return;
|
|
79
|
+
if (activePointerIdRef.current !== null &&
|
|
80
|
+
event.pointerId !== activePointerIdRef.current) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
activeTool.onPointerUp?.(event, ctx, toolState);
|
|
84
|
+
activePointerIdRef.current = null;
|
|
85
|
+
setToolState(undefined);
|
|
86
|
+
}, [activeTool, ctx, toolState]);
|
|
87
|
+
const dispatchPointerCancel = useCallback(() => {
|
|
88
|
+
if (activeTool)
|
|
89
|
+
activeTool.onCancel?.(toolState, ctx);
|
|
90
|
+
activePointerIdRef.current = null;
|
|
91
|
+
setToolState(undefined);
|
|
92
|
+
setPreviewPatch(null);
|
|
93
|
+
}, [activeTool, ctx, toolState]);
|
|
94
|
+
const pan = useCallback((deltaScreen) => {
|
|
95
|
+
setViewport((v) => panBy(v, deltaScreen));
|
|
96
|
+
}, []);
|
|
97
|
+
const zoom = useCallback((focalScreen, nextZoom) => {
|
|
98
|
+
setViewport((v) => zoomAt(v, focalScreen, nextZoom));
|
|
99
|
+
}, []);
|
|
100
|
+
// Imperative API mirror — set via prop so it survives WithSkiaWeb's lazy
|
|
101
|
+
// boundary on web.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!imperativeRef)
|
|
104
|
+
return;
|
|
105
|
+
imperativeRef.current = {
|
|
106
|
+
undo() {
|
|
107
|
+
const entry = undoStackRef.current.pop();
|
|
108
|
+
if (!entry)
|
|
109
|
+
return;
|
|
110
|
+
redoStackRef.current.push(entry);
|
|
111
|
+
onCommit(entry.inverse);
|
|
112
|
+
},
|
|
113
|
+
redo() {
|
|
114
|
+
const entry = redoStackRef.current.pop();
|
|
115
|
+
if (!entry)
|
|
116
|
+
return;
|
|
117
|
+
undoStackRef.current.push(entry);
|
|
118
|
+
onCommit(entry.forward);
|
|
119
|
+
},
|
|
120
|
+
canUndo() {
|
|
121
|
+
return undoStackRef.current.length > 0;
|
|
122
|
+
},
|
|
123
|
+
canRedo() {
|
|
124
|
+
return redoStackRef.current.length > 0;
|
|
125
|
+
},
|
|
126
|
+
zoomToFit() {
|
|
127
|
+
setViewport(() => {
|
|
128
|
+
const docW = canvas.viewport.width;
|
|
129
|
+
const docH = canvas.viewport.height;
|
|
130
|
+
const z = Math.min(width / docW, height / docH);
|
|
131
|
+
const offsetX = (width - docW * z) / 2;
|
|
132
|
+
const offsetY = (height - docH * z) / 2;
|
|
133
|
+
return { zoom: z, pan: { x: -offsetX / z, y: -offsetY / z } };
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
resetView() {
|
|
137
|
+
setViewport(DEFAULT_VIEWPORT);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
return () => {
|
|
141
|
+
if (imperativeRef)
|
|
142
|
+
imperativeRef.current = null;
|
|
143
|
+
};
|
|
144
|
+
}, [
|
|
145
|
+
imperativeRef,
|
|
146
|
+
onCommit,
|
|
147
|
+
canvas.viewport.width,
|
|
148
|
+
canvas.viewport.height,
|
|
149
|
+
width,
|
|
150
|
+
height,
|
|
151
|
+
]);
|
|
152
|
+
const worldTransform = useMemo(() => [
|
|
153
|
+
{ scale: viewport.zoom },
|
|
154
|
+
{ translateX: -viewport.pan.x },
|
|
155
|
+
{ translateY: -viewport.pan.y },
|
|
156
|
+
], [viewport]);
|
|
157
|
+
// Detect the built-in pen tool's in-flight state shape so the canvas can
|
|
158
|
+
// render the preview (the tool factory is Skia-free and can't render).
|
|
159
|
+
const penDrawingStroke = toolState &&
|
|
160
|
+
typeof toolState === 'object' &&
|
|
161
|
+
'kind' in toolState &&
|
|
162
|
+
toolState.kind === 'pen-drawing'
|
|
163
|
+
? toolState.stroke
|
|
164
|
+
: null;
|
|
165
|
+
return {
|
|
166
|
+
effectiveCanvas,
|
|
167
|
+
worldTransform,
|
|
168
|
+
viewport,
|
|
169
|
+
measurementsById,
|
|
170
|
+
activeTool,
|
|
171
|
+
toolState,
|
|
172
|
+
ctx,
|
|
173
|
+
penDrawingStroke,
|
|
174
|
+
customPreviewState: toolState,
|
|
175
|
+
dispatchPointerDown,
|
|
176
|
+
dispatchPointerMove,
|
|
177
|
+
dispatchPointerUp,
|
|
178
|
+
dispatchPointerCancel,
|
|
179
|
+
pan,
|
|
180
|
+
zoom,
|
|
181
|
+
};
|
|
182
|
+
};
|