@reekon-tools/boldr-utils 1.6.34 → 1.6.36

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.
@@ -5,5 +5,7 @@ export declare const TILE_SCALE_MIN = 0.4;
5
5
  export declare const TILE_SCALE_MAX = 2;
6
6
  export declare const resolveTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile">, platform: TileScalePlatform) => number;
7
7
  export declare const clampTileScale: (v: number) => number;
8
+ export declare const tileDocSizeForFactor: (factor: number, zoom: number) => number;
9
+ export declare const tileScaleFromDocSize: (docSize: number, zoom: number) => number;
8
10
  export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
9
11
  export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
@@ -37,6 +37,24 @@ export const resolveTileScaleFactor = (canvas, platform) => (platform === 'mobil
37
37
  // Clamp a tile-scale-factor candidate to the supported range. The single guard
38
38
  // for the value before it lands in the document (slider input, restored docs).
39
39
  export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
40
+ // --- Cross-platform initial-size calibration ---------------------------------
41
+ // The platform knobs are screen-space multipliers, so the same value covers a
42
+ // very different share of the DRAWING on a phone than on a desktop pane. To
43
+ // hand mobile a sensible starting size, web persists the doc-space edge length
44
+ // a scale-1 tile covered as the user saw it (`tileDocSize`, stamped on tile
45
+ // placement and web slider commits); a mobile canvas with no own knob yet
46
+ // inverts that at its content-fit zoom so its tiles cover the SAME area of the
47
+ // image. Two inverses of one identity: screenSize = docSize × zoom.
48
+ // Doc-space edge length a scale-1 tile covers at `zoom` under `factor` — the
49
+ // web write side of the calibration. Non-positive zoom yields 0, which the
50
+ // setTileDocSize op ignores.
51
+ export const tileDocSizeForFactor = (factor, zoom) => zoom > 0 ? (STAMP_TILE_SIZE * factor) / zoom : 0;
52
+ // Tile-scale factor that makes a scale-1 tile cover `docSize` doc units at
53
+ // `zoom` — the mobile read side of the calibration. Deliberately NOT clamped
54
+ // to the slider range: faithfully matching the web look can land below
55
+ // TILE_SCALE_MIN on a phone, and rendering/hit-testing handle any positive
56
+ // value. The clamp applies once the user drives the slider themselves.
57
+ export const tileScaleFromDocSize = (docSize, zoom) => (docSize * zoom) / STAMP_TILE_SIZE;
40
58
  // --- Tile sizing is independent of canvas size ------------------------------
41
59
  // A measurement tile's footprint is base × per-tile `scale` × the document-wide
42
60
  // `tileScaleFactor` (below) — and NOTHING tied to the canvas's pixel size, so
@@ -10,6 +10,9 @@ export interface AnnotationCanvasHandle {
10
10
  canRedo(): boolean;
11
11
  zoomToFit(): void;
12
12
  resetView(): void;
13
+ getTileScaleFactor(): number;
14
+ getTileDocSize(factor: number): number;
15
+ ensureMeasurementVisible(id: AnnotationElementId): void;
13
16
  placeMeasurementAtCenter(ref: MeasurementRef): void;
14
17
  placeAnnotationAtCenter(opts?: {
15
18
  defaultLengthDoc?: number;
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
- import { resolveTileScaleFactor } from './stampLayout.js';
3
+ import { resolveTileScaleFactor, stampTileSize, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
4
4
  import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
5
5
  import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
6
6
  // The viewport that frames the document's content on screen. When a background
@@ -76,7 +76,21 @@ export const useAnnotationCanvasState = (props) => {
76
76
  // tools (hit boxes) and overlays (drawn size) share one value. Uses the
77
77
  // effective (preview-applied) canvas for parity with what is on screen; the
78
78
  // slider only ever commits, so this matches ctx.document in practice.
79
- const tileScaleFactor = resolveTileScaleFactor(effectiveCanvas, tileScalePlatform ?? 'web');
79
+ // A mobile canvas whose own knob is still unset derives its factor from the
80
+ // web-stamped doc-space calibration (tileDocSize) instead of web's raw
81
+ // screen-space value: at the initial content-fit view its tiles then cover
82
+ // the same area of the image the web user saw. The first mobile slider
83
+ // commit writes tileScaleFactorMobile and takes over for good.
84
+ const tileScalePlatformResolved = tileScalePlatform ?? 'web';
85
+ let tileScaleFactor = resolveTileScaleFactor(effectiveCanvas, tileScalePlatformResolved);
86
+ if (tileScalePlatformResolved === 'mobile' &&
87
+ effectiveCanvas.tileScaleFactorMobile == null &&
88
+ effectiveCanvas.tileDocSize != null &&
89
+ effectiveCanvas.tileDocSize > 0 &&
90
+ width > 0 &&
91
+ height > 0) {
92
+ tileScaleFactor = tileScaleFromDocSize(effectiveCanvas.tileDocSize, computeContentFit(effectiveCanvas, width, height).zoom);
93
+ }
80
94
  const ctx = useMemo(() => ({
81
95
  document: canvas,
82
96
  selection,
@@ -87,11 +101,27 @@ export const useAnnotationCanvasState = (props) => {
87
101
  setPreviewPatch(patch);
88
102
  },
89
103
  commit(patch) {
90
- const inverse = invertPatch(canvas, patch);
91
- undoStackRef.current.push({ forward: patch, inverse });
104
+ // Placing a tile on WEB stamps the doc-space calibration: the area of
105
+ // the drawing a scale-1 tile covers at the zoom the user is looking
106
+ // at, right when they judged the size against the image. Mobile
107
+ // derives its initial factor from it (see tileScaleFactor above).
108
+ // Appended into the same patch so placement + calibration are one
109
+ // undo step.
110
+ let effective = patch;
111
+ if (tileScalePlatformResolved !== 'mobile' &&
112
+ patch.ops.some((o) => o.op === 'addMeasurement')) {
113
+ const docSize = tileDocSizeForFactor(tileScaleFactor, viewportApi.state.zoom);
114
+ if (docSize > 0) {
115
+ effective = {
116
+ ops: [...patch.ops, { op: 'setTileDocSize', value: docSize }],
117
+ };
118
+ }
119
+ }
120
+ const inverse = invertPatch(canvas, effective);
121
+ undoStackRef.current.push({ forward: effective, inverse });
92
122
  redoStackRef.current = [];
93
123
  setPreviewPatch(null);
94
- onCommit(patch);
124
+ onCommit(effective);
95
125
  },
96
126
  setSelection(s) {
97
127
  onSelectionChange(s);
@@ -116,6 +146,7 @@ export const useAnnotationCanvasState = (props) => {
116
146
  viewportApi,
117
147
  tileViewportScale,
118
148
  tileScaleFactor,
149
+ tileScalePlatformResolved,
119
150
  onCommit,
120
151
  onSelectionChange,
121
152
  pickMeasurement,
@@ -264,6 +295,35 @@ export const useAnnotationCanvasState = (props) => {
264
295
  // for a high-res background is the very crop this is meant to escape.
265
296
  setViewport(computeContentFit(canvas, width, height));
266
297
  },
298
+ getTileScaleFactor() {
299
+ return ctxRef.current.tileScaleFactor;
300
+ },
301
+ getTileDocSize(factor) {
302
+ return tileDocSizeForFactor(factor, ctxRef.current.viewport.state.zoom);
303
+ },
304
+ ensureMeasurementVisible(id) {
305
+ const c = ctxRef.current;
306
+ const m = c.document.placedMeasurements.find((x) => x.id === id);
307
+ if (!m)
308
+ return;
309
+ // The tile is a screen-constant square centered on its anchor, so its
310
+ // half-edge is screen px regardless of zoom (stampTileSize).
311
+ const half = stampTileSize(m, c.tileScaleFactor, c.tileViewportScale) / 2;
312
+ const s = c.viewport.worldToScreen(m.anchor);
313
+ if (s.x - half >= 0 &&
314
+ s.x + half <= width &&
315
+ s.y - half >= 0 &&
316
+ s.y + half <= height) {
317
+ return;
318
+ }
319
+ setViewport((v) => ({
320
+ zoom: v.zoom,
321
+ pan: {
322
+ x: m.anchor.x - width / (2 * v.zoom),
323
+ y: m.anchor.y - height / (2 * v.zoom),
324
+ },
325
+ }));
326
+ },
267
327
  placeMeasurementAtCenter(ref) {
268
328
  const c = ctxRef.current;
269
329
  const anchor = c.viewport.screenToWorld({
package/dist/exports.d.ts CHANGED
@@ -23,7 +23,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
23
23
  export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
24
24
  export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
25
25
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
26
- export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
26
+ export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, tileDocSizeForFactor, tileScaleFromDocSize, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
27
27
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
28
28
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
29
29
  export { createSelectTool, type SelectToolOptions, } from './annotation/canvas/tools/selectTool.js';
package/dist/exports.js CHANGED
@@ -23,7 +23,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
23
23
  export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
24
24
  export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
25
25
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
26
- export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
26
+ export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, tileDocSizeForFactor, tileScaleFromDocSize, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
27
27
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
28
28
  export { createPenTool, } from './annotation/canvas/tools/penTool.js';
29
29
  export { createSelectTool, } from './annotation/canvas/tools/selectTool.js';
@@ -111,6 +111,7 @@ export interface AnnotationCanvasState {
111
111
  placedMeasurements: PlacedMeasurementRef[];
112
112
  tileScaleFactor?: number;
113
113
  tileScaleFactorMobile?: number;
114
+ tileDocSize?: number;
114
115
  externalPayloadPath?: string;
115
116
  }
116
117
  export type AnnotationElement = (AnnotationStroke & {
@@ -160,6 +161,9 @@ export type AnnotationPatchOp = {
160
161
  op: 'setTileScaleFactor';
161
162
  value: number;
162
163
  platform?: TileScalePlatform;
164
+ } | {
165
+ op: 'setTileDocSize';
166
+ value: number;
163
167
  } | {
164
168
  op: 'setLayers';
165
169
  layers: AnnotationLayer[];
@@ -77,6 +77,10 @@ const applyOp = (state, op) => {
77
77
  return op.platform === 'mobile'
78
78
  ? { ...state, tileScaleFactorMobile: op.value }
79
79
  : { ...state, tileScaleFactor: op.value };
80
+ case 'setTileDocSize':
81
+ // Guard nonsense values (a zero/negative doc size would derive a
82
+ // zero/negative mobile factor) — keep the existing calibration instead.
83
+ return op.value > 0 ? { ...state, tileDocSize: op.value } : state;
80
84
  case 'setLayers':
81
85
  return { ...state, layers: op.layers };
82
86
  }
@@ -163,6 +167,14 @@ const invertOp = (before, op) => {
163
167
  : before.tileScaleFactor) ?? 1,
164
168
  ...(op.platform && { platform: op.platform }),
165
169
  };
170
+ case 'setTileDocSize':
171
+ // Restore the prior calibration when there was one. A doc that never
172
+ // had a calibration can't be reverted to "absent" through this op
173
+ // (applyOp ignores non-positive values), so the stamp survives undo —
174
+ // benign, since it still describes what the web user last saw.
175
+ return before.tileDocSize != null && before.tileDocSize > 0
176
+ ? { op: 'setTileDocSize', value: before.tileDocSize }
177
+ : null;
166
178
  case 'setLayers':
167
179
  return { op: 'setLayers', layers: before.layers };
168
180
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.34",
3
+ "version": "1.6.36",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@
24
24
  "scripts": {
25
25
  "build": "tsc",
26
26
  "prepack": "yarn run build",
27
+ "sync:local": "./scripts/sync-local.sh",
27
28
  "test": "vitest",
28
29
  "coverage": "vitest run --coverage",
29
30
  "format": "prettier --write .",