@reekon-tools/boldr-utils 1.6.20 → 1.6.24

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,9 +1,21 @@
1
1
  import { stampTileSize } from '../stampLayout.js';
2
2
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
- import { hitShapeOutline } from '../shapeGeometry.js';
3
+ import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
4
4
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
5
- import { editTextShape, findTextShapeAt } from './textEditing.js';
5
+ import { editTextShape } from './textEditing.js';
6
6
  const HIT_PADDING = 6;
7
+ // Whether an element id refers to a text shape (findHit reports text, lines,
8
+ // rects and ellipses all as kind 'shape'; only text supports tap-to-edit).
9
+ const isTextShape = (doc, id) => doc.shapes.some((s) => s.id === id && s.kind === 'text');
10
+ // Re-tap-to-edit for text. The pointer-down records a text shape's id here only
11
+ // when that shape was *already* selected before this gesture; a release with no
12
+ // movement then re-opens its editor. Held at module scope (not in ToolState)
13
+ // because the native tap gesture synthesizes pointer-down and pointer-up in a
14
+ // single React tick — a ToolState set on down isn't visible to up, but this is.
15
+ // Only one select gesture is ever in flight, so a shared cell is safe. A first
16
+ // tap (shape not yet selected) leaves this null, so it only selects; the second
17
+ // tap edits. Cleared on drag, release, and cancel.
18
+ let pendingTextEditId = null;
7
19
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
8
20
  // override via `hitTest` for more precision later.
9
21
  const hitStroke = (stroke, p) => {
@@ -303,6 +315,105 @@ const rectCornerPatch = (doc, id, corner, delta) => {
303
315
  ],
304
316
  };
305
317
  };
