@reekon-tools/boldr-utils 1.6.12 → 1.6.13
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 +4 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.js +19 -5
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +4 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +150 -8
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
- package/dist/annotation/canvas/Tool.d.ts +17 -0
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
- package/dist/annotation/canvas/elements/ShapeElement.js +28 -2
- package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
- package/dist/annotation/canvas/measurementGeometry.d.ts +20 -0
- package/dist/annotation/canvas/measurementGeometry.js +38 -1
- package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
- package/dist/annotation/canvas/strokeGeometry.js +8 -0
- package/dist/annotation/canvas/textGeometry.d.ts +24 -0
- package/dist/annotation/canvas/textGeometry.js +110 -0
- package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/penTool.js +3 -1
- package/dist/annotation/canvas/tools/selectTool.js +155 -19
- package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/textTool.js +78 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
- package/dist/annotation/canvas/useAnnotationCanvasState.js +30 -4
- package/dist/annotation/data/coalescedRunner.d.ts +1 -0
- package/dist/annotation/data/coalescedRunner.js +48 -0
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +35 -14
- package/dist/exports.d.ts +4 -2
- package/dist/exports.js +3 -1
- package/dist/types/annotation.d.ts +7 -0
- package/package.json +1 -1
|
@@ -16,6 +16,26 @@ export declare const lineLength: (line: {
|
|
|
16
16
|
a: Vec2;
|
|
17
17
|
b: Vec2;
|
|
18
18
|
}) => number;
|
|
19
|
+
export interface NormalizedRect {
|
|
20
|
+
minX: number;
|
|
21
|
+
minY: number;
|
|
22
|
+
maxX: number;
|
|
23
|
+
maxY: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const normalizeRect: (rect: {
|
|
26
|
+
a: Vec2;
|
|
27
|
+
b: Vec2;
|
|
28
|
+
}) => NormalizedRect;
|
|
29
|
+
export declare const rectCenter: (rect: {
|
|
30
|
+
a: Vec2;
|
|
31
|
+
b: Vec2;
|
|
32
|
+
}) => Vec2;
|
|
33
|
+
export type RectCorner = 'tl' | 'tr' | 'bl' | 'br';
|
|
34
|
+
export declare const rectCornerPoint: (rect: {
|
|
35
|
+
a: Vec2;
|
|
36
|
+
b: Vec2;
|
|
37
|
+
}, corner: RectCorner) => Vec2;
|
|
38
|
+
export declare const oppositeRectCorner: (corner: RectCorner) => RectCorner;
|
|
19
39
|
export interface RemoveMeasurementResult {
|
|
20
40
|
ops: AnnotationPatchOp[];
|
|
21
41
|
keepSelection: boolean;
|
|
@@ -48,8 +48,45 @@ export const lineLength = (line) => {
|
|
|
48
48
|
const dy = line.b.y - line.a.y;
|
|
49
49
|
return Math.sqrt(dx * dx + dy * dy);
|
|
50
50
|
};
|
|
51
|
+
export const normalizeRect = (rect) => ({
|
|
52
|
+
minX: Math.min(rect.a.x, rect.b.x),
|
|
53
|
+
minY: Math.min(rect.a.y, rect.b.y),
|
|
54
|
+
maxX: Math.max(rect.a.x, rect.b.x),
|
|
55
|
+
maxY: Math.max(rect.a.y, rect.b.y),
|
|
56
|
+
});
|
|
57
|
+
// The tile anchor for a rectangle annotation: always the center. Any op that
|
|
58
|
+
// changes `rect` must write this into `anchor` in the same patch.
|
|
59
|
+
export const rectCenter = (rect) => ({
|
|
60
|
+
x: (rect.a.x + rect.b.x) / 2,
|
|
61
|
+
y: (rect.a.y + rect.b.y) / 2,
|
|
62
|
+
});
|
|
63
|
+
export const rectCornerPoint = (rect, corner) => {
|
|
64
|
+
const n = normalizeRect(rect);
|
|
65
|
+
switch (corner) {
|
|
66
|
+
case 'tl':
|
|
67
|
+
return { x: n.minX, y: n.minY };
|
|
68
|
+
case 'tr':
|
|
69
|
+
return { x: n.maxX, y: n.minY };
|
|
70
|
+
case 'bl':
|
|
71
|
+
return { x: n.minX, y: n.maxY };
|
|
72
|
+
case 'br':
|
|
73
|
+
return { x: n.maxX, y: n.maxY };
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
export const oppositeRectCorner = (corner) => {
|
|
77
|
+
switch (corner) {
|
|
78
|
+
case 'tl':
|
|
79
|
+
return 'br';
|
|
80
|
+
case 'tr':
|
|
81
|
+
return 'bl';
|
|
82
|
+
case 'bl':
|
|
83
|
+
return 'tr';
|
|
84
|
+
case 'br':
|
|
85
|
+
return 'tl';
|
|
86
|
+
}
|
|
87
|
+
};
|
|
51
88
|
export const buildRemoveMeasurementOps = (placed) => {
|
|
52
|
-
if (placementOf(placed)
|
|
89
|
+
if (placementOf(placed) !== 'none' && placed.measurementId) {
|
|
53
90
|
return {
|
|
54
91
|
ops: [
|
|
55
92
|
{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StrokeCap, Vec2 } from '../../types/annotation.js';
|
|
2
2
|
export declare const toSkiaStrokeCap: (cap: StrokeCap | undefined) => "butt" | "round" | "square";
|
|
3
|
+
export declare const dashIntervals: (width: number) => [number, number];
|
|
3
4
|
export declare const arrowheadLength: (width: number) => number;
|
|
4
5
|
export declare const arrowheadTriangle: (end: Vec2, from: Vec2, width: number) => [Vec2, Vec2, Vec2];
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
// (we draw an arrowhead separately), so the base line uses a 'round' cap.
|
|
5
5
|
// Absent === 'round'.
|
|
6
6
|
export const toSkiaStrokeCap = (cap) => cap === 'butt' || cap === 'square' ? cap : 'round';
|
|
7
|
+
// Dash pattern ([on, off], doc units) for dashed strokes/lines/rect borders.
|
|
8
|
+
// Scales with the stroke width so thick strokes read as dashed rather than
|
|
9
|
+
// dotted, floored so thin strokes still show distinct dashes. Single source
|
|
10
|
+
// for every dashed paint in the canvas — keep all platforms identical.
|
|
11
|
+
export const dashIntervals = (width) => [
|
|
12
|
+
Math.max(width * 3, 6),
|
|
13
|
+
Math.max(width * 2, 4),
|
|
14
|
+
];
|
|
7
15
|
// Forward length of the arrowhead (line end → apex), as a multiple of stroke
|
|
8
16
|
// width (clamped to a minimum so thin strokes still get a visible head). Doc
|
|
9
17
|
// units. Kept small since the head is a solid filled triangle.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnnotationShape, Vec2 } from '../../types/annotation.js';
|
|
2
|
+
export declare const DEFAULT_TEXT_FONT_SIZE = 24;
|
|
3
|
+
export declare const MIN_TEXT_FONT_SIZE = 8;
|
|
4
|
+
export declare const MAX_TEXT_FONT_SIZE = 200;
|
|
5
|
+
export declare const TEXT_LINE_HEIGHT_FACTOR = 1.2;
|
|
6
|
+
export declare const TEXT_BASELINE_FACTOR = 0.8;
|
|
7
|
+
export declare const SELECTION_PAD = 6;
|
|
8
|
+
export declare const estimateLineWidth: (line: string, fontSize: number) => number;
|
|
9
|
+
export interface TextBounds {
|
|
10
|
+
minX: number;
|
|
11
|
+
minY: number;
|
|
12
|
+
maxX: number;
|
|
13
|
+
maxY: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const textShapeBounds: (shape: AnnotationShape) => TextBounds | null;
|
|
16
|
+
export declare const hitTestTextShape: (shape: AnnotationShape, p: Vec2, padding?: number) => boolean;
|
|
17
|
+
export interface ResizeGeometry {
|
|
18
|
+
pivot: Vec2;
|
|
19
|
+
handle: Vec2;
|
|
20
|
+
minScale: number;
|
|
21
|
+
maxScale: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const textResizeGeometry: (shape: AnnotationShape) => ResizeGeometry | null;
|
|
24
|
+
export declare const resizeScaleFromDrag: (geom: ResizeGeometry, delta: Vec2) => number;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Geometry for `kind: 'text'` shapes. A text shape stores only its TOP-LEFT
|
|
2
|
+
// anchor (`geometry.points[0]`), its content (`text`, '\n'-separated lines) and
|
|
3
|
+
// its size (`style.fontSize`, doc units); everything else — bounds, hit boxes,
|
|
4
|
+
// the resize handle, line layout — derives from those through this module.
|
|
5
|
+
//
|
|
6
|
+
// This module is deliberately Skia-free: tools (selectTool, textTool) need the
|
|
7
|
+
// same bounds the renderer uses, and tools must stay importable without
|
|
8
|
+
// touching @shopify/react-native-skia (see penTool.ts). So bounds are
|
|
9
|
+
// ESTIMATED from per-character advance-width factors rather than measured with
|
|
10
|
+
// a real SkFont. The renderer (ShapeElement) lays glyphs out with the same
|
|
11
|
+
// line-height/baseline factors, so boxes and glyphs agree vertically; widths
|
|
12
|
+
// are approximate, which is fine for selection chrome and hit-testing.
|
|
13
|
+
export const DEFAULT_TEXT_FONT_SIZE = 24;
|
|
14
|
+
export const MIN_TEXT_FONT_SIZE = 8;
|
|
15
|
+
export const MAX_TEXT_FONT_SIZE = 200;
|
|
16
|
+
// Line height and baseline offset (from the line top), as multiples of
|
|
17
|
+
// fontSize. The renderer uses the same factors so the estimated box always
|
|
18
|
+
// brackets the drawn glyphs vertically.
|
|
19
|
+
export const TEXT_LINE_HEIGHT_FACTOR = 1.2;
|
|
20
|
+
export const TEXT_BASELINE_FACTOR = 0.8;
|
|
21
|
+
// Selection-box padding in doc units. Lives here (Skia-free) rather than in
|
|
22
|
+
// AnnotationCanvasSkia so tools can hit-test selection chrome — the text
|
|
23
|
+
// resize handle sits on the padded box corner — without importing the Skia
|
|
24
|
+
// tree. AnnotationCanvasSkia imports this for every selection box.
|
|
25
|
+
export const SELECTION_PAD = 6;
|
|
26
|
+
// Advance width / fontSize per character class, tuned for a bold geometric
|
|
27
|
+
// sans (the app font is Metropolis Bold). Estimates, not measurements — see
|
|
28
|
+
// the module comment.
|
|
29
|
+
const NARROW = new Set("ijl!|.,:;'`".split(''));
|
|
30
|
+
const SLIM = new Set('ftr()[]{}"- '.split(''));
|
|
31
|
+
const WIDE = new Set('mwMW@%'.split(''));
|
|
32
|
+
const charWidthFactor = (ch) => {
|
|
33
|
+
if (NARROW.has(ch))
|
|
34
|
+
return 0.3;
|
|
35
|
+
if (SLIM.has(ch))
|
|
36
|
+
return 0.42;
|
|
37
|
+
if (WIDE.has(ch))
|
|
38
|
+
return 0.92;
|
|
39
|
+
if (ch >= 'A' && ch <= 'Z')
|
|
40
|
+
return 0.72;
|
|
41
|
+
return 0.6;
|
|
42
|
+
};
|
|
43
|
+
export const estimateLineWidth = (line, fontSize) => {
|
|
44
|
+
let w = 0;
|
|
45
|
+
for (const ch of line)
|
|
46
|
+
w += charWidthFactor(ch);
|
|
47
|
+
return w * fontSize;
|
|
48
|
+
};
|
|
49
|
+
const textFontSize = (shape) => shape.style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
50
|
+
// Estimated bounds of a text shape, or null when it isn't a renderable text
|
|
51
|
+
// shape (wrong kind, no anchor, no text).
|
|
52
|
+
export const textShapeBounds = (shape) => {
|
|
53
|
+
if (shape.kind !== 'text')
|
|
54
|
+
return null;
|
|
55
|
+
const origin = shape.geometry.points[0];
|
|
56
|
+
if (!origin || !shape.text)
|
|
57
|
+
return null;
|
|
58
|
+
const fontSize = textFontSize(shape);
|
|
59
|
+
const lines = shape.text.split('\n');
|
|
60
|
+
let width = 0;
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const w = estimateLineWidth(line, fontSize);
|
|
63
|
+
if (w > width)
|
|
64
|
+
width = w;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
minX: origin.x,
|
|
68
|
+
minY: origin.y,
|
|
69
|
+
maxX: origin.x + width,
|
|
70
|
+
maxY: origin.y + lines.length * TEXT_LINE_HEIGHT_FACTOR * fontSize,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
// Whether a world point is on a text shape (its estimated box + padding).
|
|
74
|
+
export const hitTestTextShape = (shape, p, padding = SELECTION_PAD) => {
|
|
75
|
+
const b = textShapeBounds(shape);
|
|
76
|
+
if (!b)
|
|
77
|
+
return false;
|
|
78
|
+
return (p.x >= b.minX - padding &&
|
|
79
|
+
p.x <= b.maxX + padding &&
|
|
80
|
+
p.y >= b.minY - padding &&
|
|
81
|
+
p.y <= b.maxY + padding);
|
|
82
|
+
};
|
|
83
|
+
export const textResizeGeometry = (shape) => {
|
|
84
|
+
const b = textShapeBounds(shape);
|
|
85
|
+
if (!b)
|
|
86
|
+
return null;
|
|
87
|
+
const fontSize = textFontSize(shape);
|
|
88
|
+
return {
|
|
89
|
+
pivot: { x: b.minX, y: b.minY },
|
|
90
|
+
handle: { x: b.maxX + SELECTION_PAD, y: b.maxY + SELECTION_PAD },
|
|
91
|
+
minScale: MIN_TEXT_FONT_SIZE / fontSize,
|
|
92
|
+
maxScale: MAX_TEXT_FONT_SIZE / fontSize,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
// Uniform scale for a resize drag: how far the handle has moved from the
|
|
96
|
+
// pivot, relative to where it started, clamped to the geometry's scale range.
|
|
97
|
+
// The native resize preview worklet in AnnotationCanvasInner.native.tsx is a
|
|
98
|
+
// WORKLET TWIN of this math — keep them in sync.
|
|
99
|
+
export const resizeScaleFromDrag = (geom, delta) => {
|
|
100
|
+
const bx = geom.handle.x - geom.pivot.x;
|
|
101
|
+
const by = geom.handle.y - geom.pivot.y;
|
|
102
|
+
const baseLen = Math.sqrt(bx * bx + by * by);
|
|
103
|
+
let s = 1;
|
|
104
|
+
if (baseLen > 0) {
|
|
105
|
+
const nx = bx + delta.x;
|
|
106
|
+
const ny = by + delta.y;
|
|
107
|
+
s = Math.sqrt(nx * nx + ny * ny) / baseLen;
|
|
108
|
+
}
|
|
109
|
+
return Math.min(geom.maxScale, Math.max(geom.minScale, s));
|
|
110
|
+
};
|
|
@@ -10,6 +10,7 @@ export const createPenTool = (options = {}) => {
|
|
|
10
10
|
const color = options.color ?? '#111827';
|
|
11
11
|
const width = options.width ?? 2;
|
|
12
12
|
const cap = options.cap ?? 'round';
|
|
13
|
+
const dash = options.dash ?? false;
|
|
13
14
|
const variant = options.variant ?? 'pen';
|
|
14
15
|
const minSampleDistance = options.minSampleDistance ?? 1.5;
|
|
15
16
|
const minSampleDistanceSq = minSampleDistance * minSampleDistance;
|
|
@@ -23,7 +24,7 @@ export const createPenTool = (options = {}) => {
|
|
|
23
24
|
cursor: 'crosshair',
|
|
24
25
|
// Drives UI-thread drawing on native (see FreehandConfig). The
|
|
25
26
|
// onPointerDown/Move/Up below remain the web/parity implementation.
|
|
26
|
-
freehand: { variant, color, width, cap, minSampleDistance },
|
|
27
|
+
freehand: { variant, color, width, cap, dash, minSampleDistance },
|
|
27
28
|
onPointerDown(event, ctx) {
|
|
28
29
|
const stroke = {
|
|
29
30
|
id: makeId('stroke'),
|
|
@@ -32,6 +33,7 @@ export const createPenTool = (options = {}) => {
|
|
|
32
33
|
color,
|
|
33
34
|
width,
|
|
34
35
|
cap,
|
|
36
|
+
...(dash && { dash }),
|
|
35
37
|
points: [event.world.x, event.world.y],
|
|
36
38
|
pressure: event.pressure !== undefined ? [event.pressure] : undefined,
|
|
37
39
|
createdAt: Date.now(),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { STAMP_TILE_SIZE } from '../stampLayout.js';
|
|
2
|
-
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, } from '../measurementGeometry.js';
|
|
2
|
+
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, } from '../measurementGeometry.js';
|
|
3
|
+
import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
|
|
3
4
|
const HIT_PADDING = 6;
|
|
4
5
|
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
5
6
|
// override via `hitTest` for more precision later.
|
|
@@ -42,6 +43,18 @@ const hitMeasurement = (m, p, zoom = 1) => {
|
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
// Rectangle annotation: grab anywhere along the border (the interior stays
|
|
47
|
+
// transparent to hits so elements behind the rect remain selectable).
|
|
48
|
+
if (m.rect && placementOf(m) === 'rectangle') {
|
|
49
|
+
const n = normalizeRect(m.rect);
|
|
50
|
+
const r2 = (LINE_GRAB_PX / zoom) ** 2;
|
|
51
|
+
if (segmentDistanceSq(p.x, p.y, n.minX, n.minY, n.maxX, n.minY) <= r2 ||
|
|
52
|
+
segmentDistanceSq(p.x, p.y, n.maxX, n.minY, n.maxX, n.maxY) <= r2 ||
|
|
53
|
+
segmentDistanceSq(p.x, p.y, n.maxX, n.maxY, n.minX, n.maxY) <= r2 ||
|
|
54
|
+
segmentDistanceSq(p.x, p.y, n.minX, n.maxY, n.minX, n.minY) <= r2) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
45
58
|
return false;
|
|
46
59
|
};
|
|
47
60
|
const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
@@ -65,24 +78,38 @@ const findHit = (doc, world, zoom) => {
|
|
|
65
78
|
}
|
|
66
79
|
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
67
80
|
// Default shape hit test: bounding box of the shape's points + padding.
|
|
81
|
+
// Text shapes store only their top-left anchor, so their box comes from
|
|
82
|
+
// the estimated text bounds instead.
|
|
68
83
|
const s = doc.shapes[i];
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
let minX;
|
|
85
|
+
let maxX;
|
|
86
|
+
let minY;
|
|
87
|
+
let maxY;
|
|
88
|
+
if (s.kind === 'text') {
|
|
89
|
+
const b = textShapeBounds(s);
|
|
90
|
+
if (!b)
|
|
91
|
+
continue;
|
|
92
|
+
({ minX, maxX, minY, maxY } = b);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const pts = s.geometry.points;
|
|
96
|
+
if (pts.length === 0)
|
|
97
|
+
continue;
|
|
98
|
+
minX = pts[0].x;
|
|
99
|
+
maxX = pts[0].x;
|
|
100
|
+
minY = pts[0].y;
|
|
101
|
+
maxY = pts[0].y;
|
|
102
|
+
for (let j = 1; j < pts.length; j++) {
|
|
103
|
+
const p = pts[j];
|
|
104
|
+
if (p.x < minX)
|
|
105
|
+
minX = p.x;
|
|
106
|
+
if (p.x > maxX)
|
|
107
|
+
maxX = p.x;
|
|
108
|
+
if (p.y < minY)
|
|
109
|
+
minY = p.y;
|
|
110
|
+
if (p.y > maxY)
|
|
111
|
+
maxY = p.y;
|
|
112
|
+
}
|
|
86
113
|
}
|
|
87
114
|
if (world.x >= minX - HIT_PADDING &&
|
|
88
115
|
world.x <= maxX + HIT_PADDING &&
|
|
@@ -111,6 +138,8 @@ const translatePatch = (elementKind, id, doc, delta) => {
|
|
|
111
138
|
const patch = { anchor: tx(m.anchor) };
|
|
112
139
|
if (m.line)
|
|
113
140
|
patch.line = { a: tx(m.line.a), b: tx(m.line.b) };
|
|
141
|
+
if (m.rect)
|
|
142
|
+
patch.rect = { a: tx(m.rect.a), b: tx(m.rect.b) };
|
|
114
143
|
if (m.leader)
|
|
115
144
|
patch.leader = { from: tx(m.leader.from), to: tx(m.leader.to) };
|
|
116
145
|
return { op: 'updateMeasurement', id, patch };
|
|
@@ -207,6 +236,81 @@ const endpointPatch = (doc, id, handle, delta) => {
|
|
|
207
236
|
const anchor = recomputeAnchor(line, 'line', linePosOf(m), m.anchor);
|
|
208
237
|
return { ops: [{ op: 'updateMeasurement', id, patch: { line, anchor } }] };
|
|
209
238
|
};
|
|
239
|
+
// Which corner handle of a (selected) rectangle annotation is under `world`.
|
|
240
|
+
// Prefers the nearest corner when several are within range (small rects).
|
|
241
|
+
const findRectCornerHit = (doc, id, world, zoom) => {
|
|
242
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
243
|
+
if (!m || !m.rect || placementOf(m) !== 'rectangle')
|
|
244
|
+
return null;
|
|
245
|
+
const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
|
|
246
|
+
let best = null;
|
|
247
|
+
for (const corner of ['tl', 'tr', 'bl', 'br']) {
|
|
248
|
+
const p = rectCornerPoint(m.rect, corner);
|
|
249
|
+
const d = (world.x - p.x) ** 2 + (world.y - p.y) ** 2;
|
|
250
|
+
if (d <= r2 && (!best || d < best.d))
|
|
251
|
+
best = { corner, d };
|
|
252
|
+
}
|
|
253
|
+
if (!best)
|
|
254
|
+
return null;
|
|
255
|
+
return {
|
|
256
|
+
corner: best.corner,
|
|
257
|
+
moving: rectCornerPoint(m.rect, best.corner),
|
|
258
|
+
fixed: rectCornerPoint(m.rect, oppositeRectCorner(best.corner)),
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
// Drag one rect corner by a world delta (opposite corner fixed) and re-center
|
|
262
|
+
// the anchor, keeping the tile locked to the rect's center.
|
|
263
|
+
const rectCornerPatch = (doc, id, corner, delta) => {
|
|
264
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
265
|
+
if (!m || !m.rect || placementOf(m) !== 'rectangle')
|
|
266
|
+
return null;
|
|
267
|
+
const moving = rectCornerPoint(m.rect, corner);
|
|
268
|
+
const rect = {
|
|
269
|
+
a: rectCornerPoint(m.rect, oppositeRectCorner(corner)),
|
|
270
|
+
b: { x: moving.x + delta.x, y: moving.y + delta.y },
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
ops: [
|
|
274
|
+
{ op: 'updateMeasurement', id, patch: { rect, anchor: rectCenter(rect) } },
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
// --- Text-shape resize (corner-scale about the top-left anchor; shared by the
|
|
279
|
+
// native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
|
|
280
|
+
// Resize geometry when the (selected) text shape's corner handle is under
|
|
281
|
+
// `world`, else null. The handle is drawn on the padded selection box's
|
|
282
|
+
// bottom-right corner (see AnnotationCanvasSkia); grab radius matches the
|
|
283
|
+
// measurement endpoint handles.
|
|
284
|
+
const findResizeHandleHit = (doc, id, world, zoom) => {
|
|
285
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
286
|
+
if (!s)
|
|
287
|
+
return null;
|
|
288
|
+
const geom = textResizeGeometry(s);
|
|
289
|
+
if (!geom)
|
|
290
|
+
return null;
|
|
291
|
+
const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
|
|
292
|
+
const dx = world.x - geom.handle.x;
|
|
293
|
+
const dy = world.y - geom.handle.y;
|
|
294
|
+
return dx * dx + dy * dy <= r2 ? geom : null;
|
|
295
|
+
};
|
|
296
|
+
// Scale the text shape's fontSize by the drag (clamped to the geometry's
|
|
297
|
+
// scale range, so it matches the native live preview exactly). The anchor —
|
|
298
|
+
// the scale pivot — is untouched.
|
|
299
|
+
const resizePatch = (doc, id, delta) => {
|
|
300
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
301
|
+
if (!s)
|
|
302
|
+
return null;
|
|
303
|
+
const geom = textResizeGeometry(s);
|
|
304
|
+
if (!geom)
|
|
305
|
+
return null;
|
|
306
|
+
const scale = resizeScaleFromDrag(geom, delta);
|
|
307
|
+
const fontSize = Math.round((s.style.fontSize ?? DEFAULT_TEXT_FONT_SIZE) * scale * 10) / 10;
|
|
308
|
+
return {
|
|
309
|
+
ops: [
|
|
310
|
+
{ op: 'updateShape', id, patch: { style: { ...s.style, fontSize } } },
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
};
|
|
210
314
|
// Patch for the current drag mode (web pointer path), from a world-space delta.
|
|
211
315
|
const dragPatch = (s, doc, delta, zoom) => {
|
|
212
316
|
if (s.mode === 'endpoint' && s.handle) {
|
|
@@ -214,6 +318,11 @@ const dragPatch = (s, doc, delta, zoom) => {
|
|
|
214
318
|
}
|
|
215
319
|
if (s.mode === 'slide')
|
|
216
320
|
return slidePatch(doc, s.id, delta, zoom);
|
|
321
|
+
if (s.mode === 'resize')
|
|
322
|
+
return resizePatch(doc, s.id, delta);
|
|
323
|
+
if (s.mode === 'rect-corner' && s.corner) {
|
|
324
|
+
return rectCornerPatch(doc, s.id, s.corner, delta);
|
|
325
|
+
}
|
|
217
326
|
const op = translatePatch(s.elementKind, s.id, doc, delta);
|
|
218
327
|
return op ? { ops: [op] } : null;
|
|
219
328
|
};
|
|
@@ -234,6 +343,10 @@ export const createSelectTool = () => ({
|
|
|
234
343
|
buildSlidePatch: slidePatch,
|
|
235
344
|
hitTestHandle: findHandleHit,
|
|
236
345
|
buildEndpointPatch: endpointPatch,
|
|
346
|
+
hitTestResizeHandle: findResizeHandleHit,
|
|
347
|
+
buildResizePatch: resizePatch,
|
|
348
|
+
hitTestRectCorner: findRectCornerHit,
|
|
349
|
+
buildRectCornerPatch: rectCornerPatch,
|
|
237
350
|
},
|
|
238
351
|
// Web pointer path. Mirrors the native UI-thread drag using the same shared
|
|
239
352
|
// helpers: an endpoint handle on the selected annotation resizes the line;
|
|
@@ -241,7 +354,7 @@ export const createSelectTool = () => ({
|
|
|
241
354
|
onPointerDown(event, ctx) {
|
|
242
355
|
const { world } = event;
|
|
243
356
|
const zoom = ctx.viewport.state.zoom;
|
|
244
|
-
// Endpoint handles show only on the selected
|
|
357
|
+
// Endpoint/resize handles show only on the selected element — check first.
|
|
245
358
|
const selId = ctx.selection?.ids[0];
|
|
246
359
|
if (selId) {
|
|
247
360
|
const handle = findHandleHit(ctx.document, selId, world, zoom);
|
|
@@ -257,6 +370,29 @@ export const createSelectTool = () => ({
|
|
|
257
370
|
delta: { x: 0, y: 0 },
|
|
258
371
|
};
|
|
259
372
|
}
|
|
373
|
+
if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
|
|
374
|
+
return {
|
|
375
|
+
kind: 'dragging',
|
|
376
|
+
id: selId,
|
|
377
|
+
elementKind: 'shape',
|
|
378
|
+
mode: 'resize',
|
|
379
|
+
start: world,
|
|
380
|
+
delta: { x: 0, y: 0 },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const rectCorner = findRectCornerHit(ctx.document, selId, world, zoom);
|
|
384
|
+
if (rectCorner) {
|
|
385
|
+
ctx.setSelection({ ids: [selId] });
|
|
386
|
+
return {
|
|
387
|
+
kind: 'dragging',
|
|
388
|
+
id: selId,
|
|
389
|
+
elementKind: 'measurement',
|
|
390
|
+
mode: 'rect-corner',
|
|
391
|
+
corner: rectCorner.corner,
|
|
392
|
+
start: world,
|
|
393
|
+
delta: { x: 0, y: 0 },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
260
396
|
}
|
|
261
397
|
const hit = findHit(ctx.document, world, zoom);
|
|
262
398
|
if (!hit) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface TextToolOptions {
|
|
4
|
+
color?: string;
|
|
5
|
+
fontSize?: number;
|
|
6
|
+
dash?: boolean;
|
|
7
|
+
autoSwitchToSelect?: boolean;
|
|
8
|
+
onPlaced?: (shape: AnnotationShape) => void;
|
|
9
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
10
|
+
selectToolId?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const createTextTool: (options?: TextToolOptions) => Tool;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_TEXT_FONT_SIZE, hitTestTextShape } from '../textGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
+
// Topmost text shape under a world point, for tap-to-edit.
|
|
7
|
+
const findTextShapeAt = (doc, world) => {
|
|
8
|
+
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
9
|
+
const s = doc.shapes[i];
|
|
10
|
+
if (s.kind === 'text' && hitTestTextShape(s, world))
|
|
11
|
+
return s;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
// Tap-to-type. Tapping empty canvas opens the consumer's text input and
|
|
16
|
+
// commits a new text shape at the tap point (top-left anchored); tapping an
|
|
17
|
+
// existing text shape re-opens the input pre-filled to edit it (clearing the
|
|
18
|
+
// text deletes the shape). Both paths leave the shape selected and (by
|
|
19
|
+
// default) switch back to select so it can be dragged/resized immediately —
|
|
20
|
+
// the same flow as the measurement stamp tool. Skia-free, like every tool.
|
|
21
|
+
export const createTextTool = (options = {}) => {
|
|
22
|
+
const color = options.color ?? '#111827';
|
|
23
|
+
const fontSize = options.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
24
|
+
const dash = options.dash ?? false;
|
|
25
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
26
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
27
|
+
return {
|
|
28
|
+
id: 'text',
|
|
29
|
+
label: 'Text',
|
|
30
|
+
cursor: 'text',
|
|
31
|
+
onPointerUp(event, ctx) {
|
|
32
|
+
const existing = findTextShapeAt(ctx.document, event.world);
|
|
33
|
+
if (existing) {
|
|
34
|
+
void ctx
|
|
35
|
+
.requestTextInput({ initialText: existing.text })
|
|
36
|
+
.then((text) => {
|
|
37
|
+
if (text === null)
|
|
38
|
+
return;
|
|
39
|
+
if (text === '') {
|
|
40
|
+
ctx.commit({ ops: [{ op: 'removeShape', id: existing.id }] });
|
|
41
|
+
ctx.setSelection(null);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (text !== existing.text) {
|
|
45
|
+
ctx.commit({
|
|
46
|
+
ops: [{ op: 'updateShape', id: existing.id, patch: { text } }],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
ctx.setSelection({ ids: [existing.id] });
|
|
50
|
+
if (autoSwitchToSelect)
|
|
51
|
+
options.onAutoSwitch?.(selectToolId);
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const anchor = event.world;
|
|
56
|
+
void ctx.requestTextInput().then((text) => {
|
|
57
|
+
if (!text)
|
|
58
|
+
return;
|
|
59
|
+
const shape = {
|
|
60
|
+
id: makeId(),
|
|
61
|
+
layerId: firstLayerId(ctx.document),
|
|
62
|
+
kind: 'text',
|
|
63
|
+
geometry: { points: [anchor] },
|
|
64
|
+
// `...(dash && ...)` keeps the key absent (not `false`) for solid
|
|
65
|
+
// text, matching the stroke convention (absent === solid).
|
|
66
|
+
style: { stroke: color, fontSize, ...(dash && { dash: true }) },
|
|
67
|
+
text,
|
|
68
|
+
createdAt: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
ctx.commit({ ops: [{ op: 'addShape', shape }] });
|
|
71
|
+
ctx.setSelection({ ids: [shape.id] });
|
|
72
|
+
options.onPlaced?.(shape);
|
|
73
|
+
if (autoSwitchToSelect)
|
|
74
|
+
options.onAutoSwitch?.(selectToolId);
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Measurement } from '../../types/firestore.js';
|
|
2
2
|
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type Vec2 } from '../../types/annotation.js';
|
|
3
3
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
|
-
import type { CanvasPointerEvent, Tool, ToolContext, ToolState } from './Tool.js';
|
|
4
|
+
import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
|
|
5
5
|
import { type ViewportState } from './viewport.js';
|
|
6
6
|
export interface AnnotationCanvasHandle {
|
|
7
7
|
undo(): void;
|
|
@@ -27,6 +27,7 @@ export interface UseAnnotationCanvasStateProps {
|
|
|
27
27
|
onSelectionChange(selection: Selection | null): void;
|
|
28
28
|
measurements?: Measurement[];
|
|
29
29
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
30
|
+
requestTextInput?: RequestTextInput;
|
|
30
31
|
width: number;
|
|
31
32
|
height: number;
|
|
32
33
|
initialViewport?: ViewportState;
|