@reekon-tools/boldr-utils 1.6.14 → 1.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/AnnotationCanvasInner.js +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +2 -2
- package/dist/annotation/canvas/elements/ShapeElement.js +74 -30
- package/dist/annotation/canvas/measurementGeometry.js +6 -5
- package/dist/annotation/canvas/stampLayout.d.ts +4 -0
- package/dist/annotation/canvas/stampLayout.js +25 -9
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
- package/dist/annotation/canvas/tools/measurementTool.d.ts +15 -0
- package/dist/annotation/canvas/tools/measurementTool.js +133 -0
- package/dist/annotation/canvas/tools/selectTool.js +3 -3
- package/dist/annotation/canvas/tools/textTool.d.ts +3 -1
- package/dist/annotation/canvas/tools/textTool.js +28 -3
- package/dist/exports.d.ts +1 -0
- package/dist/exports.js +1 -0
- package/dist/types/annotation.d.ts +2 -0
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, } from 'react';
|
|
|
4
4
|
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
5
5
|
import { buildRemoveMeasurementOps } from './measurementGeometry.js';
|
|
6
6
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
7
|
-
import {
|
|
7
|
+
import { stampTileSize } from './stampLayout.js';
|
|
8
8
|
// Screen-px radius of a measurement-annotation endpoint handle (matches the
|
|
9
9
|
// native HANDLE_RADIUS_PX). Divided by zoom for a constant on-screen size.
|
|
10
10
|
const HANDLE_PX = 7;
|
|
@@ -196,7 +196,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
196
196
|
inset: 0,
|
|
197
197
|
pointerEvents: 'none',
|
|
198
198
|
}, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
|
|
199
|
-
const size =
|
|
199
|
+
const size = stampTileSize(placed);
|
|
200
200
|
const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
|
|
201
201
|
const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
|
|
202
202
|
const isSelected = selection?.ids.includes(placed.id) ?? false;
|
|
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
|
|
4
4
|
import { StyleSheet, TouchableOpacity, View, } from 'react-native';
|
|
5
5
|
import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
|
|
6
6
|
import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
|
|
7
|
-
import {
|
|
7
|
+
import { stampTileSize } from './stampLayout.js';
|
|
8
8
|
import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
9
9
|
import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
|
|
10
10
|
import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
|
|
@@ -1044,7 +1044,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1044
1044
|
} }, placed.id))) }))] }));
|
|
1045
1045
|
};
|
|
1046
1046
|
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onStampLongPress, onRemove, }) => {
|
|
1047
|
-
const size =
|
|
1047
|
+
const size = stampTileSize(placed);
|
|
1048
1048
|
const half = size / 2;
|
|
1049
1049
|
const anchorX = placed.anchor.x;
|
|
1050
1050
|
const anchorY = placed.anchor.y;
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Circle, DashPathEffect,
|
|
2
|
+
import { Circle, DashPathEffect, Line, Paragraph, Path, Rect, Skia, TextDecoration, TextDecorationStyle, } from '@shopify/react-native-skia';
|
|
3
3
|
import { memo, useMemo } from 'react';
|
|
4
4
|
import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
|
|
5
|
-
import { DEFAULT_TEXT_FONT_SIZE,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
9
|
-
const
|
|
5
|
+
import { DEFAULT_TEXT_FONT_SIZE, TEXT_LINE_HEIGHT_FACTOR, } from '../textGeometry.js';
|
|
6
|
+
// Family the loaded annotation typeface is registered under in the per-shape
|
|
7
|
+
// TypefaceFontProvider that backs the text Paragraph (reusing the same typeface
|
|
8
|
+
// the rest of the canvas draws with).
|
|
9
|
+
const TEXT_FONT_FAMILY = 'annotation-text';
|
|
10
|
+
// A wide layout width so text only breaks on explicit '\n' (never auto-wraps).
|
|
11
|
+
// Left-aligned, so the unused width is never visible.
|
|
12
|
+
const TEXT_LAYOUT_WIDTH = 100000;
|
|
13
|
+
// Readable glyph color for highlighted text: the picked color becomes the
|
|
14
|
+
// highlight band, so the text itself flips to black/white by luminance.
|
|
15
|
+
const highlightTextColor = (hex) => {
|
|
16
|
+
const h = hex.replace('#', '');
|
|
17
|
+
if (h.length < 6)
|
|
18
|
+
return '#1A1A1A';
|
|
19
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
20
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
21
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
22
|
+
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
23
|
+
return lum > 0.6 ? '#1A1A1A' : '#FFFFFF';
|
|
24
|
+
};
|
|
10
25
|
const polygonPath = (points, closed) => {
|
|
11
26
|
const path = Skia.Path.Make();
|
|
12
27
|
if (points.length === 0)
|
|
@@ -58,6 +73,53 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
58
73
|
p.close();
|
|
59
74
|
return p;
|
|
60
75
|
}, [kind, geometry.points, closed, style.cap, strokeWidth]);
|
|
76
|
+
// Text shapes render through the Skia Paragraph API so decorations (solid
|
|
77
|
+
// underline / wavy squiggle / highlight band) paint natively. The loaded font
|
|
78
|
+
// is registered into a provider so the Paragraph draws with the same typeface
|
|
79
|
+
// as the rest of the canvas. Built only for text shapes.
|
|
80
|
+
const textParagraph = useMemo(() => {
|
|
81
|
+
if (kind !== 'text' || !text || !font)
|
|
82
|
+
return null;
|
|
83
|
+
const typeface = font.getTypeface();
|
|
84
|
+
if (!typeface)
|
|
85
|
+
return null;
|
|
86
|
+
const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
87
|
+
const decoration = style.textDecoration;
|
|
88
|
+
const colorStr = style.stroke ?? '#000000';
|
|
89
|
+
const textStyle = {
|
|
90
|
+
color: Skia.Color(decoration === 'highlight' ? highlightTextColor(colorStr) : colorStr),
|
|
91
|
+
fontFamilies: [TEXT_FONT_FAMILY],
|
|
92
|
+
fontSize,
|
|
93
|
+
// Match textGeometry's line-height factor so the (Skia-free) selection
|
|
94
|
+
// box keeps bracketing the rendered glyphs vertically.
|
|
95
|
+
heightMultiplier: TEXT_LINE_HEIGHT_FACTOR,
|
|
96
|
+
};
|
|
97
|
+
if (decoration === 'underline' || decoration === 'squiggle') {
|
|
98
|
+
textStyle.decoration = TextDecoration.Underline;
|
|
99
|
+
textStyle.decorationStyle =
|
|
100
|
+
decoration === 'squiggle'
|
|
101
|
+
? TextDecorationStyle.Wavy
|
|
102
|
+
: TextDecorationStyle.Solid;
|
|
103
|
+
textStyle.decorationColor = Skia.Color(colorStr);
|
|
104
|
+
}
|
|
105
|
+
else if (decoration === 'highlight') {
|
|
106
|
+
textStyle.backgroundColor = Skia.Color(colorStr);
|
|
107
|
+
}
|
|
108
|
+
const provider = Skia.TypefaceFontProvider.Make();
|
|
109
|
+
provider.registerFont(typeface, TEXT_FONT_FAMILY);
|
|
110
|
+
const builder = Skia.ParagraphBuilder.Make({
|
|
111
|
+
textStyle,
|
|
112
|
+
strutStyle: {
|
|
113
|
+
strutEnabled: true,
|
|
114
|
+
fontSize,
|
|
115
|
+
heightMultiplier: TEXT_LINE_HEIGHT_FACTOR,
|
|
116
|
+
forceStrutHeight: true,
|
|
117
|
+
},
|
|
118
|
+
}, provider);
|
|
119
|
+
builder.pushStyle(textStyle);
|
|
120
|
+
builder.addText(text);
|
|
121
|
+
return builder.build();
|
|
122
|
+
}, [kind, text, font, style.fontSize, style.textDecoration, style.stroke]);
|
|
61
123
|
const dashEffect = style.dash ? (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) : null;
|
|
62
124
|
switch (kind) {
|
|
63
125
|
case 'rect': {
|
|
@@ -92,32 +154,14 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
92
154
|
return (_jsxs(_Fragment, { children: [fill && closed && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), strokeJoin: "round", children: dashEffect }), arrowHead && _jsx(Path, { path: arrowHead, color: stroke, style: "fill" })] }));
|
|
93
155
|
}
|
|
94
156
|
case 'text': {
|
|
95
|
-
// `origin` is the TOP-LEFT of the text block
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// vector, so this is loss-free. Lines are laid out with the textGeometry
|
|
100
|
-
// factors; Skia <Text> y is the BASELINE, hence the baseline offset.
|
|
157
|
+
// `origin` is the TOP-LEFT of the text block; textGeometry derives the
|
|
158
|
+
// selection box from the same anchor + line-height factor used to build
|
|
159
|
+
// the paragraph, so chrome brackets the glyphs. fontSize is set directly
|
|
160
|
+
// on the paragraph (world units), so no manual glyph scaling is needed.
|
|
101
161
|
const [origin] = geometry.points;
|
|
102
|
-
if (!origin || !
|
|
103
|
-
return null;
|
|
104
|
-
if (!font)
|
|
162
|
+
if (!origin || !textParagraph)
|
|
105
163
|
return null;
|
|
106
|
-
|
|
107
|
-
const baseSize = font.getSize();
|
|
108
|
-
const scale = baseSize > 0 ? fontSize / baseSize : 1;
|
|
109
|
-
// Dash-styled text strokes the glyph outlines (a dash effect is a path
|
|
110
|
-
// effect — it needs stroked geometry, so it can't apply to filled
|
|
111
|
-
// glyphs) and runs the shared dash pattern along them.
|
|
112
|
-
const outlineWidth = baseSize * TEXT_OUTLINE_WIDTH_FACTOR;
|
|
113
|
-
return (_jsx(Group, { transform: [
|
|
114
|
-
{ translateX: origin.x },
|
|
115
|
-
{ translateY: origin.y },
|
|
116
|
-
{ scale },
|
|
117
|
-
], children: text.split('\n').map((line, i) => (_jsx(Text, { x: 0, y: (i * TEXT_LINE_HEIGHT_FACTOR + TEXT_BASELINE_FACTOR) * baseSize, text: line, font: font, color: stroke, ...(style.dash && {
|
|
118
|
-
style: 'stroke',
|
|
119
|
-
strokeWidth: outlineWidth,
|
|
120
|
-
}), children: style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(outlineWidth) })) }, i))) }));
|
|
164
|
+
return (_jsx(Paragraph, { paragraph: textParagraph, x: origin.x, y: origin.y, width: TEXT_LAYOUT_WIDTH }));
|
|
121
165
|
}
|
|
122
166
|
}
|
|
123
167
|
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Pure geometry for measurement annotations (the `placement: 'line'` variant
|
|
2
|
-
// of PlacedMeasurementRef). Skia-free (its only import is
|
|
3
|
-
//
|
|
2
|
+
// of PlacedMeasurementRef). Skia-free (its only import is from stampLayout) so
|
|
3
|
+
// it's safe in tools, hit-tests, and unit tests.
|
|
4
4
|
//
|
|
5
5
|
// NOTE: the native overlay's `useAnimatedStyle` and the live-line derived values
|
|
6
6
|
// run on the UI thread and CANNOT call these functions across the worklet
|
|
7
7
|
// boundary, so they inline the same math. Those inlined copies are the "worklet
|
|
8
8
|
// twins" of these helpers — if you change the math here, change them too (they
|
|
9
9
|
// are commented as such in AnnotationCanvasInner.native.tsx).
|
|
10
|
-
import {
|
|
10
|
+
import { stampTileSize } from './stampLayout.js';
|
|
11
11
|
// Default position of the tile along its line when `linePos` is absent: center.
|
|
12
12
|
export const DEFAULT_LINE_POS = 0.5;
|
|
13
13
|
export const lerp = (a, b, t) => ({
|
|
@@ -114,8 +114,9 @@ export const hitPlacedMeasurement = (m, p, zoom = 1) => {
|
|
|
114
114
|
// anchor, so its doc-space footprint shrinks as you zoom in. Convert the
|
|
115
115
|
// screen-space half-extent (+ padding) back to doc space via the zoom so
|
|
116
116
|
// the hit box always matches what's drawn.
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Unassociated inputs use the smaller input-tile footprint (#6); the helper
|
|
118
|
+
// folds in scale + the associated/blank size so the hit box matches the draw.
|
|
119
|
+
const half = (stampTileSize(m) / 2 + STAMP_HIT_PADDING) / zoom;
|
|
119
120
|
const dx = Math.abs(p.x - m.anchor.x);
|
|
120
121
|
const dy = Math.abs(p.y - m.anchor.y);
|
|
121
122
|
if (dx <= half && dy <= half)
|
|
@@ -1 +1,5 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../types/annotation.js';
|
|
1
2
|
export declare const STAMP_TILE_SIZE = 96;
|
|
3
|
+
export declare const STAMP_INPUT_TILE_SIZE = 56;
|
|
4
|
+
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
|
|
5
|
+
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">) => number;
|
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
// Shared layout
|
|
1
|
+
// Shared layout constants for the placed measurement stamp. Lives in a
|
|
2
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
|
|
5
|
-
// consumer's static import graph.
|
|
6
|
-
// Constant SCREEN-space edge length of a placed measurement
|
|
7
|
-
// square tile. The tile is a fixed-size
|
|
8
|
-
// `placed.scale` and does NOT change with
|
|
9
|
-
// canvas). Also drives the select-tool hit
|
|
10
|
-
// space via the zoom.
|
|
3
|
+
// overlay — see measurementStampOverlay.ts) and the hit-test (selectTool,
|
|
4
|
+
// measurementGeometry) can import it without dragging
|
|
5
|
+
// @shopify/react-native-skia into the consumer's static import graph.
|
|
6
|
+
// Constant SCREEN-space edge length of a placed measurement that HAS a
|
|
7
|
+
// measurement associated, rendered as a square tile. The tile is a fixed-size
|
|
8
|
+
// pin: its on-screen size is this times `placed.scale` and does NOT change with
|
|
9
|
+
// zoom (only its position tracks the canvas). Also drives the select-tool hit
|
|
10
|
+
// box, which converts it back to doc space via the zoom.
|
|
11
11
|
export const STAMP_TILE_SIZE = 96;
|
|
12
|
+
// Edge length for an UNASSOCIATED stamp — a measurement annotation with no
|
|
13
|
+
// measurement picked yet, which renders as a compact "+" input placeholder
|
|
14
|
+
// rather than a full readable tile. Smaller than STAMP_TILE_SIZE so empty
|
|
15
|
+
// inputs read as lightweight tap targets; associated tiles keep STAMP_TILE_SIZE
|
|
16
|
+
// so existing saved annotations are visually unchanged. The single knob for #6.
|
|
17
|
+
export const STAMP_INPUT_TILE_SIZE = 56;
|
|
18
|
+
// A placed measurement is an unassociated input until a measurement reference
|
|
19
|
+
// is attached (id or path). Such stamps use STAMP_INPUT_TILE_SIZE.
|
|
20
|
+
export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
|
|
21
|
+
// Screen-space edge length for a placed stamp: the compact input size while
|
|
22
|
+
// unassociated, full size once a measurement is attached, then scaled by the
|
|
23
|
+
// per-stamp `scale`. The ONE source of truth for tile footprint — render
|
|
24
|
+
// overlay, hit-test, and slide-grab classification all call this so the drawn
|
|
25
|
+
// tile and its touch box always agree.
|
|
26
|
+
export const stampTileSize = (m) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
|
|
27
|
+
(m.scale ?? 1);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface MeasurementLineToolOptions {
|
|
4
|
+
id?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
minDragPx?: number;
|
|
7
|
+
autoSwitchToSelect?: boolean;
|
|
8
|
+
selectToolId?: string;
|
|
9
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
10
|
+
onPlaced?: (measurement: PlacedMeasurementRef) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_LINE_POS, recomputeAnchor } from '../measurementGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
+
// Build the blank line-measurement annotation for a drag from `a` to `b`: a
|
|
7
|
+
// 2-point line with a value tile magnetized to its center (linePos 0.5). The
|
|
8
|
+
// payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
|
|
9
|
+
// line and a (legacy) center-placed one are byte-identical once committed.
|
|
10
|
+
const buildLineMeasurement = (opts) => {
|
|
11
|
+
const line = { a: opts.a, b: opts.b };
|
|
12
|
+
return {
|
|
13
|
+
id: opts.id,
|
|
14
|
+
layerId: opts.layerId,
|
|
15
|
+
placement: 'line',
|
|
16
|
+
line,
|
|
17
|
+
linePos: DEFAULT_LINE_POS,
|
|
18
|
+
// Center of the line; recomputeAnchor keeps this in sync on edits.
|
|
19
|
+
anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
|
|
20
|
+
x: (opts.a.x + opts.b.x) / 2,
|
|
21
|
+
y: (opts.a.y + opts.b.y) / 2,
|
|
22
|
+
}),
|
|
23
|
+
showLabel: true,
|
|
24
|
+
showValue: true,
|
|
25
|
+
createdAt: Date.now(),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
// Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
|
|
29
|
+
// annotation (a line with a blank value tile at its center); release commits
|
|
30
|
+
// it. Mirrors createShapeTool's interaction so "input lines" are placed the
|
|
31
|
+
// same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
|
|
32
|
+
// old center-place affordance. The annotation stays blank (no measurement
|
|
33
|
+
// associated) until the user fills it via the tile / picker. Skia-free, like
|
|
34
|
+
// every tool factory; it renders its live preview through ctx.preview, so it
|
|
35
|
+
// works on web and (via the generic tool-pan dispatch) on native.
|
|
36
|
+
export const createMeasurementLineTool = (options = {}) => {
|
|
37
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
38
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
39
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
40
|
+
return {
|
|
41
|
+
id: options.id ?? 'measure-line',
|
|
42
|
+
label: options.label ?? 'Measurement line',
|
|
43
|
+
cursor: 'crosshair',
|
|
44
|
+
onPointerDown(event) {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'measurement-line-drawing',
|
|
47
|
+
id: makeId(),
|
|
48
|
+
startWorld: event.world,
|
|
49
|
+
startScreen: event.screen,
|
|
50
|
+
moved: false,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
onPointerMove(event, ctx, state) {
|
|
54
|
+
const s = state;
|
|
55
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
56
|
+
return s;
|
|
57
|
+
const measurement = buildLineMeasurement({
|
|
58
|
+
id: s.id,
|
|
59
|
+
layerId: firstLayerId(ctx.document),
|
|
60
|
+
a: s.startWorld,
|
|
61
|
+
b: event.world,
|
|
62
|
+
});
|
|
63
|
+
ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
64
|
+
return { ...s, moved: true };
|
|
65
|
+
},
|
|
66
|
+
onPointerUp(event, ctx, state) {
|
|
67
|
+
const s = state;
|
|
68
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
69
|
+
return;
|
|
70
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
71
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
72
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
73
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
74
|
+
ctx.preview({ ops: [] });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const measurement = buildLineMeasurement({
|
|
78
|
+
id: s.id,
|
|
79
|
+
layerId: firstLayerId(ctx.document),
|
|
80
|
+
a: s.startWorld,
|
|
81
|
+
b: event.world,
|
|
82
|
+
});
|
|
83
|
+
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
84
|
+
// Leave it selected (and hand back to select) so the blank line can be
|
|
85
|
+
// filled / moved straight away — the same flow as the text tool.
|
|
86
|
+
ctx.setSelection({ ids: [measurement.id] });
|
|
87
|
+
options.onPlaced?.(measurement);
|
|
88
|
+
if (autoSwitchToSelect)
|
|
89
|
+
options.onAutoSwitch?.(selectToolId);
|
|
90
|
+
},
|
|
91
|
+
onCancel(_state, ctx) {
|
|
92
|
+
ctx.preview({ ops: [] });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export type MeasurementToolPlacement = 'line' | 'rectangle' | 'none';
|
|
4
|
+
export interface MeasurementToolOptions {
|
|
5
|
+
id?: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
placement?: MeasurementToolPlacement;
|
|
8
|
+
linePos?: number;
|
|
9
|
+
minDragPx?: number;
|
|
10
|
+
autoSwitchToSelect?: boolean;
|
|
11
|
+
selectToolId?: string;
|
|
12
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
13
|
+
onPlaced?: (measurement: PlacedMeasurementRef) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare const createMeasurementTool: (options?: MeasurementToolOptions) => Tool;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_LINE_POS, recomputeAnchor, rectCenter, } from '../measurementGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
+
// Build the blank measurement annotation for a drag a→b (or a single point for
|
|
7
|
+
// the bare stamp). The payload mirrors useAnnotationCanvasState's
|
|
8
|
+
// placeAnnotationAtCenter so a drawn annotation is identical to a (legacy)
|
|
9
|
+
// center-placed one once committed.
|
|
10
|
+
const buildMeasurement = (opts) => {
|
|
11
|
+
const { id, layerId, placement, linePos, a, b } = opts;
|
|
12
|
+
const base = {
|
|
13
|
+
id,
|
|
14
|
+
layerId,
|
|
15
|
+
showLabel: true,
|
|
16
|
+
showValue: true,
|
|
17
|
+
createdAt: Date.now(),
|
|
18
|
+
};
|
|
19
|
+
if (placement === 'rectangle') {
|
|
20
|
+
const rect = { a, b };
|
|
21
|
+
return { ...base, placement: 'rectangle', rect, anchor: rectCenter(rect) };
|
|
22
|
+
}
|
|
23
|
+
if (placement === 'none') {
|
|
24
|
+
// Bare value tile at the tap point — no line/rect geometry.
|
|
25
|
+
return { ...base, placement: 'none', anchor: b };
|
|
26
|
+
}
|
|
27
|
+
const line = { a, b };
|
|
28
|
+
return {
|
|
29
|
+
...base,
|
|
30
|
+
placement: 'line',
|
|
31
|
+
line,
|
|
32
|
+
linePos,
|
|
33
|
+
// Tile anchored along the line at linePos; recomputeAnchor keeps it in
|
|
34
|
+
// sync on later edits.
|
|
35
|
+
anchor: recomputeAnchor(line, 'line', linePos, {
|
|
36
|
+
x: (a.x + b.x) / 2,
|
|
37
|
+
y: (a.y + b.y) / 2,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
// Draw-to-add measurement tool. For 'line'/'rectangle' a press-drag rubber-bands
|
|
42
|
+
// the annotation (a line/rect with a blank value tile) and release commits it;
|
|
43
|
+
// for 'none' a tap drops a bare value tile. Mirrors createShapeTool's
|
|
44
|
+
// interaction so measurements are placed the same way as shapes. The annotation
|
|
45
|
+
// stays blank (no measurement associated) until the user fills it via the tile
|
|
46
|
+
// / keypad. Skia-free; renders its live preview through ctx.preview, so it works
|
|
47
|
+
// on web and (via the generic tool-pan dispatch) on native.
|
|
48
|
+
export const createMeasurementTool = (options = {}) => {
|
|
49
|
+
const placement = options.placement ?? 'line';
|
|
50
|
+
const linePos = options.linePos ?? DEFAULT_LINE_POS;
|
|
51
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
52
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
53
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
54
|
+
const place = (ctx, measurement) => {
|
|
55
|
+
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
56
|
+
ctx.setSelection({ ids: [measurement.id] });
|
|
57
|
+
options.onPlaced?.(measurement);
|
|
58
|
+
if (autoSwitchToSelect)
|
|
59
|
+
options.onAutoSwitch?.(selectToolId);
|
|
60
|
+
};
|
|
61
|
+
// Bare stamp: tap-to-place, no rubber-band.
|
|
62
|
+
if (placement === 'none') {
|
|
63
|
+
return {
|
|
64
|
+
id: options.id ?? 'measure-bare',
|
|
65
|
+
label: options.label ?? 'Measurement stamp',
|
|
66
|
+
cursor: 'copy',
|
|
67
|
+
onPointerUp(event, ctx) {
|
|
68
|
+
place(ctx, buildMeasurement({
|
|
69
|
+
id: makeId(),
|
|
70
|
+
layerId: firstLayerId(ctx.document),
|
|
71
|
+
placement: 'none',
|
|
72
|
+
linePos,
|
|
73
|
+
a: event.world,
|
|
74
|
+
b: event.world,
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const defaultId = placement === 'rectangle' ? 'measure-rect' : 'measure-line';
|
|
80
|
+
const defaultLabel = placement === 'rectangle' ? 'Measurement rectangle' : 'Measurement line';
|
|
81
|
+
return {
|
|
82
|
+
id: options.id ?? defaultId,
|
|
83
|
+
label: options.label ?? defaultLabel,
|
|
84
|
+
cursor: 'crosshair',
|
|
85
|
+
onPointerDown(event) {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'measurement-drawing',
|
|
88
|
+
id: makeId(),
|
|
89
|
+
startWorld: event.world,
|
|
90
|
+
startScreen: event.screen,
|
|
91
|
+
moved: false,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
onPointerMove(event, ctx, state) {
|
|
95
|
+
const s = state;
|
|
96
|
+
if (s?.kind !== 'measurement-drawing')
|
|
97
|
+
return s;
|
|
98
|
+
const measurement = buildMeasurement({
|
|
99
|
+
id: s.id,
|
|
100
|
+
layerId: firstLayerId(ctx.document),
|
|
101
|
+
placement,
|
|
102
|
+
linePos,
|
|
103
|
+
a: s.startWorld,
|
|
104
|
+
b: event.world,
|
|
105
|
+
});
|
|
106
|
+
ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
107
|
+
return { ...s, moved: true };
|
|
108
|
+
},
|
|
109
|
+
onPointerUp(event, ctx, state) {
|
|
110
|
+
const s = state;
|
|
111
|
+
if (s?.kind !== 'measurement-drawing')
|
|
112
|
+
return;
|
|
113
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
114
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
115
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
116
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
117
|
+
ctx.preview({ ops: [] });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
place(ctx, buildMeasurement({
|
|
121
|
+
id: s.id,
|
|
122
|
+
layerId: firstLayerId(ctx.document),
|
|
123
|
+
placement,
|
|
124
|
+
linePos,
|
|
125
|
+
a: s.startWorld,
|
|
126
|
+
b: event.world,
|
|
127
|
+
}));
|
|
128
|
+
},
|
|
129
|
+
onCancel(_state, ctx) {
|
|
130
|
+
ctx.preview({ ops: [] });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stampTileSize } from '../stampLayout.js';
|
|
2
2
|
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
|
|
3
3
|
import { hitShapeOutline } from '../shapeGeometry.js';
|
|
4
4
|
import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
|
|
@@ -132,8 +132,8 @@ const classifyGrab = (doc, id, world, zoom) => {
|
|
|
132
132
|
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
133
133
|
if (!m)
|
|
134
134
|
return 'move';
|
|
135
|
-
|
|
136
|
-
const half = ((
|
|
135
|
+
// Same footprint the tile is drawn at (smaller for unassociated inputs, #6).
|
|
136
|
+
const half = (stampTileSize(m) / 2 + HIT_PADDING) / zoom;
|
|
137
137
|
const onTile = Math.abs(world.x - m.anchor.x) <= half &&
|
|
138
138
|
Math.abs(world.y - m.anchor.y) <= half;
|
|
139
139
|
return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
1
|
+
import type { AnnotationShape, AnnotationTextDecoration } from '../../../types/annotation.js';
|
|
2
2
|
import type { Tool } from '../Tool.js';
|
|
3
3
|
export interface TextToolOptions {
|
|
4
|
+
id?: string;
|
|
4
5
|
color?: string;
|
|
5
6
|
fontSize?: number;
|
|
6
7
|
dash?: boolean;
|
|
8
|
+
decoration?: AnnotationTextDecoration;
|
|
7
9
|
autoSwitchToSelect?: boolean;
|
|
8
10
|
onPlaced?: (shape: AnnotationShape) => void;
|
|
9
11
|
onAutoSwitch?: (toToolId: string) => void;
|
|
@@ -2,6 +2,10 @@ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
|
2
2
|
import { DEFAULT_TEXT_FONT_SIZE, hitTestTextShape } from '../textGeometry.js';
|
|
3
3
|
let counter = 0;
|
|
4
4
|
const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
// Screen-px a press may travel and still count as a tap. Beyond this the
|
|
6
|
+
// gesture is a drag (e.g. an attempt to pan with the text tool active) and must
|
|
7
|
+
// NOT open the text sheet — the cause of the stray "weird popup" on screen.
|
|
8
|
+
const TAP_SLOP_PX = 10;
|
|
5
9
|
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
10
|
// Topmost text shape under a world point, for tap-to-edit.
|
|
7
11
|
const findTextShapeAt = (doc, world) => {
|
|
@@ -22,13 +26,29 @@ export const createTextTool = (options = {}) => {
|
|
|
22
26
|
const color = options.color ?? '#111827';
|
|
23
27
|
const fontSize = options.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
24
28
|
const dash = options.dash ?? false;
|
|
29
|
+
const decoration = options.decoration;
|
|
25
30
|
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
26
31
|
const selectToolId = options.selectToolId ?? 'select';
|
|
27
32
|
return {
|
|
28
|
-
id: 'text',
|
|
33
|
+
id: options.id ?? 'text',
|
|
29
34
|
label: 'Text',
|
|
30
35
|
cursor: 'text',
|
|
31
|
-
onPointerUp
|
|
36
|
+
// Arm the press so onPointerUp can tell a tap from a drag. The native tap
|
|
37
|
+
// gesture and the generic tool-pan both dispatch a down before the up.
|
|
38
|
+
onPointerDown(event) {
|
|
39
|
+
return { kind: 'text-armed', startScreen: event.screen };
|
|
40
|
+
},
|
|
41
|
+
onPointerUp(event, ctx, state) {
|
|
42
|
+
// A press that traveled beyond the tap slop is a drag, not a tap — never
|
|
43
|
+
// open the text sheet for it. (If the press wasn't armed, fall back to
|
|
44
|
+
// treating it as a tap so the tool still works.)
|
|
45
|
+
const armed = state;
|
|
46
|
+
if (armed?.kind === 'text-armed') {
|
|
47
|
+
const dx = event.screen.x - armed.startScreen.x;
|
|
48
|
+
const dy = event.screen.y - armed.startScreen.y;
|
|
49
|
+
if (dx * dx + dy * dy > TAP_SLOP_PX * TAP_SLOP_PX)
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
32
52
|
const existing = findTextShapeAt(ctx.document, event.world);
|
|
33
53
|
if (existing) {
|
|
34
54
|
void ctx
|
|
@@ -63,7 +83,12 @@ export const createTextTool = (options = {}) => {
|
|
|
63
83
|
geometry: { points: [anchor] },
|
|
64
84
|
// `...(dash && ...)` keeps the key absent (not `false`) for solid
|
|
65
85
|
// text, matching the stroke convention (absent === solid).
|
|
66
|
-
style: {
|
|
86
|
+
style: {
|
|
87
|
+
stroke: color,
|
|
88
|
+
fontSize,
|
|
89
|
+
...(dash && { dash: true }),
|
|
90
|
+
...(decoration && { textDecoration: decoration }),
|
|
91
|
+
},
|
|
67
92
|
text,
|
|
68
93
|
createdAt: Date.now(),
|
|
69
94
|
};
|
package/dist/exports.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export { createPanTool, type PanToolOptions, } from './annotation/canvas/tools/p
|
|
|
29
29
|
export { createTextTool, type TextToolOptions, } from './annotation/canvas/tools/textTool.js';
|
|
30
30
|
export { createShapeTool, buildShapeFromDrag, type ShapeToolOptions, } from './annotation/canvas/tools/shapeTool.js';
|
|
31
31
|
export { createPolygonTool, type PolygonToolOptions, } from './annotation/canvas/tools/polygonTool.js';
|
|
32
|
+
export { createMeasurementTool, type MeasurementToolOptions, type MeasurementToolPlacement, } from './annotation/canvas/tools/measurementTool.js';
|
|
32
33
|
export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeToolKind, } from './annotation/canvas/shapeGeometry.js';
|
|
33
34
|
export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, type ResizeGeometry, type TextBounds, } from './annotation/canvas/textGeometry.js';
|
|
34
35
|
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, type NormalizedRect, type RectCorner, } from './annotation/canvas/measurementGeometry.js';
|
package/dist/exports.js
CHANGED
|
@@ -29,6 +29,7 @@ export { createPanTool, } from './annotation/canvas/tools/panTool.js';
|
|
|
29
29
|
export { createTextTool, } from './annotation/canvas/tools/textTool.js';
|
|
30
30
|
export { createShapeTool, buildShapeFromDrag, } from './annotation/canvas/tools/shapeTool.js';
|
|
31
31
|
export { createPolygonTool, } from './annotation/canvas/tools/polygonTool.js';
|
|
32
|
+
export { createMeasurementTool, } from './annotation/canvas/tools/measurementTool.js';
|
|
32
33
|
export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './annotation/canvas/shapeGeometry.js';
|
|
33
34
|
export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, } from './annotation/canvas/textGeometry.js';
|
|
34
35
|
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
|
|
@@ -19,6 +19,7 @@ export interface AnnotationStroke {
|
|
|
19
19
|
createdAt: number;
|
|
20
20
|
}
|
|
21
21
|
export type AnnotationShapeKind = 'rect' | 'ellipse' | 'line' | 'arrow' | 'polygon' | 'text';
|
|
22
|
+
export type AnnotationTextDecoration = 'underline' | 'squiggle' | 'highlight';
|
|
22
23
|
export interface AnnotationShapeStyle {
|
|
23
24
|
stroke?: string;
|
|
24
25
|
fill?: string;
|
|
@@ -26,6 +27,7 @@ export interface AnnotationShapeStyle {
|
|
|
26
27
|
fontSize?: number;
|
|
27
28
|
fontFamily?: string;
|
|
28
29
|
dash?: boolean;
|
|
30
|
+
textDecoration?: AnnotationTextDecoration;
|
|
29
31
|
cap?: StrokeCap;
|
|
30
32
|
}
|
|
31
33
|
export interface AnnotationShape {
|