318
+ // --- Geometric-shape corner resize (rect/ellipse/polygon). The shape twin of
319
+ // the rectangle-annotation corner drag above: drag one bounding-box corner to
320
+ // scale length and width about the opposite (fixed) corner. Shared by the
321
+ // native UI-thread drag via DragSelectionConfig AND the web pointer handlers. ---
322
+ // Doc-space floor on a resized shape's width/height. Keeps a corner drag from
323
+ // collapsing a shape to a line (and from crossing the fixed corner, which would
324
+ // mirror a triangle), so the scale stays positive on both axes. The native
325
+ // shapeResizeScale worklet inlines the same value — keep them in sync.
326
+ const MIN_SHAPE_EXTENT = 1;
327
+ // Which shapes get the corner-scale handles: rect (two-corner geometry, scaled
328
+ // exactly like the rectangle annotation), polygon/triangle (all points scaled
329
+ // about the fixed corner) and ellipse. Lines/arrows resize via their endpoint
330
+ // handles and text via its own font-size handle, so both are excluded here.
331
+ const isCornerResizableShape = (s) => (s.kind === 'rect' || s.kind === 'ellipse' || s.kind === 'polygon') &&
332
+ s.geometry.points.length >= 2;
333
+ // The shape's visual bounds expressed as the {a,b} rect the rectCornerPoint/
334
+ // oppositeRectCorner helpers consume (so shapes reuse the exact corner math the
335
+ // rectangle annotation already uses). Uses visualShapeBounds, so the corner
336
+ // handles/hit-test sit on the same box the selection outline draws — for an
337
+ // ellipse that's the circle's bounding square, not the raw drag rect.
338
+ const shapeCornerRect = (s) => {
339
+ const vb = visualShapeBounds(s);
340
+ if (!vb)
341
+ return null;
342
+ return { a: { x: vb.minX, y: vb.minY }, b: { x: vb.maxX, y: vb.maxY } };
343
+ };
344
+ // Which bounding-box corner of a (selected) resizable shape is under `world`,
345
+ // or null. Mirrors findRectCornerHit but reads the box from the shape's points.
346
+ const findShapeCornerHit = (doc, id, world, zoom) => {
347
+ const s = doc.shapes.find((x) => x.id === id);
348
+ if (!s || !isCornerResizableShape(s))
349
+ return null;
350
+ const rect = shapeCornerRect(s);
351
+ if (!rect)
352
+ return null;
353
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
354
+ let best = null;
355
+ for (const corner of ['tl', 'tr', 'bl', 'br']) {
356
+ const p = rectCornerPoint(rect, corner);
357
+ const d = (world.x - p.x) ** 2 + (world.y - p.y) ** 2;
358
+ if (d <= r2 && (!best || d < best.d))
359
+ best = { corner, d };
360
+ }
361
+ if (!best)
362
+ return null;
363
+ return {
364
+ corner: best.corner,
365
+ moving: rectCornerPoint(rect, best.corner),
366
+ fixed: rectCornerPoint(rect, oppositeRectCorner(best.corner)),
367
+ };
368
+ };
369
+ // Scale every geometry point about the fixed (opposite) corner so the grabbed
370
+ // corner follows the drag. The moving corner is clamped so it can't cross or
371
+ // collapse onto the fixed corner (min extent on each axis), keeping the scale
372
+ // positive — no flip, no zero-size shape.
373
+ //
374
+ // rect/polygon scale length and width INDEPENDENTLY (a two-point rect's grabbed
375
+ // corner tracks the finger; a polygon's whole outline scales). An ellipse
376
+ // renders as a circle (radius = half its larger extent), so it scales
377
+ // UNIFORMLY off the diagonal instead — otherwise a non-uniform drag would
378
+ // preview as an oval and then snap back to a circle on commit.
379
+ const shapeCornerPatch = (doc, id, corner, delta) => {
380
+ const s = doc.shapes.find((x) => x.id === id);
381
+ if (!s || !isCornerResizableShape(s))
382
+ return null;
383
+ const rect = shapeCornerRect(s);
384
+ if (!rect)
385
+ return null;
386
+ const fixed = rectCornerPoint(rect, oppositeRectCorner(corner));
387
+ const moving = rectCornerPoint(rect, corner);
388
+ const denomX = moving.x - fixed.x;
389
+ const denomY = moving.y - fixed.y;
390
+ // Clamp the dragged offset to the same side of the fixed corner, magnitude
391
+ // >= MIN_SHAPE_EXTENT, so the scale factor never flips sign or hits zero.
392
+ const clampOffset = (offset, sign) => sign >= 0
393
+ ? Math.max(MIN_SHAPE_EXTENT, offset)
394
+ : Math.min(-MIN_SHAPE_EXTENT, offset);
395
+ const offX = clampOffset(moving.x + delta.x - fixed.x, denomX >= 0 ? 1 : -1);
396
+ const offY = clampOffset(moving.y + delta.y - fixed.y, denomY >= 0 ? 1 : -1);
397
+ let sx = denomX !== 0 ? offX / denomX : 1;
398
+ let sy = denomY !== 0 ? offY / denomY : 1;
399
+ if (s.kind === 'ellipse') {
400
+ // Uniform: ratio of the new corner-distance to the old, so the circle
401
+ // grows/shrinks about the fixed corner without distorting.
402
+ const oldDiag = Math.hypot(denomX, denomY);
403
+ const s0 = oldDiag !== 0 ? Math.hypot(offX, offY) / oldDiag : 1;
404
+ sx = s0;
405
+ sy = s0;
406
+ }
407
+ const points = s.geometry.points.map((p) => ({
408
+ x: fixed.x + (p.x - fixed.x) * sx,
409
+ y: fixed.y + (p.y - fixed.y) * sy,
410
+ }));
411
+ return {
412
+ ops: [
413
+ { op: 'updateShape', id, patch: { geometry: { ...s.geometry, points } } },
414
+ ],
415
+ };
416
+ };
306
417
  // --- Text-shape resize (corner-scale about the top-left anchor; shared by the
307
418
  // native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
308
419
  // Resize geometry when the (selected) text shape's corner handle is under
@@ -355,6 +466,9 @@ const dragPatch = (s, doc, delta, zoom) => {
355
466
  if (s.mode === 'rect-corner' && s.corner) {
356
467
  return rectCornerPatch(doc, s.id, s.corner, delta);
357
468
  }
469
+ if (s.mode === 'shape-corner' && s.corner) {
470
+ return shapeCornerPatch(doc, s.id, s.corner, delta);
471
+ }
358
472
  const op = translatePatch(s.elementKind, s.id, doc, delta);
359
473
  return op ? { ops: [op] } : null;
360
474
  };
