@reekon-tools/boldr-utils 1.6.14 → 1.6.17

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.
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useFont } from '@shopify/react-native-skia';
3
- import { useCallback, useEffect, useRef, } from 'react';
2
+ import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
3
+ import { useCallback, useEffect, useMemo, useRef, } from 'react';
4
4
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
+ import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
5
6
  import { buildRemoveMeasurementOps } from './measurementGeometry.js';
6
7
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
7
- import { STAMP_TILE_SIZE } from './stampLayout.js';
8
+ import { stampTileSize } from './stampLayout.js';
8
9
  // Screen-px radius of a measurement-annotation endpoint handle (matches the
9
10
  // native HANDLE_RADIUS_PX). Divided by zoom for a constant on-screen size.
10
11
  const HANDLE_PX = 7;
@@ -17,6 +18,20 @@ export const AnnotationCanvasInner = (props) => {
17
18
  const allowMiddlePan = panTriggers.includes('middleMouse');
18
19
  const allowRightPan = panTriggers.includes('rightMouse');
19
20
  const valueFont = useFont(stampFontSource, stampValueFontSize);
21
+ // Typeface provider for text-shape Paragraphs. CanvasKit's registerFont
22
+ // rejects the raw pointer valueFont.getTypeface() returns, and useFonts can't
23
+ // take a plain URL (its resolveAsset expects a bundled module). So load the
24
+ // typeface via useTypeface — which fetches the URL and builds a *managed*
25
+ // typeface (MakeFreeTypeFaceFromData) — then register that. Null until the
26
+ // async load resolves; ShapeElement renders text only once it's ready.
27
+ const textTypeface = useTypeface(stampFontSource);
28
+ const textFontMgr = useMemo(() => {
29
+ if (!textTypeface)
30
+ return null;
31
+ const provider = Skia.TypefaceFontProvider.Make();
32
+ provider.registerFont(textTypeface, TEXT_FONT_FAMILY);
33
+ return provider;
34
+ }, [textTypeface]);
20
35
  const state = useAnnotationCanvasState(props);
21
36
  const containerRef = useRef(null);
22
37
  const panGestureRef = useRef(null);
@@ -179,6 +194,7 @@ export const AnnotationCanvasInner = (props) => {
179
194
  worldTransform: state.worldTransform,
180
195
  resolveImageUrl,
181
196
  valueFont,
197
+ textFontMgr,
182
198
  penDrawingStroke: state.penDrawingStroke,
183
199
  // Endpoint handles on the selected line annotation. Web drives endpoint
184
200
  // drag through selectTool's pointer handlers (preview patches update the
@@ -196,7 +212,7 @@ export const AnnotationCanvasInner = (props) => {
196
212
  inset: 0,
197
213
  pointerEvents: 'none',
198
214
  }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
199
- const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
215
+ const size = stampTileSize(placed);
200
216
  const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
201
217
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
202
218
  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,4 +1,4 @@
1
- import { type SkFont, type SkPath, type Transforms3d } from '@shopify/react-native-skia';
1
+ import { type SkFont, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
4
4
  type AnimatedPoint = {
@@ -22,6 +22,7 @@ export interface AnnotationCanvasSkiaProps {
22
22
  };
23
23
  resolveImageUrl?: (storagePath: string) => Promise<string>;
24
24
  valueFont: SkFont | null;
25
+ textFontMgr?: SkTypefaceFontProvider | null;
25
26
  penDrawingStroke: AnnotationStroke | null;
26
27
  livePreview?: {
27
28
  path: SkPath | {
@@ -72,5 +73,5 @@ export interface AnnotationCanvasSkiaProps {
72
73
  };
73
74
  customPreview?: ReactNode;
74
75
  }
75
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
76
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
76
77
  export {};
@@ -86,7 +86,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
86
86
  // since the function-call pattern works identically on native we use it
87
87
  // in both Inners for consistency. Don't add hooks here; this is a plain
88
88
  // JSX-returning helper, not a component.
89
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
89
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
90
90
  // Rectangle annotation: a stroked border whose center carries the
91
91
  // tile. A corner drag renders from the live geometry (outside the
92
92
  // group translate, like an endpoint drag); otherwise the committed
@@ -1,7 +1,9 @@
1
- import { type SkFont } from '@shopify/react-native-skia';
1
+ import { type SkFont, type SkTypefaceFontProvider } from '@shopify/react-native-skia';
2
2
  import type { AnnotationShape } from '../../../types/annotation.js';
3
+ export declare const TEXT_FONT_FAMILY = "annotation-text";
3
4
  export interface ShapeElementProps {
4
5
  shape: AnnotationShape;
5
6
  font?: SkFont | null;
7
+ textFontMgr?: SkTypefaceFontProvider | null;
6
8
  }
7
- export declare const ShapeElement: import("react").MemoExoticComponent<({ shape, font }: ShapeElementProps) => import("react/jsx-runtime").JSX.Element | null>;
9
+ export declare const ShapeElement: import("react").MemoExoticComponent<({ shape, font, textFontMgr }: ShapeElementProps) => import("react/jsx-runtime").JSX.Element | null>;
@@ -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
7
+ // TypefaceFontProvider that backs the text Paragraph. Exported so the web
8
+ // canvas can register its font under the same name via useFonts (see below).
9
+ export 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)
@@ -21,7 +36,7 @@ const polygonPath = (points, closed) => {
21
36
  };
22
37
  // Memoized — see StrokeElement. Unchanged shapes keep their identity across
23
38
  // applyPatch, so only the edited shape (and the shared font) re-render.
24
- export const ShapeElement = memo(({ shape, font }) => {
39
+ export const ShapeElement = memo(({ shape, font, textFontMgr }) => {
25
40
  const { kind, geometry, style, text } = shape;
26
41
  const stroke = style.stroke ?? '#000000';
27
42
  const fill = style.fill;
@@ -58,6 +73,78 @@ 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)
82
+ return null;
83
+ // Resolve the typeface provider that backs the Paragraph. Web threads one
84
+ // pre-built via useFonts (managed typeface); native builds one here from
85
+ // the loaded SkFont. `textFontMgr === undefined` distinguishes "native, no
86
+ // manager threaded" from "web, manager still loading (null)" — in the
87
+ // latter we render nothing rather than fall back to the SkFont path, which
88
+ // throws on CanvasKit (raw pointer to registerFont).
89
+ let provider;
90
+ if (textFontMgr !== undefined) {
91
+ provider = textFontMgr;
92
+ }
93
+ else if (font) {
94
+ const typeface = font.getTypeface();
95
+ if (!typeface)
96
+ return null;
97
+ provider = Skia.TypefaceFontProvider.Make();
98
+ provider.registerFont(typeface, TEXT_FONT_FAMILY);
99
+ }
100
+ else {
101
+ provider = null;
102
+ }
103
+ if (!provider)
104
+ return null;
105
+ const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
106
+ const decoration = style.textDecoration;
107
+ const colorStr = style.stroke ?? '#000000';
108
+ const textStyle = {
109
+ color: Skia.Color(decoration === 'highlight' ? highlightTextColor(colorStr) : colorStr),
110
+ fontFamilies: [TEXT_FONT_FAMILY],
111
+ fontSize,
112
+ // Match textGeometry's line-height factor so the (Skia-free) selection
113
+ // box keeps bracketing the rendered glyphs vertically.
114
+ heightMultiplier: TEXT_LINE_HEIGHT_FACTOR,
115
+ };
116
+ if (decoration === 'underline' || decoration === 'squiggle') {
117
+ textStyle.decoration = TextDecoration.Underline;
118
+ textStyle.decorationStyle =
119
+ decoration === 'squiggle'
120
+ ? TextDecorationStyle.Wavy
121
+ : TextDecorationStyle.Solid;
122
+ textStyle.decorationColor = Skia.Color(colorStr);
123
+ }
124
+ else if (decoration === 'highlight') {
125
+ textStyle.backgroundColor = Skia.Color(colorStr);
126
+ }
127
+ const builder = Skia.ParagraphBuilder.Make({
128
+ textStyle,
129
+ strutStyle: {
130
+ strutEnabled: true,
131
+ fontSize,
132
+ heightMultiplier: TEXT_LINE_HEIGHT_FACTOR,
133
+ forceStrutHeight: true,
134
+ },
135
+ }, provider);
136
+ builder.pushStyle(textStyle);
137
+ builder.addText(text);
138
+ return builder.build();
139
+ }, [
140
+ kind,
141
+ text,
142
+ font,
143
+ textFontMgr,
144
+ style.fontSize,
145
+ style.textDecoration,
146
+ style.stroke,
147
+ ]);
61
148
  const dashEffect = style.dash ? (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) : null;
62
149
  switch (kind) {
63
150
  case 'rect': {
@@ -92,32 +179,14 @@ export const ShapeElement = memo(({ shape, font }) => {
92
179
  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
180
  }
94
181
  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.
182
+ // `origin` is the TOP-LEFT of the text block; textGeometry derives the
183
+ // selection box from the same anchor + line-height factor used to build
184
+ // the paragraph, so chrome brackets the glyphs. fontSize is set directly
185
+ // on the paragraph (world units), so no manual glyph scaling is needed.
101
186
  const [origin] = geometry.points;
102
- if (!origin || !text)
103
- return null;
104
- if (!font)
187
+ if (!origin || !textParagraph)
105
188
  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))) }));
189
+ return (_jsx(Paragraph, { paragraph: textParagraph, x: origin.x, y: origin.y, width: TEXT_LAYOUT_WIDTH }));
121
190
  }
122
191
  }
123
192
  });
@@ -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.17",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",