@reekon-tools/boldr-utils 1.6.11 → 1.6.12
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 → annotation/canvas}/AnnotationCanvasInner.d.ts +2 -2
- package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.js +19 -14
- package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.native.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +668 -0
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +46 -0
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +129 -0
- package/dist/{canvas → annotation/canvas}/Tool.d.ts +23 -1
- package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.d.ts +2 -2
- package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.js +13 -6
- package/dist/annotation/canvas/elements/ShapeElement.d.ts +7 -0
- package/dist/{canvas → annotation/canvas}/elements/ShapeElement.js +5 -3
- package/dist/annotation/canvas/elements/StrokeElement.d.ts +7 -0
- package/dist/annotation/canvas/elements/StrokeElement.js +40 -0
- package/dist/annotation/canvas/measurementGeometry.d.ts +23 -0
- package/dist/annotation/canvas/measurementGeometry.js +74 -0
- package/dist/{canvas → annotation/canvas}/measurementPicker.d.ts +1 -1
- package/dist/{canvas → annotation/canvas}/measurementStampOverlay.d.ts +2 -2
- package/dist/annotation/canvas/stampLayout.d.ts +1 -0
- package/dist/annotation/canvas/stampLayout.js +11 -0
- package/dist/annotation/canvas/strokeGeometry.d.ts +4 -0
- package/dist/annotation/canvas/strokeGeometry.js +33 -0
- package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.d.ts +1 -1
- package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.js +1 -1
- package/dist/{canvas → annotation/canvas}/tools/panTool.js +3 -0
- package/dist/{canvas → annotation/canvas}/tools/penTool.d.ts +2 -1
- package/dist/{canvas → annotation/canvas}/tools/penTool.js +32 -5
- package/dist/annotation/canvas/tools/selectTool.js +310 -0
- package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.d.ts +9 -2
- package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.js +115 -1
- package/dist/{canvas → annotation/canvas}/viewport.d.ts +1 -1
- package/dist/{data → annotation/data}/AnnotationDataProvider.d.ts +1 -1
- package/dist/{data → annotation/data}/InMemoryAnnotationProvider.d.ts +1 -1
- package/dist/{data → annotation/data}/InMemoryAnnotationProvider.js +1 -1
- package/dist/{data → annotation/data}/canvasPersistence.d.ts +1 -1
- package/dist/{data → annotation/data}/canvasPersistence.js +1 -1
- package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.d.ts +1 -1
- package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.js +2 -2
- package/dist/exports.d.ts +21 -19
- package/dist/exports.js +16 -14
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.native.d.ts +1 -1
- package/dist/index.native.js +1 -1
- package/dist/types/annotation.d.ts +15 -3
- package/dist/types/firestore.d.ts +0 -1
- package/dist/{hooks → utils}/useParseMeasurement.js +1 -1
- package/package.json +1 -1
- package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
- package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
- package/dist/canvas/AnnotationCanvasSkia.js +0 -20
- package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
- package/dist/canvas/elements/MeasurementStampElement.js +0 -30
- package/dist/canvas/elements/ShapeElement.d.ts +0 -7
- package/dist/canvas/elements/StrokeElement.d.ts +0 -7
- package/dist/canvas/elements/StrokeElement.js +0 -18
- package/dist/canvas/stampLayout.d.ts +0 -5
- package/dist/canvas/stampLayout.js +0 -14
- package/dist/canvas/tools/selectTool.js +0 -182
- package/dist/utils/evaluateFormula.d.ts +0 -20
- package/dist/utils/evaluateFormula.js +0 -31
- /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.d.ts +0 -0
- /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.js +0 -0
- /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.d.ts +0 -0
- /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.js +0 -0
- /package/dist/{canvas → annotation/canvas}/Tool.js +0 -0
- /package/dist/{canvas → annotation/canvas}/measurementPicker.js +0 -0
- /package/dist/{canvas → annotation/canvas}/measurementStampOverlay.js +0 -0
- /package/dist/{canvas → annotation/canvas}/pointerAdapter.d.ts +0 -0
- /package/dist/{canvas → annotation/canvas}/pointerAdapter.js +0 -0
- /package/dist/{canvas → annotation/canvas}/tools/panTool.d.ts +0 -0
- /package/dist/{canvas → annotation/canvas}/tools/selectTool.d.ts +0 -0
- /package/dist/{canvas → annotation/canvas}/viewport.js +0 -0
- /package/dist/{data → annotation/data}/AnnotationDataContext.d.ts +0 -0
- /package/dist/{data → annotation/data}/AnnotationDataContext.js +0 -0
- /package/dist/{data → annotation/data}/AnnotationDataProvider.js +0 -0
- /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.d.ts +0 -0
- /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.js +0 -0
- /package/dist/{data → annotation/data}/hooks/useAnnotationList.d.ts +0 -0
- /package/dist/{data → annotation/data}/hooks/useAnnotationList.js +0 -0
- /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.d.ts +0 -0
- /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.js +0 -0
- /package/dist/{hooks → utils}/useParseMeasurement.d.ts +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type SkFont, type SkPath, type Transforms3d } from '@shopify/react-native-skia';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
|
|
4
|
+
type AnimatedPoint = {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
} | {
|
|
8
|
+
value: {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export interface AnnotationCanvasSkiaProps {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
effectiveCanvas: AnnotationCanvasState;
|
|
17
|
+
worldTransform: Transforms3d | {
|
|
18
|
+
value: Transforms3d;
|
|
19
|
+
};
|
|
20
|
+
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
21
|
+
valueFont: SkFont | null;
|
|
22
|
+
penDrawingStroke: AnnotationStroke | null;
|
|
23
|
+
livePreview?: {
|
|
24
|
+
path: SkPath | {
|
|
25
|
+
value: SkPath;
|
|
26
|
+
};
|
|
27
|
+
color: string;
|
|
28
|
+
width: number;
|
|
29
|
+
cap?: StrokeCap;
|
|
30
|
+
opacity: number;
|
|
31
|
+
} | null;
|
|
32
|
+
draggingId?: string | null;
|
|
33
|
+
dragTransform?: Transforms3d | {
|
|
34
|
+
value: Transforms3d;
|
|
35
|
+
};
|
|
36
|
+
selectedId?: string | null;
|
|
37
|
+
endpointDragId?: string | null;
|
|
38
|
+
liveLineP1?: AnimatedPoint;
|
|
39
|
+
liveLineP2?: AnimatedPoint;
|
|
40
|
+
handleRadius?: number | {
|
|
41
|
+
value: number;
|
|
42
|
+
};
|
|
43
|
+
customPreview?: ReactNode;
|
|
44
|
+
}
|
|
45
|
+
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Canvas, Circle, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
|
|
3
|
+
import { placementOf } from './measurementGeometry.js';
|
|
4
|
+
import { arrowheadTriangle, toSkiaStrokeCap } from './strokeGeometry.js';
|
|
5
|
+
import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
|
|
6
|
+
import { ShapeElement } from './elements/ShapeElement.js';
|
|
7
|
+
import { StrokeElement } from './elements/StrokeElement.js';
|
|
8
|
+
// Default visual constants for the measurement-annotation line (the tile itself
|
|
9
|
+
// is a fixed-size overlay; only the line lives in Skia). Width is in doc units,
|
|
10
|
+
// so it scales with zoom like the drawn line/arrow shapes. These are fallbacks:
|
|
11
|
+
// a placed annotation may override them via `lineColor`/`lineWidth`/`lineCap`.
|
|
12
|
+
const MEASUREMENT_LINE_COLOR = '#0066FF';
|
|
13
|
+
const MEASUREMENT_LINE_WIDTH = 2;
|
|
14
|
+
// Endpoint handle color — constant selection chrome, independent of the (now
|
|
15
|
+
// editable) line color so the handles stay legible on any line color.
|
|
16
|
+
const MEASUREMENT_HANDLE_COLOR = '#0066FF';
|
|
17
|
+
// Selection bounding box for strokes/shapes (measurements get handles instead).
|
|
18
|
+
// Padding + stroke width are in doc units (scale with zoom) — simple and clear;
|
|
19
|
+
// the box is only a "this is selected" affordance, not a precise gizmo.
|
|
20
|
+
const SELECTION_COLOR = '#0066FF';
|
|
21
|
+
const SELECTION_PAD = 6;
|
|
22
|
+
const SELECTION_STROKE = 1.5;
|
|
23
|
+
// Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
|
|
24
|
+
const strokeBounds = (points) => {
|
|
25
|
+
if (points.length < 2)
|
|
26
|
+
return null;
|
|
27
|
+
let minX = points[0];
|
|
28
|
+
let maxX = points[0];
|
|
29
|
+
let minY = points[1];
|
|
30
|
+
let maxY = points[1];
|
|
31
|
+
for (let i = 2; i < points.length; i += 2) {
|
|
32
|
+
const x = points[i];
|
|
33
|
+
const y = points[i + 1];
|
|
34
|
+
if (x < minX)
|
|
35
|
+
minX = x;
|
|
36
|
+
if (x > maxX)
|
|
37
|
+
maxX = x;
|
|
38
|
+
if (y < minY)
|
|
39
|
+
minY = y;
|
|
40
|
+
if (y > maxY)
|
|
41
|
+
maxY = y;
|
|
42
|
+
}
|
|
43
|
+
return { minX, minY, maxX, maxY };
|
|
44
|
+
};
|
|
45
|
+
// Bounds of a Vec2[] (shape geometry), or null if empty.
|
|
46
|
+
const pointsBounds = (pts) => {
|
|
47
|
+
if (pts.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
let { x: minX, y: minY } = pts[0];
|
|
50
|
+
let { x: maxX, y: maxY } = pts[0];
|
|
51
|
+
for (let i = 1; i < pts.length; i++) {
|
|
52
|
+
const p = pts[i];
|
|
53
|
+
if (p.x < minX)
|
|
54
|
+
minX = p.x;
|
|
55
|
+
if (p.x > maxX)
|
|
56
|
+
maxX = p.x;
|
|
57
|
+
if (p.y < minY)
|
|
58
|
+
minY = p.y;
|
|
59
|
+
if (p.y > maxY)
|
|
60
|
+
maxY = p.y;
|
|
61
|
+
}
|
|
62
|
+
return { minX, minY, maxX, maxY };
|
|
63
|
+
};
|
|
64
|
+
// Wraps a single element in the live drag transform while it's being dragged
|
|
65
|
+
// (native, UI-thread). When not dragging it renders the element untouched, so
|
|
66
|
+
// the element's React.memo still bails out on unrelated re-renders.
|
|
67
|
+
const DraggableElement = ({ isDragging, transform, children, }) => isDragging && transform ? (_jsx(Group, { transform: transform, children: children })) : (_jsx(_Fragment, { children: children }));
|
|
68
|
+
// Bounding-box outline around a selected stroke/shape. Padded in doc space and
|
|
69
|
+
// wrapped in DraggableElement so it tracks the element during a group drag.
|
|
70
|
+
const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElement, { isDragging: isDragging, transform: transform, children: _jsx(Rect, { x: bounds.minX - SELECTION_PAD, y: bounds.minY - SELECTION_PAD, width: bounds.maxX - bounds.minX + SELECTION_PAD * 2, height: bounds.maxY - bounds.minY + SELECTION_PAD * 2, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }) }));
|
|
71
|
+
// Platform-agnostic Skia subtree shared by web and native Inners.
|
|
72
|
+
//
|
|
73
|
+
// Placed measurements are NOT drawn here — they render as a non-interactive
|
|
74
|
+
// RN/DOM overlay of real tile components (`renderMeasurementStamp`) positioned
|
|
75
|
+
// over this canvas by the Inner. This subtree owns only the vector content
|
|
76
|
+
// (background, strokes, shapes) and the live tool previews.
|
|
77
|
+
//
|
|
78
|
+
// Call this as a FUNCTION (`AnnotationCanvasSkia({ ... })`), not as a JSX
|
|
79
|
+
// component (`<AnnotationCanvasSkia ... />`). Used as a component, it adds
|
|
80
|
+
// a React component boundary between the parent and Skia's `<Canvas>` that
|
|
81
|
+
// breaks Skia's reconciler when the page first calls `useFont` — the JS
|
|
82
|
+
// thread hangs in `MakeFreeTypeFaceFromData`. Symptoms only appeared on
|
|
83
|
+
// Vite's dev server with a symlinked boldr-utils + React Refresh, but
|
|
84
|
+
// since the function-call pattern works identically on native we use it
|
|
85
|
+
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
86
|
+
// JSX-returning helper, not a component.
|
|
87
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
|
|
88
|
+
if (placementOf(placed) !== 'line' || !placed.line)
|
|
89
|
+
return null;
|
|
90
|
+
const isEndpointDrag = placed.id === endpointDragId;
|
|
91
|
+
const isSelected = placed.id === selectedId;
|
|
92
|
+
const p1 = isEndpointDrag && liveLineP1 ? liveLineP1 : placed.line.a;
|
|
93
|
+
const p2 = isEndpointDrag && liveLineP2 ? liveLineP2 : placed.line.b;
|
|
94
|
+
const lineColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
|
|
95
|
+
const lineWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
|
|
96
|
+
// Solid filled-triangle arrowhead at endpoint b (the line's "end").
|
|
97
|
+
// Built from the static endpoints, so it's skipped during a live
|
|
98
|
+
// endpoint drag (it reappears on release) rather than lagging the
|
|
99
|
+
// moving finger.
|
|
100
|
+
const arrowPath = (() => {
|
|
101
|
+
if (placed.lineCap !== 'arrow' || isEndpointDrag || !placed.line) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const [apex, baseL, baseR] = arrowheadTriangle(placed.line.b, placed.line.a, lineWidth);
|
|
105
|
+
const p = Skia.Path.Make();
|
|
106
|
+
p.moveTo(apex.x, apex.y);
|
|
107
|
+
p.lineTo(baseL.x, baseL.y);
|
|
108
|
+
p.lineTo(baseR.x, baseR.y);
|
|
109
|
+
p.close();
|
|
110
|
+
return p;
|
|
111
|
+
})();
|
|
112
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
|
|
113
|
+
return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
|
|
114
|
+
}), (() => {
|
|
115
|
+
if (!selectedId)
|
|
116
|
+
return null;
|
|
117
|
+
const isDragging = selectedId === draggingId;
|
|
118
|
+
const stroke = effectiveCanvas.strokes.find((s) => s.id === selectedId);
|
|
119
|
+
if (stroke) {
|
|
120
|
+
const b = strokeBounds(stroke.points);
|
|
121
|
+
return b ? (_jsx(SelectionBox, { bounds: b, isDragging: isDragging, transform: dragTransform })) : null;
|
|
122
|
+
}
|
|
123
|
+
const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
|
|
124
|
+
if (shape) {
|
|
125
|
+
const b = pointsBounds(shape.geometry.points);
|
|
126
|
+
return b ? (_jsx(SelectionBox, { bounds: b, isDragging: isDragging, transform: dragTransform })) : null;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
})(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity })), customPreview] }) }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode, ComponentType } from 'react';
|
|
2
|
-
import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, Selection, Vec2 } from '
|
|
2
|
+
import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, AnnotationElementId, Selection, StrokeCap, Vec2 } from '../../types/annotation.js';
|
|
3
3
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
4
|
import type { ViewportApi } from './viewport.js';
|
|
5
5
|
export interface CanvasPointerEvent {
|
|
@@ -24,11 +24,33 @@ export interface ToolContext {
|
|
|
24
24
|
applyZoom(focalScreen: Vec2, nextZoom: number): void;
|
|
25
25
|
}
|
|
26
26
|
export type ToolState = unknown;
|
|
27
|
+
export interface FreehandConfig {
|
|
28
|
+
variant: 'pen' | 'marker' | 'highlighter';
|
|
29
|
+
color: string;
|
|
30
|
+
width: number;
|
|
31
|
+
cap?: StrokeCap;
|
|
32
|
+
minSampleDistance: number;
|
|
33
|
+
}
|
|
34
|
+
export type DragElementKind = 'stroke' | 'shape' | 'measurement';
|
|
35
|
+
export interface DragSelectionConfig {
|
|
36
|
+
hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
|
|
37
|
+
id: AnnotationElementId;
|
|
38
|
+
kind: DragElementKind;
|
|
39
|
+
} | null;
|
|
40
|
+
buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
|
|
41
|
+
classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'slide' | 'move';
|
|
42
|
+
buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
|
|
43
|
+
hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
|
|
44
|
+
buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
|
|
45
|
+
}
|
|
27
46
|
export interface Tool {
|
|
28
47
|
id: string;
|
|
29
48
|
label: string;
|
|
30
49
|
icon?: ComponentType;
|
|
31
50
|
cursor?: string;
|
|
51
|
+
freehand?: FreehandConfig;
|
|
52
|
+
panViewport?: boolean;
|
|
53
|
+
dragSelection?: DragSelectionConfig;
|
|
32
54
|
onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
33
55
|
onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
34
56
|
onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnnotationBackgroundImage, BackgroundFit } from '
|
|
1
|
+
import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
|
|
2
2
|
export interface BackgroundImageElementProps {
|
|
3
3
|
image: AnnotationBackgroundImage;
|
|
4
4
|
docWidth: number;
|
|
@@ -6,4 +6,4 @@ export interface BackgroundImageElementProps {
|
|
|
6
6
|
fit?: BackgroundFit;
|
|
7
7
|
resolveUrl?: (path: string) => Promise<string>;
|
|
8
8
|
}
|
|
9
|
-
export declare const BackgroundImageElement: ({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null
|
|
9
|
+
export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Image, useImage } from '@shopify/react-native-skia';
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
3
|
+
import { memo, useEffect, useRef, useState } from 'react';
|
|
4
4
|
const computeFit = (imgW, imgH, docW, docH, fit) => {
|
|
5
5
|
if (fit === 'stretch') {
|
|
6
6
|
return { x: 0, y: 0, width: docW, height: docH };
|
|
@@ -12,12 +12,19 @@ const computeFit = (imgW, imgH, docW, docH, fit) => {
|
|
|
12
12
|
const h = imgH * scale;
|
|
13
13
|
return { x: (docW - w) / 2, y: (docH - h) / 2, width: w, height: h };
|
|
14
14
|
};
|
|
15
|
-
export const BackgroundImageElement = ({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
|
|
15
|
+
export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
|
|
16
16
|
const [url, setUrl] = useState(image.downloadUrl);
|
|
17
|
+
// Hold resolveUrl in a ref so re-resolution is keyed only on the image
|
|
18
|
+
// identity, not the function's. Consumers commonly pass an inline closure
|
|
19
|
+
// (a new reference each render); depending on it directly would re-run a
|
|
20
|
+
// network URL resolve on every render.
|
|
21
|
+
const resolveUrlRef = useRef(resolveUrl);
|
|
22
|
+
resolveUrlRef.current = resolveUrl;
|
|
17
23
|
useEffect(() => {
|
|
18
24
|
let cancelled = false;
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
const resolve = resolveUrlRef.current;
|
|
26
|
+
if (resolve) {
|
|
27
|
+
resolve(image.storagePath).then((next) => {
|
|
21
28
|
if (!cancelled)
|
|
22
29
|
setUrl(next);
|
|
23
30
|
});
|
|
@@ -28,10 +35,10 @@ export const BackgroundImageElement = ({ image, docWidth, docHeight, fit = 'cont
|
|
|
28
35
|
return () => {
|
|
29
36
|
cancelled = true;
|
|
30
37
|
};
|
|
31
|
-
}, [image.downloadUrl, image.storagePath
|
|
38
|
+
}, [image.downloadUrl, image.storagePath]);
|
|
32
39
|
const skImage = useImage(url);
|
|
33
40
|
if (!skImage)
|
|
34
41
|
return null;
|
|
35
42
|
const dims = computeFit(image.widthPx, image.heightPx, docWidth, docHeight, fit);
|
|
36
43
|
return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
|
|
37
|
-
};
|
|
44
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SkFont } from '@shopify/react-native-skia';
|
|
2
|
+
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
3
|
+
export interface ShapeElementProps {
|
|
4
|
+
shape: AnnotationShape;
|
|
5
|
+
font?: SkFont | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const ShapeElement: import("react").MemoExoticComponent<({ shape, font }: ShapeElementProps) => import("react/jsx-runtime").JSX.Element | null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Circle, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
|
|
3
|
-
import { useMemo } from 'react';
|
|
3
|
+
import { memo, useMemo } from 'react';
|
|
4
4
|
const polygonPath = (points) => {
|
|
5
5
|
const path = Skia.Path.Make();
|
|
6
6
|
if (points.length === 0)
|
|
@@ -12,7 +12,9 @@ const polygonPath = (points) => {
|
|
|
12
12
|
path.close();
|
|
13
13
|
return path;
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
// Memoized — see StrokeElement. Unchanged shapes keep their identity across
|
|
16
|
+
// applyPatch, so only the edited shape (and the shared font) re-render.
|
|
17
|
+
export const ShapeElement = memo(({ shape, font }) => {
|
|
16
18
|
const { kind, geometry, style, text } = shape;
|
|
17
19
|
const stroke = style.stroke ?? '#000000';
|
|
18
20
|
const fill = style.fill;
|
|
@@ -59,4 +61,4 @@ export const ShapeElement = ({ shape, font }) => {
|
|
|
59
61
|
return (_jsx(Text, { x: origin.x, y: origin.y, text: text, font: font, color: stroke }));
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
|
-
};
|
|
64
|
+
});
|
|
@@ -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: import("react").MemoExoticComponent<({ stroke }: StrokeElementProps) => import("react/jsx-runtime").JSX.Element>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Path, Skia } from '@shopify/react-native-skia';
|
|
3
|
+
import { memo, useMemo } from 'react';
|
|
4
|
+
import { arrowheadTriangle, toSkiaStrokeCap } from '../strokeGeometry.js';
|
|
5
|
+
export const pointsToSkPath = (points) => {
|
|
6
|
+
const path = Skia.Path.Make();
|
|
7
|
+
if (points.length < 2)
|
|
8
|
+
return path;
|
|
9
|
+
path.moveTo(points[0], points[1]);
|
|
10
|
+
for (let i = 2; i < points.length; i += 2) {
|
|
11
|
+
path.lineTo(points[i], points[i + 1]);
|
|
12
|
+
}
|
|
13
|
+
return path;
|
|
14
|
+
};
|
|
15
|
+
// A solid filled-triangle arrowhead at the stroke's end (vertex at the last
|
|
16
|
+
// point, base corners back along the final segment), or null when the cap isn't
|
|
17
|
+
// 'arrow'. Rendered as a separate filled <Path> on top of the stroked line.
|
|
18
|
+
const arrowheadPath = (stroke) => {
|
|
19
|
+
const n = stroke.points.length;
|
|
20
|
+
if (stroke.cap !== 'arrow' || n < 4)
|
|
21
|
+
return null;
|
|
22
|
+
const end = { x: stroke.points[n - 2], y: stroke.points[n - 1] };
|
|
23
|
+
const from = { x: stroke.points[n - 4], y: stroke.points[n - 3] };
|
|
24
|
+
const [apex, baseL, baseR] = arrowheadTriangle(end, from, stroke.width);
|
|
25
|
+
const path = Skia.Path.Make();
|
|
26
|
+
path.moveTo(apex.x, apex.y);
|
|
27
|
+
path.lineTo(baseL.x, baseL.y);
|
|
28
|
+
path.lineTo(baseR.x, baseR.y);
|
|
29
|
+
path.close();
|
|
30
|
+
return path;
|
|
31
|
+
};
|
|
32
|
+
// Memoized: the canvas re-renders the whole element list on every commit,
|
|
33
|
+
// preview, and selection change, but applyPatch preserves the identity of
|
|
34
|
+
// unchanged strokes — so memo lets all but the changed stroke bail out.
|
|
35
|
+
export const StrokeElement = memo(({ stroke }) => {
|
|
36
|
+
const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
|
|
37
|
+
const arrow = useMemo(() => arrowheadPath(stroke), [stroke.points, stroke.cap, stroke.width]);
|
|
38
|
+
const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
|
|
39
|
+
return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity }), arrow && (_jsx(Path, { path: arrow, color: stroke.color, style: "fill", opacity: opacity }))] }));
|
|
40
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AnnotationPatchOp, MeasurementPlacement, PlacedMeasurementRef, Vec2 } from '../../types/annotation.js';
|
|
2
|
+
export declare const DEFAULT_LINE_POS = 0.5;
|
|
3
|
+
export declare const lerp: (a: Vec2, b: Vec2, t: number) => Vec2;
|
|
4
|
+
export declare const placementOf: (m: Pick<PlacedMeasurementRef, "placement">) => MeasurementPlacement;
|
|
5
|
+
export declare const linePosOf: (m: Pick<PlacedMeasurementRef, "linePos">) => number;
|
|
6
|
+
export declare const recomputeAnchor: (line: {
|
|
7
|
+
a: Vec2;
|
|
8
|
+
b: Vec2;
|
|
9
|
+
} | undefined, placement: MeasurementPlacement, linePos: number, fallbackAnchor: Vec2) => Vec2;
|
|
10
|
+
export declare const projectToLinePos: (line: {
|
|
11
|
+
a: Vec2;
|
|
12
|
+
b: Vec2;
|
|
13
|
+
}, world: Vec2) => number;
|
|
14
|
+
export declare const snapLinePos: (t: number, threshold?: number) => number;
|
|
15
|
+
export declare const lineLength: (line: {
|
|
16
|
+
a: Vec2;
|
|
17
|
+
b: Vec2;
|
|
18
|
+
}) => number;
|
|
19
|
+
export interface RemoveMeasurementResult {
|
|
20
|
+
ops: AnnotationPatchOp[];
|
|
21
|
+
keepSelection: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare const buildRemoveMeasurementOps: (placed: PlacedMeasurementRef) => RemoveMeasurementResult;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Pure geometry for measurement annotations (the `placement: 'line'` variant
|
|
2
|
+
// of PlacedMeasurementRef). Skia-free and dependency-free so it's safe in tools,
|
|
3
|
+
// hit-tests, and unit tests.
|
|
4
|
+
//
|
|
5
|
+
// NOTE: the native overlay's `useAnimatedStyle` and the live-line derived values
|
|
6
|
+
// run on the UI thread and CANNOT call these functions across the worklet
|
|
7
|
+
// boundary, so they inline the same math. Those inlined copies are the "worklet
|
|
8
|
+
// twins" of these helpers — if you change the math here, change them too (they
|
|
9
|
+
// are commented as such in AnnotationCanvasInner.native.tsx).
|
|
10
|
+
// Default position of the tile along its line when `linePos` is absent: center.
|
|
11
|
+
export const DEFAULT_LINE_POS = 0.5;
|
|
12
|
+
export const lerp = (a, b, t) => ({
|
|
13
|
+
x: a.x + (b.x - a.x) * t,
|
|
14
|
+
y: a.y + (b.y - a.y) * t,
|
|
15
|
+
});
|
|
16
|
+
// Back-compat accessors: a stamp persisted before this feature has neither
|
|
17
|
+
// field, and reads as a bare 'none' stamp centered on its line (if any).
|
|
18
|
+
export const placementOf = (m) => m.placement ?? 'none';
|
|
19
|
+
export const linePosOf = (m) => m.linePos ?? DEFAULT_LINE_POS;
|
|
20
|
+
// The tile anchor implied by a line + placement. For 'line', the tile sits at
|
|
21
|
+
// lerp(a, b, linePos); for everything else the stored anchor stands. Callers
|
|
22
|
+
// must write the result into `anchor` in the same patch that changes
|
|
23
|
+
// `line`/`placement`/`linePos` so the two never drift.
|
|
24
|
+
export const recomputeAnchor = (line, placement, linePos, fallbackAnchor) => {
|
|
25
|
+
if (placement === 'line' && line)
|
|
26
|
+
return lerp(line.a, line.b, linePos);
|
|
27
|
+
return fallbackAnchor;
|
|
28
|
+
};
|
|
29
|
+
// Project a world point onto segment a→b and return the clamped parameter
|
|
30
|
+
// t ∈ [0, 1] (0 = a, 1 = b). A zero-length line returns 0.
|
|
31
|
+
export const projectToLinePos = (line, world) => {
|
|
32
|
+
const abx = line.b.x - line.a.x;
|
|
33
|
+
const aby = line.b.y - line.a.y;
|
|
34
|
+
const lenSq = abx * abx + aby * aby;
|
|
35
|
+
if (lenSq === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
const t = ((world.x - line.a.x) * abx + (world.y - line.a.y) * aby) / lenSq;
|
|
38
|
+
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
39
|
+
};
|
|
40
|
+
// Snap t to center (0.5) when within `threshold` of it. `threshold` is in
|
|
41
|
+
// t-space; for a consistent on-screen detent, callers convert a screen-px
|
|
42
|
+
// radius to t via (radiusPx / zoom) / lineLengthDoc.
|
|
43
|
+
export const snapLinePos = (t, threshold = 0.06) => Math.abs(t - 0.5) <= threshold ? 0.5 : t;
|
|
44
|
+
// Euclidean length of a line in doc space (used to convert a screen-px snap
|
|
45
|
+
// radius into a t-space threshold).
|
|
46
|
+
export const lineLength = (line) => {
|
|
47
|
+
const dx = line.b.x - line.a.x;
|
|
48
|
+
const dy = line.b.y - line.a.y;
|
|
49
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
50
|
+
};
|
|
51
|
+
export const buildRemoveMeasurementOps = (placed) => {
|
|
52
|
+
if (placementOf(placed) === 'line' && placed.measurementId) {
|
|
53
|
+
return {
|
|
54
|
+
ops: [
|
|
55
|
+
{
|
|
56
|
+
op: 'updateMeasurement',
|
|
57
|
+
id: placed.id,
|
|
58
|
+
patch: {
|
|
59
|
+
measurementId: undefined,
|
|
60
|
+
measurementPath: undefined,
|
|
61
|
+
groupId: undefined,
|
|
62
|
+
labelOverride: undefined,
|
|
63
|
+
unitOverride: undefined,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
keepSelection: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
ops: [{ op: 'removeMeasurement', id: placed.id }],
|
|
72
|
+
keepSelection: false,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { PlacedMeasurementRef } from '
|
|
3
|
-
import type { Measurement } from '
|
|
2
|
+
import type { PlacedMeasurementRef } from '../../types/annotation.js';
|
|
3
|
+
import type { Measurement } from '../../types/firestore.js';
|
|
4
4
|
export interface MeasurementStampRenderArgs {
|
|
5
5
|
placed: PlacedMeasurementRef;
|
|
6
6
|
measurement: Measurement | null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const STAMP_TILE_SIZE = 96;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Shared layout constant for the placed measurement stamp. Lives in a
|
|
2
|
+
// Skia-free module so both the render path (the `renderMeasurementStamp`
|
|
3
|
+
// overlay — see measurementStampOverlay.ts) and the hit-test (selectTool)
|
|
4
|
+
// can import it without dragging @shopify/react-native-skia into the
|
|
5
|
+
// consumer's static import graph.
|
|
6
|
+
// Constant SCREEN-space edge length of a placed measurement rendered as a
|
|
7
|
+
// square tile. The tile is a fixed-size pin: its on-screen size is this times
|
|
8
|
+
// `placed.scale` and does NOT change with zoom (only its position tracks the
|
|
9
|
+
// canvas). Also drives the select-tool hit box, which converts it back to doc
|
|
10
|
+
// space via the zoom.
|
|
11
|
+
export const STAMP_TILE_SIZE = 96;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { StrokeCap, Vec2 } from '../../types/annotation.js';
|
|
2
|
+
export declare const toSkiaStrokeCap: (cap: StrokeCap | undefined) => "butt" | "round" | "square";
|
|
3
|
+
export declare const arrowheadLength: (width: number) => number;
|
|
4
|
+
export declare const arrowheadTriangle: (end: Vec2, from: Vec2, width: number) => [Vec2, Vec2, Vec2];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Pure helpers for stroke end-cap rendering. Skia-free and dependency-free so
|
|
2
|
+
// they're safe in element renderers, the measurement-line pass, and unit tests.
|
|
3
|
+
// Map our end-cap style to a Skia-valid strokeCap. 'arrow' is NOT a Skia cap
|
|
4
|
+
// (we draw an arrowhead separately), so the base line uses a 'round' cap.
|
|
5
|
+
// Absent === 'round'.
|
|
6
|
+
export const toSkiaStrokeCap = (cap) => cap === 'butt' || cap === 'square' ? cap : 'round';
|
|
7
|
+
// Forward length of the arrowhead (line end → apex), as a multiple of stroke
|
|
8
|
+
// width (clamped to a minimum so thin strokes still get a visible head). Doc
|
|
9
|
+
// units. Kept small since the head is a solid filled triangle.
|
|
10
|
+
export const arrowheadLength = (width) => Math.max(width * 2, 6);
|
|
11
|
+
// Half the arrowhead's base width. Kept >= the round-cap radius (width / 2) so
|
|
12
|
+
// the triangle fully covers the shaft's end cap.
|
|
13
|
+
const arrowheadHalfBase = (width) => Math.max(width * 0.9, 4);
|
|
14
|
+
// A solid-triangle arrowhead pointing in the travel direction (from `from`
|
|
15
|
+
// toward `end`). Returns [apex, baseLeft, baseRight]: the apex sits FORWARD of
|
|
16
|
+
// the line end (so the point isn't swallowed by the shaft or its end cap), and
|
|
17
|
+
// the base straddles the end point (so the triangle hides the cap bulge).
|
|
18
|
+
export const arrowheadTriangle = (end, from, width) => {
|
|
19
|
+
const dx = end.x - from.x;
|
|
20
|
+
const dy = end.y - from.y;
|
|
21
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
22
|
+
const ux = dx / len; // unit travel direction
|
|
23
|
+
const uy = dy / len;
|
|
24
|
+
const px = -uy; // unit perpendicular
|
|
25
|
+
const py = ux;
|
|
26
|
+
const head = arrowheadLength(width);
|
|
27
|
+
const half = arrowheadHalfBase(width);
|
|
28
|
+
return [
|
|
29
|
+
{ x: end.x + ux * head, y: end.y + uy * head }, // apex, forward of the end
|
|
30
|
+
{ x: end.x + px * half, y: end.y + py * half }, // base left
|
|
31
|
+
{ x: end.x - px * half, y: end.y - py * half }, // base right
|
|
32
|
+
];
|
|
33
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_LAYER_ID } from '
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
2
|
let counter = 0;
|
|
3
3
|
const makeId = () => `measurement-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
4
4
|
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
@@ -5,6 +5,9 @@ export const createPanTool = (options = {}) => ({
|
|
|
5
5
|
id: 'pan',
|
|
6
6
|
label: 'Hand',
|
|
7
7
|
cursor: options.cursor ?? 'grab',
|
|
8
|
+
// Native pans the viewport on the UI thread (see Tool.panViewport). The
|
|
9
|
+
// onPointer* handlers below remain the web/parity implementation.
|
|
10
|
+
panViewport: true,
|
|
8
11
|
onPointerDown(event, _ctx, _state) {
|
|
9
12
|
return { kind: 'panning', lastScreen: event.screen };
|
|
10
13
|
},
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { AnnotationStroke } from '
|
|
1
|
+
import type { AnnotationStroke, StrokeCap } from '../../../types/annotation.js';
|
|
2
2
|
import type { Tool } from '../Tool.js';
|
|
3
3
|
export interface PenToolOptions {
|
|
4
4
|
color?: string;
|
|
5
5
|
width?: number;
|
|
6
|
+
cap?: StrokeCap;
|
|
6
7
|
minSampleDistance?: number;
|
|
7
8
|
variant?: 'pen' | 'marker' | 'highlighter';
|
|
8
9
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_LAYER_ID } from '
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
2
|
const distSq = (ax, ay, bx, by) => {
|
|
3
3
|
const dx = ax - bx;
|
|
4
4
|
const dy = ay - by;
|
|
@@ -9,13 +9,21 @@ const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).t
|
|
|
9
9
|
export const createPenTool = (options = {}) => {
|
|
10
10
|
const color = options.color ?? '#111827';
|
|
11
11
|
const width = options.width ?? 2;
|
|
12
|
+
const cap = options.cap ?? 'round';
|
|
12
13
|
const variant = options.variant ?? 'pen';
|
|
13
14
|
const minSampleDistance = options.minSampleDistance ?? 1.5;
|
|
14
15
|
const minSampleDistanceSq = minSampleDistance * minSampleDistance;
|
|
16
|
+
// A degenerate single-point dot has no direction (so 'arrow' is meaningless)
|
|
17
|
+
// and is invisible with a 'butt' cap (zero-length path), so a dot falls back
|
|
18
|
+
// to a visible, directionless cap.
|
|
19
|
+
const dotCap = cap === 'butt' || cap === 'arrow' ? 'round' : cap;
|
|
15
20
|
return {
|
|
16
21
|
id: variant,
|
|
17
22
|
label: variant === 'pen' ? 'Pen' : variant === 'marker' ? 'Marker' : 'Highlighter',
|
|
18
23
|
cursor: 'crosshair',
|
|
24
|
+
// Drives UI-thread drawing on native (see FreehandConfig). The
|
|
25
|
+
// onPointerDown/Move/Up below remain the web/parity implementation.
|
|
26
|
+
freehand: { variant, color, width, cap, minSampleDistance },
|
|
19
27
|
onPointerDown(event, ctx) {
|
|
20
28
|
const stroke = {
|
|
21
29
|
id: makeId('stroke'),
|
|
@@ -23,6 +31,7 @@ export const createPenTool = (options = {}) => {
|
|
|
23
31
|
tool: variant,
|
|
24
32
|
color,
|
|
25
33
|
width,
|
|
34
|
+
cap,
|
|
26
35
|
points: [event.world.x, event.world.y],
|
|
27
36
|
pressure: event.pressure !== undefined ? [event.pressure] : undefined,
|
|
28
37
|
createdAt: Date.now(),
|
|
@@ -54,10 +63,28 @@ export const createPenTool = (options = {}) => {
|
|
|
54
63
|
const s = state;
|
|
55
64
|
if (s?.kind !== 'pen-drawing')
|
|
56
65
|
return;
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
const pts = s.stroke.points;
|
|
67
|
+
if (pts.length >= 4) {
|
|
68
|
+
// Two or more distinct samples → a normal stroke.
|
|
69
|
+
ctx.commit({ ops: [{ op: 'addStroke', stroke: s.stroke }] });
|
|
70
|
+
}
|
|
71
|
+
else if (pts.length === 2) {
|
|
72
|
+
// A click with no drag → a single dot. Duplicate the point so the
|
|
73
|
+
// stroke renders as a filled dot of diameter = width. A 'butt' cap
|
|
74
|
+
// would make the zero-length path invisible, so the dot uses dotCap.
|
|
75
|
+
ctx.commit({
|
|
76
|
+
ops: [
|
|
77
|
+
{
|
|
78
|
+
op: 'addStroke',
|
|
79
|
+
stroke: {
|
|
80
|
+
...s.stroke,
|
|
81
|
+
cap: dotCap,
|
|
82
|
+
points: [pts[0], pts[1], pts[0], pts[1]],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
61
88
|
},
|
|
62
89
|
// No renderPreview. The canvas inner detects PenDrawingState in
|
|
63
90
|
// toolState and renders the in-flight stroke directly with its own
|