@reekon-tools/boldr-utils 1.6.24 → 1.6.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +179 -78
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +4 -2
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +5 -2
- package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +4 -1
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +21 -19
- package/dist/annotation/canvas/useAnnotationCanvasState.js +41 -10
- package/dist/annotation/canvas/viewport.d.ts +13 -1
- package/dist/annotation/canvas/viewport.js +32 -0
- 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/exports.d.ts +2 -1
- package/dist/exports.js +2 -1
- package/dist/hooks/useParseMeasurement.d.ts +4 -0
- package/dist/hooks/useParseMeasurement.js +14 -0
- package/dist/utils/evaluateFormula.d.ts +20 -0
- package/dist/utils/evaluateFormula.js +31 -0
- package/dist/utils/indexLabels.d.ts +3 -0
- package/dist/utils/indexLabels.js +40 -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
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
|
-
import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
3
|
+
import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
4
4
|
import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
5
|
+
// The viewport that frames the document's content on screen. When a background
|
|
6
|
+
// image is present we fit its rendered rect (so the whole image shows, filling
|
|
7
|
+
// the viewport regardless of its pixel resolution — never the native-resolution
|
|
8
|
+
// top-left crop a 1:1 viewport gives a high-res image); otherwise we fit the
|
|
9
|
+
// document rect. Shared by the load-time auto-fit and the zoomToFit/resetView
|
|
10
|
+
// handle methods so they stay in lockstep.
|
|
11
|
+
const computeContentFit = (canvas, width, height) => {
|
|
12
|
+
if (!(width > 0) || !(height > 0))
|
|
13
|
+
return DEFAULT_VIEWPORT;
|
|
14
|
+
const { viewport } = canvas;
|
|
15
|
+
const bg = viewport.backgroundImage;
|
|
16
|
+
const rect = bg
|
|
17
|
+
? imageDocRect(bg.widthPx, bg.heightPx, viewport.width, viewport.height, viewport.backgroundFit ?? 'contain')
|
|
18
|
+
: { x: 0, y: 0, width: viewport.width, height: viewport.height };
|
|
19
|
+
return fitRectToScreen(rect, width, height);
|
|
20
|
+
};
|
|
5
21
|
// Platform-agnostic state machine for the annotation canvas. Web and native
|
|
6
22
|
// inners share this hook; each wraps it with platform-specific event
|
|
7
23
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
@@ -21,6 +37,25 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
21
37
|
return map;
|
|
22
38
|
}, [measurements]);
|
|
23
39
|
const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
|
|
40
|
+
// Frame the document the first time it carries a background image. The doc
|
|
41
|
+
// hydrates asynchronously (it opens empty, then the persisted canvas — with
|
|
42
|
+
// its background — arrives), and a high-res image at the 1:1 default viewport
|
|
43
|
+
// would otherwise show only its top-left corner. We fit it to the screen
|
|
44
|
+
// exactly once per mount; the user's own pan/zoom afterwards is preserved.
|
|
45
|
+
// (Adding a background mid-session also fits it, so you see the whole image
|
|
46
|
+
// you just dropped in.) Canvases that never gain a background keep the 1:1
|
|
47
|
+
// default — for a screen-sized document that already frames it correctly.
|
|
48
|
+
const didInitialFitRef = useRef(false);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (didInitialFitRef.current)
|
|
51
|
+
return;
|
|
52
|
+
if (!canvas.viewport.backgroundImage)
|
|
53
|
+
return;
|
|
54
|
+
if (!(width > 0) || !(height > 0))
|
|
55
|
+
return;
|
|
56
|
+
didInitialFitRef.current = true;
|
|
57
|
+
setViewport(computeContentFit(canvas, width, height));
|
|
58
|
+
}, [canvas, width, height]);
|
|
24
59
|
// Tiles are sized independently of the canvas: their footprint is base ×
|
|
25
60
|
// per-tile scale × the document-wide `tileScaleFactor` ("Tile size" slider),
|
|
26
61
|
// never the canvas pixel size — so resizing the pane no longer rescales tiles.
|
|
@@ -174,17 +209,13 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
174
209
|
return redoStackRef.current.length > 0;
|
|
175
210
|
},
|
|
176
211
|
zoomToFit() {
|
|
177
|
-
setViewport((
|
|
178
|
-
const docW = canvas.viewport.width;
|
|
179
|
-
const docH = canvas.viewport.height;
|
|
180
|
-
const z = Math.min(width / docW, height / docH);
|
|
181
|
-
const offsetX = (width - docW * z) / 2;
|
|
182
|
-
const offsetY = (height - docH * z) / 2;
|
|
183
|
-
return { zoom: z, pan: { x: -offsetX / z, y: -offsetY / z } };
|
|
184
|
-
});
|
|
212
|
+
setViewport(computeContentFit(canvas, width, height));
|
|
185
213
|
},
|
|
186
214
|
resetView() {
|
|
187
|
-
|
|
215
|
+
// "Reset view" frames the whole document/image again — the same fit the
|
|
216
|
+
// canvas opens with — rather than snapping to a 1:1 top-left view, which
|
|
217
|
+
// for a high-res background is the very crop this is meant to escape.
|
|
218
|
+
setViewport(computeContentFit(canvas, width, height));
|
|
188
219
|
},
|
|
189
220
|
placeMeasurementAtCenter(ref) {
|
|
190
221
|
const c = ctxRef.current;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Vec2 } from '../../types/annotation.js';
|
|
1
|
+
import type { BackgroundFit, Vec2 } from '../../types/annotation.js';
|
|
2
2
|
export interface ViewportState {
|
|
3
3
|
zoom: number;
|
|
4
4
|
pan: Vec2;
|
|
@@ -12,5 +12,17 @@ export interface ViewportApi {
|
|
|
12
12
|
export declare const createViewportApi: (state: ViewportState) => ViewportApi;
|
|
13
13
|
export declare const DEFAULT_VIEWPORT: ViewportState;
|
|
14
14
|
export declare const fitToScreen: (docWidth: number, docHeight: number, screenWidth: number, screenHeight: number, padding?: number) => ViewportState;
|
|
15
|
+
export declare const imageDocRect: (imgW: number, imgH: number, docW: number, docH: number, fit?: BackgroundFit) => {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
export declare const fitRectToScreen: (rect: {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}, screenWidth: number, screenHeight: number, padding?: number) => ViewportState;
|
|
15
27
|
export declare const zoomAt: (state: ViewportState, focalScreen: Vec2, nextZoom: number) => ViewportState;
|
|
16
28
|
export declare const panBy: (state: ViewportState, deltaScreen: Vec2) => ViewportState;
|
|
@@ -29,6 +29,38 @@ export const fitToScreen = (docWidth, docHeight, screenWidth, screenHeight, padd
|
|
|
29
29
|
pan: { x: -offsetX / zoom, y: -offsetY / zoom },
|
|
30
30
|
};
|
|
31
31
|
};
|
|
32
|
+
// The doc-space rectangle a background image occupies, given the image's
|
|
33
|
+
// intrinsic pixel size, the document dimensions, and the fit mode. This mirrors
|
|
34
|
+
// the math in BackgroundImageElement (which renders the image into this rect),
|
|
35
|
+
// so "fit the image to the screen" lines up exactly with what is drawn.
|
|
36
|
+
export const imageDocRect = (imgW, imgH, docW, docH, fit = 'contain') => {
|
|
37
|
+
// Unknown/zero image dimensions (or an explicit stretch) fill the doc — the
|
|
38
|
+
// same guard BackgroundImageElement uses to avoid NaN geometry.
|
|
39
|
+
if (!(imgW > 0) || !(imgH > 0) || fit === 'stretch') {
|
|
40
|
+
return { x: 0, y: 0, width: docW, height: docH };
|
|
41
|
+
}
|
|
42
|
+
const scale = fit === 'cover'
|
|
43
|
+
? Math.max(docW / imgW, docH / imgH)
|
|
44
|
+
: Math.min(docW / imgW, docH / imgH);
|
|
45
|
+
const w = imgW * scale;
|
|
46
|
+
const h = imgH * scale;
|
|
47
|
+
return { x: (docW - w) / 2, y: (docH - h) / 2, width: w, height: h };
|
|
48
|
+
};
|
|
49
|
+
// Fit a doc-space rectangle into the screen: the largest zoom that keeps the
|
|
50
|
+
// whole rect visible, centered, with optional screen-space padding.
|
|
51
|
+
export const fitRectToScreen = (rect, screenWidth, screenHeight, padding = 0) => {
|
|
52
|
+
const availableW = Math.max(1, screenWidth - padding * 2);
|
|
53
|
+
const availableH = Math.max(1, screenHeight - padding * 2);
|
|
54
|
+
const zoom = Math.min(availableW / Math.max(1, rect.width), availableH / Math.max(1, rect.height));
|
|
55
|
+
const renderedW = rect.width * zoom;
|
|
56
|
+
const renderedH = rect.height * zoom;
|
|
57
|
+
const offsetX = (screenWidth - renderedW) / 2;
|
|
58
|
+
const offsetY = (screenHeight - renderedH) / 2;
|
|
59
|
+
return {
|
|
60
|
+
zoom,
|
|
61
|
+
pan: { x: rect.x - offsetX / zoom, y: rect.y - offsetY / zoom },
|
|
62
|
+
};
|
|
63
|
+
};
|
|
32
64
|
// Zoom toward a focal screen point so the world point under the cursor stays
|
|
33
65
|
// fixed. Used for wheel-zoom on web and pinch on native.
|
|
34
66
|
export const zoomAt = (state, focalScreen, nextZoom) => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.js';
|
|
3
|
+
export type { AnnotationCanvasHandle, GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
|
|
4
|
+
export interface CanvasKitOpts {
|
|
5
|
+
locateFile?: (file: string) => string;
|
|
6
|
+
}
|
|
7
|
+
export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
canvasKitOpts?: CanvasKitOpts;
|
|
10
|
+
};
|
|
11
|
+
export declare const AnnotationCanvas: ({ fallback, canvasKitOpts, ...rest }: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { WithSkiaWeb } from '@shopify/react-native-skia/lib/module/web/index.js';
|
|
3
|
+
// Web-only entry point: lazy-loads CanvasKit wasm via WithSkiaWeb. Imperative
|
|
4
|
+
// API (undo/redo/zoom) is exposed through the `imperativeRef` prop rather
|
|
5
|
+
// than React refs, since WithSkiaWeb can't forward refs through its lazy
|
|
6
|
+
// component boundary. The native build sits in `AnnotationCanvas.native.tsx`
|
|
7
|
+
// and skips WithSkiaWeb entirely (no wasm to load).
|
|
8
|
+
export const AnnotationCanvas = ({ fallback, canvasKitOpts, ...rest }) => (_jsx(WithSkiaWeb, { getComponent: () => import('./AnnotationCanvasInner.js').then((m) => ({
|
|
9
|
+
default: m.AnnotationCanvasInner,
|
|
10
|
+
})), fallback: fallback ?? null, componentProps: rest, opts: canvasKitOpts }));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.native.js';
|
|
2
|
+
export type { AnnotationCanvasHandle, } from './useAnnotationCanvasState.js';
|
|
3
|
+
export type { GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
|
|
4
|
+
export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
|
|
5
|
+
fallback?: unknown;
|
|
6
|
+
canvasKitOpts?: unknown;
|
|
7
|
+
};
|
|
8
|
+
export declare const AnnotationCanvas: (props: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { AnnotationCanvasInner } from './AnnotationCanvasInner.native.js';
|
|
3
|
+
export const AnnotationCanvas = (props) => {
|
|
4
|
+
const { fallback: _f, canvasKitOpts: _o, ...rest } = props;
|
|
5
|
+
return _jsx(AnnotationCanvasInner, { ...rest });
|
|
6
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type CSSProperties, type MutableRefObject } from 'react';
|
|
2
|
+
import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
|
|
3
|
+
import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
|
|
4
|
+
import type { MeasurementRef } from './measurementPicker.js';
|
|
5
|
+
import type { Tool } from './Tool.js';
|
|
6
|
+
import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
|
|
7
|
+
import { type ViewportState } from './viewport.js';
|
|
8
|
+
import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
|
|
9
|
+
export type { AnnotationCanvasHandle };
|
|
10
|
+
export interface AnnotationCanvasInnerProps {
|
|
11
|
+
canvas: AnnotationCanvasState;
|
|
12
|
+
onCommit(patch: AnnotationDocumentPatch): void;
|
|
13
|
+
tools: Tool[];
|
|
14
|
+
activeToolId: string;
|
|
15
|
+
selection: Selection | null;
|
|
16
|
+
onSelectionChange(selection: Selection | null): void;
|
|
17
|
+
measurements?: Measurement[];
|
|
18
|
+
fallbackUnit?: Units;
|
|
19
|
+
fractionalTolerance?: FractionalTolerance;
|
|
20
|
+
decimalTolerance?: DecimalTolerance;
|
|
21
|
+
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
22
|
+
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
23
|
+
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
24
|
+
stampFontSource?: unknown;
|
|
25
|
+
stampValueFontSize?: number;
|
|
26
|
+
stampLabelFontSize?: number;
|
|
27
|
+
gestures?: GestureConfig;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
initialViewport?: ViewportState;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
|
|
33
|
+
}
|
|
34
|
+
export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
|
|
35
|
+
export interface GestureConfig {
|
|
36
|
+
wheel?: 'zoom' | 'pan' | 'auto';
|
|
37
|
+
panTriggers?: PanTrigger[];
|
|
38
|
+
}
|
|
39
|
+
export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useFont } from '@shopify/react-native-skia';
|
|
3
|
+
import { useCallback, useEffect, useRef, } from 'react';
|
|
4
|
+
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
5
|
+
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
6
|
+
import { STAMP_TILE_SIZE } from './stampLayout.js';
|
|
7
|
+
const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
|
|
8
|
+
export const AnnotationCanvasInner = (props) => {
|
|
9
|
+
const { fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, stampFontSource, stampValueFontSize = 14, stampLabelFontSize = 11, gestures, width, height, style, activeToolId, tools, } = props;
|
|
10
|
+
const wheelMode = gestures?.wheel ?? 'auto';
|
|
11
|
+
const panTriggers = gestures?.panTriggers ?? DEFAULT_PAN_TRIGGERS;
|
|
12
|
+
const allowSpacePan = panTriggers.includes('space');
|
|
13
|
+
const allowMiddlePan = panTriggers.includes('middleMouse');
|
|
14
|
+
const allowRightPan = panTriggers.includes('rightMouse');
|
|
15
|
+
const valueFont = useFont(stampFontSource, stampValueFontSize);
|
|
16
|
+
const labelFont = useFont(stampFontSource, stampLabelFontSize);
|
|
17
|
+
const state = useAnnotationCanvasState(props);
|
|
18
|
+
const containerRef = useRef(null);
|
|
19
|
+
const panGestureRef = useRef(null);
|
|
20
|
+
const spaceDownRef = useRef(false);
|
|
21
|
+
const activeTool = tools.find((t) => t.id === activeToolId) ?? null;
|
|
22
|
+
const toCanvasPointer = useCallback((event) => {
|
|
23
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
24
|
+
const screen = {
|
|
25
|
+
x: event.clientX - (rect?.left ?? 0),
|
|
26
|
+
y: event.clientY - (rect?.top ?? 0),
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
pointerId: event.pointerId,
|
|
30
|
+
screen,
|
|
31
|
+
world: state.ctx.viewport.screenToWorld(screen),
|
|
32
|
+
pressure: event.pressure || undefined,
|
|
33
|
+
shiftKey: event.shiftKey,
|
|
34
|
+
altKey: event.altKey,
|
|
35
|
+
metaKey: event.metaKey,
|
|
36
|
+
ctrlKey: event.ctrlKey,
|
|
37
|
+
};
|
|
38
|
+
}, [state.ctx.viewport]);
|
|
39
|
+
const isPanTriggerDown = useCallback((event) => {
|
|
40
|
+
if (allowMiddlePan && event.button === 1)
|
|
41
|
+
return true;
|
|
42
|
+
if (allowRightPan && event.button === 2)
|
|
43
|
+
return true;
|
|
44
|
+
if (allowSpacePan && event.button === 0 && spaceDownRef.current)
|
|
45
|
+
return true;
|
|
46
|
+
return false;
|
|
47
|
+
}, [allowMiddlePan, allowRightPan, allowSpacePan]);
|
|
48
|
+
const handlePointerDown = useCallback((event) => {
|
|
49
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
50
|
+
const screen = {
|
|
51
|
+
x: event.clientX - (rect?.left ?? 0),
|
|
52
|
+
y: event.clientY - (rect?.top ?? 0),
|
|
53
|
+
};
|
|
54
|
+
if (isPanTriggerDown(event)) {
|
|
55
|
+
panGestureRef.current = { pointerId: event.pointerId, lastScreen: screen };
|
|
56
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
61
|
+
state.dispatchPointerDown(toCanvasPointer(event));
|
|
62
|
+
}, [state, toCanvasPointer, isPanTriggerDown]);
|
|
63
|
+
const handlePointerMove = useCallback((event) => {
|
|
64
|
+
const pan = panGestureRef.current;
|
|
65
|
+
if (pan && event.pointerId === pan.pointerId) {
|
|
66
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
67
|
+
const screen = {
|
|
68
|
+
x: event.clientX - (rect?.left ?? 0),
|
|
69
|
+
y: event.clientY - (rect?.top ?? 0),
|
|
70
|
+
};
|
|
71
|
+
state.pan({
|
|
72
|
+
x: screen.x - pan.lastScreen.x,
|
|
73
|
+
y: screen.y - pan.lastScreen.y,
|
|
74
|
+
});
|
|
75
|
+
panGestureRef.current = { pointerId: pan.pointerId, lastScreen: screen };
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
state.dispatchPointerMove(toCanvasPointer(event));
|
|
79
|
+
}, [state, toCanvasPointer]);
|
|
80
|
+
const handlePointerUp = useCallback((event) => {
|
|
81
|
+
const pan = panGestureRef.current;
|
|
82
|
+
if (pan && event.pointerId === pan.pointerId) {
|
|
83
|
+
panGestureRef.current = null;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
state.dispatchPointerUp(toCanvasPointer(event));
|
|
87
|
+
}, [state, toCanvasPointer]);
|
|
88
|
+
const handlePointerCancel = useCallback(() => {
|
|
89
|
+
panGestureRef.current = null;
|
|
90
|
+
state.dispatchPointerCancel();
|
|
91
|
+
}, [state]);
|
|
92
|
+
const handleWheel = useCallback((event) => {
|
|
93
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
94
|
+
const focal = {
|
|
95
|
+
x: event.clientX - (rect?.left ?? 0),
|
|
96
|
+
y: event.clientY - (rect?.top ?? 0),
|
|
97
|
+
};
|
|
98
|
+
if (event.ctrlKey || event.metaKey) {
|
|
99
|
+
const factor = Math.exp(-event.deltaY / 200);
|
|
100
|
+
state.zoom(focal, state.ctx.viewport.state.zoom * factor);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (wheelMode === 'zoom') {
|
|
104
|
+
const factor = Math.exp(-event.deltaY / 300);
|
|
105
|
+
state.zoom(focal, state.ctx.viewport.state.zoom * factor);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (event.shiftKey) {
|
|
109
|
+
state.pan({ x: event.deltaY, y: event.deltaX });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
state.pan({ x: event.deltaX, y: event.deltaY });
|
|
113
|
+
}, [state, wheelMode]);
|
|
114
|
+
const handleContextMenu = useCallback((event) => {
|
|
115
|
+
if (allowRightPan)
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
}, [allowRightPan]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const el = containerRef.current;
|
|
120
|
+
if (!el)
|
|
121
|
+
return;
|
|
122
|
+
const stop = (e) => {
|
|
123
|
+
if (e.target === el || el.contains(e.target)) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
el.addEventListener('wheel', stop, { passive: false });
|
|
128
|
+
return () => el.removeEventListener('wheel', stop);
|
|
129
|
+
}, []);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!allowSpacePan)
|
|
132
|
+
return;
|
|
133
|
+
const onKeyDown = (e) => {
|
|
134
|
+
if (e.code === 'Space' && !e.repeat) {
|
|
135
|
+
spaceDownRef.current = true;
|
|
136
|
+
if (containerRef.current)
|
|
137
|
+
containerRef.current.style.cursor = 'grab';
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const onKeyUp = (e) => {
|
|
141
|
+
if (e.code === 'Space') {
|
|
142
|
+
spaceDownRef.current = false;
|
|
143
|
+
if (containerRef.current)
|
|
144
|
+
containerRef.current.style.cursor = '';
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
window.addEventListener('keydown', onKeyDown);
|
|
148
|
+
window.addEventListener('keyup', onKeyUp);
|
|
149
|
+
return () => {
|
|
150
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
151
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
152
|
+
};
|
|
153
|
+
}, [allowSpacePan]);
|
|
154
|
+
const containerStyle = {
|
|
155
|
+
position: 'relative',
|
|
156
|
+
width,
|
|
157
|
+
height,
|
|
158
|
+
overflow: 'hidden',
|
|
159
|
+
touchAction: 'none',
|
|
160
|
+
userSelect: 'none',
|
|
161
|
+
cursor: activeTool?.cursor ?? 'default',
|
|
162
|
+
...style,
|
|
163
|
+
};
|
|
164
|
+
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
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
|
+
}) }))] }));
|
|
219
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type MutableRefObject } from 'react';
|
|
2
|
+
import { type ViewStyle } from 'react-native';
|
|
3
|
+
import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
|
|
4
|
+
import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
|
|
5
|
+
import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
|
|
6
|
+
import type { MeasurementRef } from './measurementPicker.js';
|
|
7
|
+
import type { Tool } from './Tool.js';
|
|
8
|
+
import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
|
|
9
|
+
import type { ViewportState } from './viewport.js';
|
|
10
|
+
export type { AnnotationCanvasHandle };
|
|
11
|
+
export interface AnnotationCanvasInnerProps {
|
|
12
|
+
canvas: AnnotationCanvasState;
|
|
13
|
+
onCommit(patch: AnnotationDocumentPatch): void;
|
|
14
|
+
tools: Tool[];
|
|
15
|
+
activeToolId: string;
|
|
16
|
+
selection: Selection | null;
|
|
17
|
+
onSelectionChange(selection: Selection | null): void;
|
|
18
|
+
measurements?: Measurement[];
|
|
19
|
+
fallbackUnit?: Units;
|
|
20
|
+
fractionalTolerance?: FractionalTolerance;
|
|
21
|
+
decimalTolerance?: DecimalTolerance;
|
|
22
|
+
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
23
|
+
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
24
|
+
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
25
|
+
stampFontSource?: unknown;
|
|
26
|
+
stampValueFontSize?: number;
|
|
27
|
+
stampLabelFontSize?: number;
|
|
28
|
+
gestures?: unknown;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
initialViewport?: ViewportState;
|
|
32
|
+
style?: ViewStyle;
|
|
33
|
+
imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
|
|
34
|
+
}
|
|
35
|
+
export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useFont } from '@shopify/react-native-skia';
|
|
3
|
+
import { useMemo, useRef } from 'react';
|
|
4
|
+
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
5
|
+
import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
|
|
6
|
+
import { STAMP_TILE_SIZE } from './stampLayout.js';
|
|
7
|
+
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
8
|
+
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
9
|
+
// Native fingerprint: one finger drives the active tool, two fingers
|
|
10
|
+
// pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
|
|
11
|
+
// like measurement-stamp (which only listen to onPointerUp) work via tap.
|
|
12
|
+
export const AnnotationCanvasInner = (props) => {
|
|
13
|
+
const { fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, stampFontSource, stampValueFontSize = 14, stampLabelFontSize = 11, width, height, style, } = props;
|
|
14
|
+
const valueFont = useFont(stampFontSource, stampValueFontSize);
|
|
15
|
+
const labelFont = useFont(stampFontSource, stampLabelFontSize);
|
|
16
|
+
const state = useAnnotationCanvasState(props);
|
|
17
|
+
// Per-gesture refs so we always emit a matching down/move/up sequence.
|
|
18
|
+
const pointerIdRef = useRef(1);
|
|
19
|
+
const inFlightRef = useRef(null);
|
|
20
|
+
const pinchStartZoomRef = useRef(1);
|
|
21
|
+
const buildEvent = (pointerId, screen) => ({
|
|
22
|
+
pointerId,
|
|
23
|
+
screen,
|
|
24
|
+
world: state.ctx.viewport.screenToWorld(screen),
|
|
25
|
+
});
|
|
26
|
+
const gesture = useMemo(() => {
|
|
27
|
+
const toolPan = Gesture.Pan()
|
|
28
|
+
.minPointers(1)
|
|
29
|
+
.maxPointers(1)
|
|
30
|
+
.runOnJS(true)
|
|
31
|
+
.onBegin((e) => {
|
|
32
|
+
const id = pointerIdRef.current++;
|
|
33
|
+
const screen = { x: e.x, y: e.y };
|
|
34
|
+
inFlightRef.current = { id, lastScreen: screen };
|
|
35
|
+
state.dispatchPointerDown(buildEvent(id, screen));
|
|
36
|
+
})
|
|
37
|
+
.onUpdate((e) => {
|
|
38
|
+
const f = inFlightRef.current;
|
|
39
|
+
if (!f)
|
|
40
|
+
return;
|
|
41
|
+
const screen = { x: e.x, y: e.y };
|
|
42
|
+
f.lastScreen = screen;
|
|
43
|
+
state.dispatchPointerMove(buildEvent(f.id, screen));
|
|
44
|
+
})
|
|
45
|
+
.onEnd((e) => {
|
|
46
|
+
const f = inFlightRef.current;
|
|
47
|
+
if (!f)
|
|
48
|
+
return;
|
|
49
|
+
state.dispatchPointerUp(buildEvent(f.id, { x: e.x, y: e.y }));
|
|
50
|
+
inFlightRef.current = null;
|
|
51
|
+
})
|
|
52
|
+
.onFinalize(() => {
|
|
53
|
+
if (inFlightRef.current) {
|
|
54
|
+
state.dispatchPointerCancel();
|
|
55
|
+
inFlightRef.current = null;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const tap = Gesture.Tap()
|
|
59
|
+
.maxDuration(250)
|
|
60
|
+
.runOnJS(true)
|
|
61
|
+
.onEnd((e) => {
|
|
62
|
+
const id = pointerIdRef.current++;
|
|
63
|
+
const screen = { x: e.x, y: e.y };
|
|
64
|
+
// Synthesize a down+up sequence so tools that only listen to
|
|
65
|
+
// onPointerUp (e.g. measurement stamp) still fire.
|
|
66
|
+
state.dispatchPointerDown(buildEvent(id, screen));
|
|
67
|
+
state.dispatchPointerUp(buildEvent(id, screen));
|
|
68
|
+
});
|
|
69
|
+
const viewportPan = Gesture.Pan()
|
|
70
|
+
.minPointers(2)
|
|
71
|
+
.maxPointers(2)
|
|
72
|
+
.runOnJS(true)
|
|
73
|
+
.onChange((e) => {
|
|
74
|
+
state.pan({ x: e.changeX, y: e.changeY });
|
|
75
|
+
});
|
|
76
|
+
const pinch = Gesture.Pinch()
|
|
77
|
+
.runOnJS(true)
|
|
78
|
+
.onBegin(() => {
|
|
79
|
+
pinchStartZoomRef.current = state.ctx.viewport.state.zoom;
|
|
80
|
+
})
|
|
81
|
+
.onUpdate((e) => {
|
|
82
|
+
state.zoom({ x: e.focalX, y: e.focalY }, pinchStartZoomRef.current * e.scale);
|
|
83
|
+
});
|
|
84
|
+
return Gesture.Race(tap, Gesture.Simultaneous(viewportPan, pinch), toolPan);
|
|
85
|
+
}, [state]);
|
|
86
|
+
const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
|
|
87
|
+
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
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
|
+
}) }))] }));
|
|
138
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type SkFont } from '@shopify/react-native-skia';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { AnnotationCanvasState, AnnotationStroke } from '../types/annotation.js';
|
|
4
|
+
import { DecimalTolerance, FractionalTolerance, Units, type Measurement } from '../types/firestore.js';
|
|
5
|
+
export interface AnnotationCanvasSkiaProps {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
effectiveCanvas: AnnotationCanvasState;
|
|
9
|
+
worldTransform: Array<{
|
|
10
|
+
scale: number;
|
|
11
|
+
} | {
|
|
12
|
+
translateX: number;
|
|
13
|
+
} | {
|
|
14
|
+
translateY: number;
|
|
15
|
+
}>;
|
|
16
|
+
measurementsById: Map<string, Measurement>;
|
|
17
|
+
fallbackUnit?: Units;
|
|
18
|
+
fractionalTolerance?: FractionalTolerance;
|
|
19
|
+
decimalTolerance?: DecimalTolerance;
|
|
20
|
+
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
21
|
+
valueFont: SkFont | null;
|
|
22
|
+
labelFont: SkFont | null;
|
|
23
|
+
hideMeasurementStamps?: boolean;
|
|
24
|
+
penDrawingStroke: AnnotationStroke | null;
|
|
25
|
+
customPreview?: ReactNode;
|
|
26
|
+
}
|
|
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;
|