@@ -382,6 +496,8 @@ export const createSelectTool = () => ({
382
496
  buildResizePatch: resizePatch,
383
497
  hitTestRectCorner: findRectCornerHit,
384
498
  buildRectCornerPatch: rectCornerPatch,
499
+ hitTestShapeCorner: findShapeCornerHit,
500
+ buildShapeCornerPatch: shapeCornerPatch,
385
501
  },
386
502
  // Web pointer path. Mirrors the native UI-thread drag using the same shared
387
503
  // helpers: an endpoint handle on the selected annotation resizes the line;
@@ -389,6 +505,10 @@ export const createSelectTool = () => ({
389
505
  onPointerDown(event, ctx) {
390
506
  const { world } = event;
391
507
  const zoom = ctx.viewport.state.zoom;
508
+ // Reset the re-tap-to-edit latch; only a body grab of an already-selected
509
+ // text shape (below) re-arms it. Grabbing a handle or empty canvas leaves
510
+ // it cleared, so neither can leak an edit into the next release.
511
+ pendingTextEditId = null;
392
512
  // Endpoint/resize handles show only on the selected element — check first,
393
513
  // UNLESS the grab is on that element's tile: the tile is the move/slide
394
514
  // affordance and must win over a handle sitting under it, so a selected
@@ -432,6 +552,19 @@ export const createSelectTool = () => ({
432
552
  delta: { x: 0, y: 0 },
433
553
  };
434
554
  }
555
+ const shapeCorner = findShapeCornerHit(ctx.document, selId, world, zoom);
556
+ if (shapeCorner) {
557
+ ctx.setSelection({ ids: [selId] });
558
+ return {
559
+ kind: 'dragging',
560
+ id: selId,
561
+ elementKind: 'shape',
562
+ mode: 'shape-corner',
563
+ corner: shapeCorner.corner,
564
+ start: world,
565
+ delta: { x: 0, y: 0 },
566
+ };
567
+ }
435
568
  const rectCorner = findRectCornerHit(ctx.document, selId, world, zoom);
436
569
  if (rectCorner) {
437
570
  ctx.setSelection({ ids: [selId] });
@@ -451,6 +584,15 @@ export const createSelectTool = () => ({
451
584
  ctx.setSelection(null);
452
585
  return { kind: 'idle' };
453
586
  }
587
+ // Re-tapping an already-selected text shape (without dragging) opens its
588
+ // editor on release — see onPointerUp. `selId` is the selection from before
589
+ // this down, so a first tap only selects; the second tap edits.
590
+ pendingTextEditId =
591
+ hit.kind === 'shape' &&
592
+ hit.id === selId &&
593
+ isTextShape(ctx.document, hit.id)
594
+ ? hit.id
595
+ : null;
454
596
  ctx.setSelection({ ids: [hit.id] });
455
597
  const mode = hit.kind === 'measurement' &&
456
598
  classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
@@ -474,32 +616,39 @@ export const createSelectTool = () => ({
474
616
  x: event.world.x - s.start.x,
475
617
  y: event.world.y - s.start.y,
476
618
  };
619
+ // Any real movement turns this into a drag, not a re-tap — disarm the edit.
620
+ if (delta.x !== 0 || delta.y !== 0)
621
+ pendingTextEditId = null;
477
622
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
478
623
  if (patch)
479
624
  ctx.preview(patch);
480
625
  return { ...s, delta };
481
626
  },
482
627
  onPointerUp(_event, ctx, state) {
628
+ const editId = pendingTextEditId;
629
+ pendingTextEditId = null;
483
630
  const s = state;
484
- if (s?.kind !== 'dragging')
631
+ // A moved selection commits its drag and is never a tap-to-edit.
632
+ if (s?.kind === 'dragging' && (s.delta.x !== 0 || s.delta.y !== 0)) {
633
+ const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
634
+ if (patch)
635
+ ctx.commit(patch);
485
636
  return;
486
- if (s.delta.x === 0 && s.delta.y === 0)
487
- return;
488
- const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
489
- if (patch)
490
- ctx.commit(patch);
637
+ }
638
+ // No movement: re-tapping an already-selected text shape re-opens its
639
+ // editor (the same edit flow as tapping it with the text tool, via the
640
+ // shared editTextShape). `editId` was latched on the down, so this survives
641
+ // the native tap's synchronous down+up where `state` cannot.
642
+ if (editId) {
643
+ const shape = ctx.document.shapes.find((sh) => sh.id === editId);
644
+ if (shape && shape.kind === 'text')
645
+ editTextShape(ctx, shape);
646
+ }
491
647
  },
492
648
  onCancel(_state, ctx) {
649
+ pendingTextEditId = null;
493
650
  ctx.preview({ ops: [] });
494
651
  },
495
- // Long-pressing a placed text shape re-opens the editor (the same edit flow
496
- // as tapping it with the text tool, via the shared editTextShape). A hold on
497
- // any other element — or empty canvas — is ignored.
498
- onLongPress(event, ctx) {
499
- const shape = findTextShapeAt(ctx.document, event.world);
500
- if (shape)
501
- editTextShape(ctx, shape);
502
- },
503
652
  hitTest(element, p) {
504
653
  if (element.kind === 'measurement')
505
654
  return hitPlacedMeasurement(element, p);
@@ -8,6 +8,7 @@ export interface ShapeToolOptions {
8
8
  color?: string;
9
9
  width?: number;
10
10
  cap?: StrokeCap;
11
+ startCap?: StrokeCap;
11
12
  dash?: boolean;
12
13
  minDragPx?: number;
13
14
  }
@@ -18,6 +19,7 @@ export declare const buildShapeFromDrag: (opts: {
18
19
  color: string;
19
20
  width: number;
20
21
  cap?: StrokeCap;
22
+ startCap?: StrokeCap;
21
23
  dash?: boolean;
22
24
  layerId: string;
23
25
  id?: string;
@@ -24,10 +24,12 @@ export const buildShapeFromDrag = (opts) => ({
24
24
  strokeWidth: opts.width,
25
25
  ...(opts.dash && { dash: true }),
26
26
  // Caps only mean something on an open line; 'round' is the implicit
27
- // default so it stays un-persisted.
27
+ // default so it stays un-persisted. Only 'arrow' matters for the start.
28
28
  ...(opts.kind === 'line' &&
29
29
  opts.cap &&
30
30
  opts.cap !== 'round' && { cap: opts.cap }),
31
+ ...(opts.kind === 'line' &&
32
+ opts.startCap === 'arrow' && { startCap: 'arrow' }),
31
33
  },
32
34
  createdAt: Date.now(),
33
35
  });
@@ -42,6 +44,7 @@ export const createShapeTool = (options = {}) => {
42
44
  const color = options.color ?? '#111827';
43
45
  const width = options.width ?? 2;
44
46
  const cap = options.cap;
47
+ const startCap = options.startCap;
45
48
  const dash = options.dash ?? false;
46
49
  const minDragPx = options.minDragPx ?? 4;
47
50
  return {
@@ -49,7 +52,14 @@ export const createShapeTool = (options = {}) => {
49
52
  label: options.label ?? DEFAULT_LABELS[kind],
50
53
  cursor: 'crosshair',
51
54
  // Drives UI-thread rubber-banding on native (see ShapeDrawConfig).
52
- shapeDraw: { kind, color, width, ...(cap && { cap }), dash },
55
+ shapeDraw: {
56
+ kind,
57
+ color,
58
+ width,
59
+ ...(cap && { cap }),
60
+ ...(startCap === 'arrow' && { startCap: 'arrow' }),
61
+ dash,
62
+ },
53
63
  onPointerDown(event, ctx) {
54
64
  return {
55
65
  kind: 'shape-drawing',
@@ -60,6 +70,7 @@ export const createShapeTool = (options = {}) => {
60
70
  color,
61
71
  width,
62
72
  cap,
73
+ startCap,
63
74
  dash,
64
75
  layerId: firstLayerId(ctx.document),
65
76
  }),
@@ -1,7 +1,7 @@
1
1
  import { hitTestTextShape } from '../textGeometry.js';
2
2
  // Topmost text shape under a world point (z-order, top first), or null. Shared
3
- // by the text tool (tap-to-edit) and the select tool (long-press-to-edit) so a
4
- // press resolves to the same element from either tool.
3
+ // by the text tool (tap-to-edit) and the select tool (tap-a-selected-shape-to-
4
+ // edit) so a press resolves to the same element from either tool.
5
5
  export const findTextShapeAt = (doc, world) => {
6
6
  for (let i = doc.shapes.length - 1; i >= 0; i--) {
7
7
  const s = doc.shapes[i];
@@ -16,6 +16,14 @@ export interface AnnotationCanvasHandle {
16
16
  }): void;
17
17
  setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
18
18
  associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
19
+ bindColumn(id: AnnotationElementId, binding: {
20
+ groupId: string;
21
+ columnId: string;
22
+ }): void;
23
+ placeColumnTileAtCenter(binding: {
24
+ groupId: string;
25
+ columnId: string;
26
+ }): AnnotationElementId;
19
27
  deleteSelected(): void;
20
28
  }
21
29
  export interface UseAnnotationCanvasStateProps {
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
3
  import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
4
  import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
- import { viewportTileScale } from './stampLayout.js';
6
5
  // Platform-agnostic state machine for the annotation canvas. Web and native
7
6
  // inners share this hook; each wraps it with platform-specific event
8
7
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
@@ -22,11 +21,13 @@ export const useAnnotationCanvasState = (props) => {
22
21
  return map;
23
22
  }, [measurements]);
24
23
  const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
25
- // How large the document renders when fit to this canvas, as a tile-footprint
26
- // multiplier (zoom-independent). Keeps tiles a consistent fraction of the
27
- // drawing on a phone vs a desktop pane. Used by the overlays (drawn size) and
28
- // the tools (hit box) so both agree.
29
- const tileViewportScale = useMemo(() => viewportTileScale(width, height, canvas.viewport.width, canvas.viewport.height), [width, height, canvas.viewport.width, canvas.viewport.height]);
24
+ // Tiles are sized independently of the canvas: their footprint is base ×
25
+ // per-tile scale × the document-wide `tileScaleFactor` ("Tile size" slider),
26
+ // never the canvas pixel size so resizing the pane no longer rescales tiles.
27
+ // Retained as a (constant 1) value because the overlays (drawn size) and tools
28
+ // (hit box) thread it; keeping them in lockstep at 1 means both still agree.
29
+ // (See stampLayout.ts for why the old per-canvas multiplier was retired.)
30
+ const tileViewportScale = 1;
30
31
  const ctx = useMemo(() => ({
31
32
  document: canvas,
32
33
  selection,
@@ -317,6 +318,39 @@ export const useAnnotationCanvasState = (props) => {
317
318
  ],
318
319
  });
319
320
  },
321
+ bindColumn(id, binding) {
322
+ const c = ctxRef.current;
323
+ c.commit({
324
+ ops: [
325
+ {
326
+ op: 'updateMeasurement',
327
+ id,
328
+ patch: { groupId: binding.groupId, columnId: binding.columnId },
329
+ },
330
+ ],
331
+ });
332
+ },
333
+ placeColumnTileAtCenter(binding) {
334
+ const c = ctxRef.current;
335
+ const anchor = c.viewport.screenToWorld({
336
+ x: width / 2,
337
+ y: height / 2,
338
+ });
339
+ const placed = {
340
+ id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
341
+ layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
342
+ groupId: binding.groupId,
343
+ columnId: binding.columnId,
344
+ anchor,
345
+ showLabel: true,
346
+ showValue: true,
347
+ createdAt: Date.now(),
348
+ };
349
+ c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
350
+ // Select it so the consumer can immediately move it / open its editor.
351
+ c.setSelection({ ids: [placed.id] });
352
+ return placed.id;
353
+ },
320
354
  deleteSelected() {
321
355
  const c = ctxRef.current;
322
356
  const ids = c.selection?.ids;
@@ -13,6 +13,7 @@ export interface AnnotationStroke {
13
13
  color: string;
14
14
  width: number;
15
15
  cap?: StrokeCap;
16
+ startCap?: StrokeCap;
16
17
  dash?: boolean;
17
18
  points: number[];
18
19
  pressure?: number[];
@@ -29,6 +30,7 @@ export interface AnnotationShapeStyle {
29
30
  dash?: boolean;
30
31
  textDecoration?: AnnotationTextDecoration;
31
32
  cap?: StrokeCap;
33
+ startCap?: StrokeCap;
32
34
  }
33
35
  export interface AnnotationShape {
34
36
  id: AnnotationElementId;
@@ -50,6 +52,7 @@ export interface PlacedMeasurementRef {
50
52
  measurementPath?: string;
51
53
  measurementId?: string;
52
54
  groupId?: string;
55
+ columnId?: string;
53
56
  anchor: Vec2;
54
57
  placement?: MeasurementPlacement;
55
58
  line?: {
@@ -64,6 +67,7 @@ export interface PlacedMeasurementRef {
64
67
  lineColor?: string;
65
68
  lineWidth?: number;
66
69
  lineCap?: StrokeCap;
70
+ lineStartCap?: StrokeCap;
67
71
  lineDash?: boolean;
68
72
  leader?: {
69
73
  from: Vec2;
@@ -12,8 +12,14 @@ export const createEmptyCanvasState = (viewport) => ({
12
12
  viewport: {
13
13
  width: viewport?.width ?? 1000,
14
14
  height: viewport?.height ?? 1000,
15
- backgroundImage: viewport?.backgroundImage,
16
- backgroundFit: viewport?.backgroundFit,
15
+ // Only include the optional keys when defined — emitting explicit
16
+ // `undefined` values breaks Firestore writes (RN rejects undefined fields).
17
+ ...(viewport?.backgroundImage !== undefined
18
+ ? { backgroundImage: viewport.backgroundImage }
19
+ : {}),
20
+ ...(viewport?.backgroundFit !== undefined
21
+ ? { backgroundFit: viewport.backgroundFit }
22
+ : {}),
17
23
  },
18
24
  strokes: [],
19
25
  shapes: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.20",
3
+ "version": "1.6.24",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",