@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.
@@ -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 { STAMP_TILE_SIZE } from './stampLayout.js';
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 = STAMP_TILE_SIZE * (placed.scale ?? 1);
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 { STAMP_TILE_SIZE } from './stampLayout.js';
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 = STAMP_TILE_SIZE * (placed.scale ?? 1);
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, Group, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
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, TEXT_BASELINE_FACTOR, TEXT_LINE_HEIGHT_FACTOR, } from '../textGeometry.js';
6
- // Outline width of dash-styled text, as a fraction of the base font size.
7
- // Drawn pre-scale (inside the fontSize/baseSize Group), so the outline — and
8
- // the dash pattern derived from it — scales with the glyphs.
9
- const TEXT_OUTLINE_WIDTH_FACTOR = 1 / 16;
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 (textGeometry derives
96
- // bounds/hit boxes from the same anchor + factors, so chrome and glyphs
97
- // agree). The shared font is loaded at one fixed size; style.fontSize is
98
- // honored by scaling the glyph outlines about the anchor Skia text is
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 || !text)
103
- return null;
104
- if (!font)
162
+ if (!origin || !textParagraph)
105
163
  return null;
106
- const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
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 the stampLayout
3
- // constant) so it's safe in tools, hit-tests, and unit tests.
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 { STAMP_TILE_SIZE } from './stampLayout.js';
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
- const scale = m.scale ?? 1;
118
- const half = ((STAMP_TILE_SIZE * scale) / 2 + STAMP_HIT_PADDING) / zoom;
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 constant for the placed measurement stamp. Lives in a
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 @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.
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 { STAMP_TILE_SIZE } from '../stampLayout.js';
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
- const scale = m.scale ?? 1;
136
- const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
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(event, ctx) {
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: { stroke: color, fontSize, ...(dash && { dash: true }) },
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.14",
3
+ "version": "1.6.15",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",