@reekon-tools/boldr-utils 1.27.0 → 1.28.0

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.
@@ -15,6 +15,7 @@ export interface AnnotationCanvasInnerProps {
15
15
  activeToolId: string;
16
16
  selection: Selection | null;
17
17
  onSelectionChange(selection: Selection | null): void;
18
+ onViewportChange?(viewport: ViewportState): void;
18
19
  measurements?: Measurement[];
19
20
  fallbackUnit?: Units;
20
21
  fractionalTolerance?: FractionalTolerance;
@@ -5,7 +5,6 @@ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeom
5
5
  import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
6
6
  import { visualShapeBounds } from './shapeGeometry.js';
7
7
  import { backgroundCropBars, backgroundLayerDocRect, backgroundLayersOf, isBackgroundLayerLocked, } from './backgroundLayers.js';
8
- import { YellowColors } from '../../theme/colors.js';
9
8
  import { BackgroundLayerElement } from './elements/BackgroundImageElement.js';
10
9
  import { PlaneElement, PLANE_CHROME_COLOR, PLANE_GRID_COLOR, PLANE_GRID_OPACITY, PLANE_GRID_WIDTH, PLANE_OUTLINE_WIDTH, } from './elements/PlaneElement.js';
11
10
  import { ShapeElement } from './elements/ShapeElement.js';
@@ -26,10 +25,6 @@ const SELECTION_STROKE = 1.5;
26
25
  // the target the dragged endpoint is currently locked onto. Orange so it reads
27
26
  // against both the blue selection chrome and the yellow lock accent.
28
27
  const SNAP_INDICATOR_COLOR = '#FF7A00';
29
- // Outline of a selected LOCKED background layer: the lock accent (the same
30
- // yellow the selected group-header border uses), distinct from the blue
31
- // manipulable-selection chrome — locked means "selected, but pinned".
32
- const BACKGROUND_LOCKED_COLOR = YellowColors.mainYellow;
33
28
  // Link badge on a LINKED background layer (layer.fileId — an expanded file
34
29
  // tile, see fileTileSwap.ts): a chain glyph in a white disc at the layer's
35
30
  // top-right, saying "this is a group file's image, not a plain background".
@@ -310,16 +305,24 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
310
305
  // its opposite corner) plus four edge crop bars (raster layers only —
311
306
  // dragging a bar windows the image non-destructively). Chrome tracks
312
307
  // the live drag/resize transform the same way the layer itself does
313
- // above. A LOCKED layer (backgroundLayerIsLocked) keeps the outline —
314
- // it is selected, and the host's chrome offers the lock toggle — but
315
- // recolored to the lock yellow and with no handles: it cannot scale
316
- // or crop, so a grabbable gizmo would lie. During a native crop-bar
308
+ // above. A LOCKED layer (backgroundLayerIsLocked) keeps the same blue
309
+ // outline — it is selected, and the host's chrome offers the lock
310
+ // toggle — but no handles or bars: it cannot move, scale or crop, so
311
+ // a grabbable gizmo would lie. During a native crop-bar
317
312
  // drag the chrome is just the LIVE outline — handles and bars would
318
313
  // sit on stale committed edges.
319
314
  const layer = backgroundLayersOf(effectiveCanvas.viewport).find((l) => l.id === selectedId);
320
315
  if (layer) {
316
+ // The outline is screen-constant (the handle ring width — 2px /
317
+ // zoom, a number on web and a derived value on native), not the
318
+ // doc-unit SELECTION_STROKE: a background is often huge in doc
319
+ // space and viewed zoomed out, where a doc-unit stroke thins to
320
+ // nothing — and for a LOCKED layer this border is the only
321
+ // selected indicator. Doc units only when the host passes no
322
+ // handle sizing (no drag-select tool).
323
+ const outlineWidth = handleRingWidth ?? SELECTION_STROKE;
321
324
  if (layer.id === bgCropId && liveBgCrop) {
322
- return (_jsx(Rect, { x: liveBgCrop.x, y: liveBgCrop.y, width: liveBgCrop.width, height: liveBgCrop.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }));
325
+ return (_jsx(Rect, { x: liveBgCrop.x, y: liveBgCrop.y, width: liveBgCrop.width, height: liveBgCrop.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: outlineWidth }));
323
326
  }
324
327
  const locked = isBackgroundLayerLocked(effectiveCanvas.viewport, layer.id);
325
328
  const r = backgroundLayerDocRect(layer);
@@ -331,7 +334,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
331
334
  { x: r.x, y: r.y + r.height },
332
335
  { x: r.x + r.width, y: r.y + r.height },
333
336
  ];
334
- return (_jsxs(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: [_jsx(Rect, { x: r.x, y: r.y, width: r.width, height: r.height, color: locked ? BACKGROUND_LOCKED_COLOR : SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }), !locked &&
337
+ return (_jsxs(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: [_jsx(Rect, { x: r.x, y: r.y, width: r.width, height: r.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: outlineWidth }), !locked &&
335
338
  layer.format !== 'svg' &&
336
339
  backgroundCropBars(layer).map((bar) => {
337
340
  const inset = Math.min(bar.width, bar.height) * 0.18;
@@ -87,7 +87,7 @@ export interface DragSelectionConfig {
87
87
  } | null;
88
88
  buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
89
89
  isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number, headerTileScaleFactor?: number, fileTileScaleFactor?: number): boolean;
90
- hitTestBackground?(doc: AnnotationCanvasState, world: Vec2): {
90
+ hitTestBackground?(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
91
91
  id: AnnotationElementId;
92
92
  locked: boolean;
93
93
  } | null;
@@ -488,7 +488,7 @@ const shapeCornerPatch = (doc, id, corner, delta) => {
488
488
  // DragSelectionConfig AND the web pointer handlers — one source of truth).
489
489
  // Backgrounds can cover the whole document, so they never join the general
490
490
  // hit-test: a tap selects one only when every stroke/shape/measurement
491
- // misses, and a drag translates one only when it is ALREADY selected — the
491
+ // misses AND it lands on the layer's perimeter (findBackgroundHit), and a drag translates one only when it is ALREADY selected — the
492
492
  // layout tool's two-step model, which keeps a full-bleed background from
493
493
  // hijacking pan/marquee gestures. A LOCKED layer (backgroundLayerIsLocked —
494
494
  // by default the first image, which IS the canvas) is selectable like any
@@ -502,16 +502,38 @@ const pointInBackgroundLayer = (layer, p) => {
502
502
  const r = backgroundLayerDocRect(layer);
503
503
  return (p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height);
504
504
  };
505
- // Topmost background layer under a world point (reverse array order — later
506
- // layers draw on top). Point-in-doc-rect, no padding: a background is a big
507
- // target already.
508
- const findBackgroundHit = (doc, world) => {
505
+ // Screen-px half-width of the edge band that selects an UNSELECTED background
506
+ // (converted to doc space via zoom). A zoomed-in background often fills the
507
+ // whole viewport, so a tap anywhere on it selecting the image made selection
508
+ // accidental — and a selected background's chrome gets in the way of placing
509
+ // more objects. Selecting now takes a deliberate tap on the image's perimeter:
510
+ // within this distance of the visible (cropped) rect's outline, inside or out.
511
+ // Once selected, the whole rect is live again (findSelectedBackgroundHit), so
512
+ // body drags and the tap-toggle deselect are unchanged.
513
+ const BACKGROUND_EDGE_GRAB_PX = 24;
514
+ // Topmost background layer whose perimeter band contains a world point
515
+ // (reverse array order — later layers draw on top). A point in a layer's
516
+ // interior (past the band) is occluded by that layer: it never falls through
517
+ // to the edge of a layer drawn beneath it, whose outline is hidden there. A
518
+ // layer smaller than two band-widths on screen is all band, so it stays
519
+ // selectable anywhere on it.
520
+ const findBackgroundHit = (doc, world, zoom) => {
521
+ const band = BACKGROUND_EDGE_GRAB_PX / zoom;
509
522
  const layers = backgroundLayersOf(doc.viewport);
510
523
  for (let i = layers.length - 1; i >= 0; i--) {
511
524
  const layer = layers[i];
512
- if (pointInBackgroundLayer(layer, world)) {
513
- return { id: layer.id, locked: backgroundLayerIsLocked(layer, i) };
514
- }
525
+ const r = backgroundLayerDocRect(layer);
526
+ const left = world.x - r.x;
527
+ const right = r.x + r.width - world.x;
528
+ const top = world.y - r.y;
529
+ const bottom = r.y + r.height - world.y;
530
+ // Outside the band-expanded rect: this layer is not under the point.
531
+ if (Math.min(left, right, top, bottom) < -band)
532
+ continue;
533
+ // Inside the band-inset rect: the layer's interior covers the point.
534
+ if (Math.min(left, right, top, bottom) > band)
535
+ return null;
536
+ return { id: layer.id, locked: backgroundLayerIsLocked(layer, i) };
515
537
  }
516
538
  return null;
517
539
  };
@@ -907,11 +929,14 @@ export const createSelectTool = (options = {}) => ({
907
929
  delta: { x: 0, y: 0 },
908
930
  };
909
931
  }
910
- // An unselected background selects. A free one consumes the gesture
932
+ // An unselected background selects — but only from its perimeter
933
+ // band (findBackgroundHit); its interior behaves like empty canvas,
934
+ // so a full-bleed image never gets selected by accident. A free one
935
+ // consumes the gesture
911
936
  // (no pan, no drag): translating it takes a second gesture once
912
937
  // selected, the layout tool's two-step model. A locked one is the
913
938
  // canvas: it selects AND the drag pans, as on empty canvas.
914
- const bg = findBackgroundHit(ctx.document, world);
939
+ const bg = findBackgroundHit(ctx.document, world, zoom);
915
940
  if (bg) {
916
941
  ctx.setSelection({ ids: [bg.id] });
917
942
  if (bg.locked && options.panOnEmptyDrag) {
@@ -14,6 +14,8 @@ export interface AnnotationCanvasHandle {
14
14
  redo(): void;
15
15
  canUndo(): boolean;
16
16
  canRedo(): boolean;
17
+ zoomBy(factor: number, focalScreen?: Vec2): void;
18
+ getZoom(): number;
17
19
  zoomToFit(opts?: {
18
20
  animated?: boolean;
19
21
  durationMs?: number;
@@ -72,6 +74,7 @@ export interface UseAnnotationCanvasStateProps {
72
74
  activeToolId: string;
73
75
  selection: Selection | null;
74
76
  onSelectionChange(selection: Selection | null): void;
77
+ onViewportChange?(viewport: ViewportState): void;
75
78
  measurements?: Measurement[];
76
79
  pickMeasurement?: () => Promise<MeasurementRef | null>;
77
80
  requestTextInput?: RequestTextInput;
@@ -83,7 +83,7 @@ const defaultLeaderLenDoc = (docWidth) => Math.min(800, Math.max(240, docWidth *
83
83
  // inners share this hook; each wraps it with platform-specific event
84
84
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
85
85
  export const useAnnotationCanvasState = (props) => {
86
- const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, snapToGeometry, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
86
+ const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, onViewportChange, measurements, pickMeasurement, requestTextInput, snapToGeometry, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
87
87
  const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
88
88
  const [toolState, setToolState] = useState(undefined);
89
89
  // Synchronous mirror for the DISPATCHERS. The native tap gesture
@@ -127,7 +127,17 @@ export const useAnnotationCanvasState = (props) => {
127
127
  // (Adding a background mid-session also fits it, so you see the whole image
128
128
  // you just dropped in.) Canvases that never gain a background keep the 1:1
129
129
  // default — for a screen-sized document that already frames it correctly.
130
- const didInitialFitRef = useRef(false);
130
+ //
131
+ // Pre-latched in two cases, both "someone already framed the view":
132
+ // - The host passed `initialViewport` — it owns framing (the paged PDF
133
+ // editor fits each page from the file record at mount, long before the
134
+ // page's annotation doc hydrates).
135
+ // - The user (or the host, through the handle) has already moved the
136
+ // viewport this mount. The doc can hydrate mid-gesture — a page turn's
137
+ // snapshot lands while the user is already wheel-zooming — and a late fit
138
+ // here would stomp that viewport, which reads as zoom pivoting around a
139
+ // broken focal point. Every pan/zoom/fit path below latches the ref.
140
+ const didInitialFitRef = useRef(!!initialViewport);
131
141
  useEffect(() => {
132
142
  if (didInitialFitRef.current)
133
143
  return;
@@ -257,9 +267,11 @@ export const useAnnotationCanvasState = (props) => {
257
267
  : Promise.resolve(null);
258
268
  },
259
269
  applyPan(deltaScreen) {
270
+ didInitialFitRef.current = true;
260
271
  setViewport((v) => panBy(v, deltaScreen));
261
272
  },
262
273
  applyZoom(focalScreen, nextZoom) {
274
+ didInitialFitRef.current = true;
263
275
  setViewport((v) => zoomAt(v, focalScreen, nextZoom, zoomLimitsRef.current));
264
276
  },
265
277
  }), [
@@ -369,18 +381,23 @@ export const useAnnotationCanvasState = (props) => {
369
381
  // eslint-disable-next-line react-hooks/exhaustive-deps
370
382
  }, [activeTool, ctx]);
371
383
  const pan = useCallback((deltaScreen) => {
384
+ didInitialFitRef.current = true;
372
385
  setViewport((v) => panBy(v, deltaScreen));
373
386
  }, []);
374
387
  const zoom = useCallback((focalScreen, nextZoom) => {
388
+ didInitialFitRef.current = true;
375
389
  setViewport((v) => zoomAt(v, focalScreen, nextZoom, zoomLimitsRef.current));
376
390
  }, []);
377
391
  // `v.zoom * factor` inside the updater, so a burst of events that React
378
392
  // batches into one commit composes instead of each recomputing the same
379
- // target from the same stale base. See `zoomBy` on the API type.
393
+ // target from the same stale base. See `zoomBy` on the API type. Clamped
394
+ // like the other zoom paths — an unclamped wheel burst could sail past the
395
+ // document floor/ceiling.
380
396
  const zoomBy = useCallback((focalScreen, factor) => {
381
397
  if (!Number.isFinite(factor) || factor <= 0)
382
398
  return;
383
- setViewport((v) => zoomAt(v, focalScreen, v.zoom * factor));
399
+ didInitialFitRef.current = true;
400
+ setViewport((v) => zoomAt(v, focalScreen, v.zoom * factor, zoomLimitsRef.current));
384
401
  }, []);
385
402
  // Imperative API mirror — set via prop so it survives WithSkiaWeb's lazy
386
403
  // boundary on web.
@@ -445,6 +462,12 @@ export const useAnnotationCanvasState = (props) => {
445
462
  return c.viewport.screenToWorld({ x: cx, y: cy });
446
463
  };
447
464
  imperativeRef.current = {
465
+ zoomBy(factor, focalScreen) {
466
+ zoomBy(focalScreen ?? { x: width / 2, y: height / 2 }, factor);
467
+ },
468
+ getZoom() {
469
+ return ctxRef.current.viewport.state.zoom;
470
+ },
448
471
  undo() {
449
472
  const entry = undoStackRef.current.pop();
450
473
  if (!entry)
@@ -466,6 +489,7 @@ export const useAnnotationCanvasState = (props) => {
466
489
  return redoStackRef.current.length > 0;
467
490
  },
468
491
  zoomToFit(opts) {
492
+ didInitialFitRef.current = true;
469
493
  // The document is read through ctxRef, not the effect closure: this
470
494
  // effect's deps deliberately exclude `canvas`, so the closed-over
471
495
  // binding can predate tiles that landed or moved since — and the fit
@@ -479,6 +503,7 @@ export const useAnnotationCanvasState = (props) => {
479
503
  }
480
504
  },
481
505
  resetView() {
506
+ didInitialFitRef.current = true;
482
507
  // "Reset view" frames the whole document/image again — the same fit the
483
508
  // canvas opens with — rather than snapping to a 1:1 top-left view, which
484
509
  // for a high-res background is the very crop this is meant to escape.
@@ -487,6 +512,7 @@ export const useAnnotationCanvasState = (props) => {
487
512
  fitContentToRect(rect, opts) {
488
513
  if (!(rect.width > 0) || !(rect.height > 0))
489
514
  return;
515
+ didInitialFitRef.current = true;
490
516
  const target = computeContentFitRect(ctxRef.current.document, rect);
491
517
  if (opts?.animated && animateViewport) {
492
518
  animateViewport(target, opts.durationMs ?? 450);
@@ -498,6 +524,7 @@ export const useAnnotationCanvasState = (props) => {
498
524
  fitDocRect(rect, opts) {
499
525
  if (!(width > 0) || !(height > 0))
500
526
  return;
527
+ didInitialFitRef.current = true;
501
528
  const limits = zoomLimitsRef.current;
502
529
  const target = fitDocRectViewport(rect, width, height, {
503
530
  paddingPx: opts?.paddingPx,
@@ -844,7 +871,16 @@ export const useAnnotationCanvasState = (props) => {
844
871
  canvas.viewport.height,
845
872
  width,
846
873
  height,
874
+ zoomBy,
847
875
  ]);
876
+ // Viewport subscription for hosts (zoom readouts and the like). A ref keeps
877
+ // the effect off the callback identity — hosts often pass a fresh closure
878
+ // every render.
879
+ const onViewportChangeRef = useRef(onViewportChange);
880
+ onViewportChangeRef.current = onViewportChange;
881
+ useEffect(() => {
882
+ onViewportChangeRef.current?.(viewport);
883
+ }, [viewport]);
848
884
  const worldTransform = useMemo(() => [
849
885
  { scale: viewport.zoom },
850
886
  { translateX: -viewport.pan.x },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",