@reekon-tools/boldr-utils 1.13.2 → 1.14.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.
@@ -284,7 +284,7 @@ export const AnnotationCanvasInner = (props) => {
284
284
  const world = state.ctx.viewport.screenToWorld(screen);
285
285
  const placed = [...state.effectiveCanvas.placedMeasurements]
286
286
  .reverse()
287
- .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.tileScaleFactor, state.tileViewportScale));
287
+ .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.tileScaleFactor, state.tileViewportScale, state.headerTileScaleFactor));
288
288
  if (placed)
289
289
  onMeasurementStampDoubleTap(placed);
290
290
  }, [onMeasurementStampDoubleTap, state]);
@@ -320,7 +320,7 @@ export const AnnotationCanvasInner = (props) => {
320
320
  }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
321
321
  // Square tile or wide group-header pill — one footprint source of
322
322
  // truth shared with the hit-test (stampTileDims).
323
- const { width, height } = stampTileDims(placed, state.tileScaleFactor, state.tileViewportScale);
323
+ const { width, height } = stampTileDims(placed, state.tileScaleFactor, state.tileViewportScale, state.headerTileScaleFactor);
324
324
  const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
325
325
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
326
326
  const isSelected = selection?.ids.includes(placed.id) ?? false;
@@ -875,7 +875,7 @@ export const AnnotationCanvasInner = (props) => {
875
875
  return;
876
876
  const world = st.ctx.viewport.screenToWorld(screen);
877
877
  const zoomNow = st.ctx.viewport.state.zoom;
878
- const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
878
+ const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor, st.ctx.headerTileScaleFactor);
879
879
  if (!hit || hit.kind !== 'measurement' || hit.id !== prevSelectedId) {
880
880
  return;
881
881
  }
@@ -1193,7 +1193,7 @@ export const AnnotationCanvasInner = (props) => {
1193
1193
  // so a selected tile isn't stuck until you deselect it).
1194
1194
  const selId = st.ctx.selection?.ids[0];
1195
1195
  if (selId &&
1196
- !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)) {
1196
+ !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor, st.ctx.headerTileScaleFactor)) {
1197
1197
  const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
1198
1198
  if (handle) {
1199
1199
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
@@ -1293,7 +1293,7 @@ export const AnnotationCanvasInner = (props) => {
1293
1293
  return;
1294
1294
  }
1295
1295
  }
1296
- const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
1296
+ const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor, st.ctx.headerTileScaleFactor);
1297
1297
  if (!hit) {
1298
1298
  // Backgrounds can cover the whole document, so they only get the
1299
1299
  // drag once every annotation element misses, and only when ALREADY
@@ -1313,7 +1313,7 @@ export const AnnotationCanvasInner = (props) => {
1313
1313
  st.ctx.setSelection({ ids: [hit.id] });
1314
1314
  // Grabbing a line annotation's tile slides it; otherwise group-move.
1315
1315
  const grab = hit.kind === 'measurement'
1316
- ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)
1316
+ ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor, st.ctx.headerTileScaleFactor)
1317
1317
  : 'move';
1318
1318
  if (grab === 'slide') {
1319
1319
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
@@ -1621,7 +1621,7 @@ export const AnnotationCanvasInner = (props) => {
1621
1621
  // non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
1622
1622
  _jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, style], children: [backgroundLayers.map((layer) => (_jsx(BackgroundSkImageLoader, { layer: layer, resolveUrl: resolveImageUrl, onImage: onBackgroundSkImage }, layer.id))), _jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1623
1623
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1624
- : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1624
+ : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1625
1625
  ? () => {
1626
1626
  const defaultRemove = () => {
1627
1627
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
@@ -1637,10 +1637,10 @@ export const AnnotationCanvasInner = (props) => {
1637
1637
  wrapContent: loupeWrap,
1638
1638
  }) }))] }));
1639
1639
  };
1640
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1640
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1641
1641
  // Square tile or wide group-header pill — one footprint source of truth
1642
1642
  // shared with the hit-test (stampTileDims).
1643
- const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale);
1643
+ const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale, headerTileScaleFactor ?? tileScaleFactor);
1644
1644
  const halfW = width / 2;
1645
1645
  const halfH = height / 2;
1646
1646
  // Remove-"X" touch target, sized as a fraction of the tile and corner-pinned
@@ -24,6 +24,7 @@ export interface ToolContext {
24
24
  viewport: ViewportApi;
25
25
  tileViewportScale: number;
26
26
  tileScaleFactor: number;
27
+ headerTileScaleFactor: number;
27
28
  preview(patch: AnnotationDocumentPatch): void;
28
29
  commit(patch: AnnotationDocumentPatch): void;
29
30
  setSelection(selection: Selection | null): void;
@@ -54,12 +55,12 @@ export interface ShapeDrawConfig {
54
55
  }
55
56
  export type DragElementKind = 'stroke' | 'shape' | 'measurement' | 'background';
56
57
  export interface DragSelectionConfig {
57
- hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): {
58
+ hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number, headerTileScaleFactor?: number): {
58
59
  id: AnnotationElementId;
59
60
  kind: DragElementKind;
60
61
  } | null;
61
62
  buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
62
- classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): 'slide' | 'move';
63
+ classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number, headerTileScaleFactor?: number): 'slide' | 'move';
63
64
  buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
64
65
  hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
65
66
  buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
@@ -77,7 +78,7 @@ export interface DragSelectionConfig {
77
78
  fixed: Vec2;
78
79
  } | null;
79
80
  buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
80
- isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): boolean;
81
+ isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number, headerTileScaleFactor?: number): boolean;
81
82
  hitTestBackground?(doc: AnnotationCanvasState, world: Vec2): {
82
83
  id: AnnotationElementId;
83
84
  } | null;
@@ -36,7 +36,7 @@ export declare const rectCornerPoint: (rect: {
36
36
  b: Vec2;
37
37
  }, corner: RectCorner) => Vec2;
38
38
  export declare const oppositeRectCorner: (corner: RectCorner) => RectCorner;
39
- export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number, tileScaleFactor?: number, viewportScale?: number) => boolean;
39
+ export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number, tileScaleFactor?: number, viewportScale?: number, headerTileScaleFactor?: number) => boolean;
40
40
  export interface RemoveMeasurementResult {
41
41
  ops: AnnotationPatchOp[];
42
42
  keepSelection: boolean;
@@ -110,14 +110,15 @@ const segmentDistSq = (p, a, b) => {
110
110
  // around the anchor, the line body of a line annotation, or the
111
111
  // border ring of a rectangle annotation (interiors stay transparent to hits so
112
112
  // elements inside remain reachable).
113
- export const hitPlacedMeasurement = (m, p, zoom = 1, tileScaleFactor = 1, viewportScale = 1) => {
113
+ export const hitPlacedMeasurement = (m, p, zoom = 1, tileScaleFactor = 1, viewportScale = 1, headerTileScaleFactor = tileScaleFactor) => {
114
114
  // stampTileDims yields the tile's CURRENT screen-space box (the
115
115
  // zoom-relative multiplier arrives via `viewportScale`); converting the
116
116
  // half-extents (+ padding) back to doc space via the zoom keeps the hit box
117
117
  // matching what's drawn — a doc-constant footprint (blank and populated
118
- // stamps share the square footprint; group-header chips are a wide pill
119
- // see stampTileDims in stampLayout).
120
- const dims = stampTileDims(m, tileScaleFactor, viewportScale);
118
+ // stamps share the square footprint; group-header chips are a wide pill
119
+ // sized by their OWN document-wide knob, `headerTileScaleFactor` — see
120
+ // stampTileDims in stampLayout).
121
+ const dims = stampTileDims(m, tileScaleFactor, viewportScale, headerTileScaleFactor);
121
122
  const halfW = (dims.width / 2 + STAMP_HIT_PADDING) / zoom;
122
123
  const halfH = (dims.height / 2 + STAMP_HIT_PADDING) / zoom;
123
124
  const dx = Math.abs(p.x - m.anchor.x);
@@ -19,6 +19,7 @@ export declare const DEFAULT_TILE_SCALE = 1;
19
19
  export declare const TILE_SCALE_MIN = 0.4;
20
20
  export declare const TILE_SCALE_MAX = 2;
21
21
  export declare const resolveTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile">, platform: TileScalePlatform) => number;
22
+ export declare const resolveHeaderTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile" | "headerTileScaleFactor" | "headerTileScaleFactorMobile">, platform: TileScalePlatform) => number;
22
23
  export declare const clampTileScale: (v: number) => number;
23
24
  export declare const tileDocSizeForFactor: (factor: number, zoom: number) => number;
24
25
  export declare const tileScaleFromDocSize: (docSize: number, zoom: number) => number;
@@ -28,4 +29,4 @@ export interface StampTileDims {
28
29
  width: number;
29
30
  height: number;
30
31
  }
31
- export declare const stampTileDims: (m: Pick<PlacedMeasurementRef, "scale" | "isGroupHeader" | "collapsed">, tileScaleFactor?: number, viewportScale?: number) => StampTileDims;
32
+ export declare const stampTileDims: (m: Pick<PlacedMeasurementRef, "scale" | "isGroupHeader" | "collapsed">, tileScaleFactor?: number, viewportScale?: number, headerTileScaleFactor?: number) => StampTileDims;
@@ -80,6 +80,18 @@ export const TILE_SCALE_MAX = 2;
80
80
  export const resolveTileScaleFactor = (canvas, platform) => (platform === 'mobile'
81
81
  ? (canvas.tileScaleFactorMobile ?? canvas.tileScaleFactor)
82
82
  : canvas.tileScaleFactor) ?? DEFAULT_TILE_SCALE;
83
+ // Resolve the document-wide HEADER-scale knob for a platform — the group/form
84
+ // header counterpart of resolveTileScaleFactor (Asana 1217570500895543).
85
+ // Same platform fallback (mobile → web), then one more step: a document with
86
+ // no header knob at all falls back to the MEASUREMENT factor, the pre-split
87
+ // behavior where one knob drove every tile — so existing documents render
88
+ // identically until a user first moves the header slider. The ONE read path
89
+ // for the header fields — renderers, hit-testing, and the slider UIs must all
90
+ // resolve through here so they agree.
91
+ export const resolveHeaderTileScaleFactor = (canvas, platform) => (platform === 'mobile'
92
+ ? (canvas.headerTileScaleFactorMobile ?? canvas.headerTileScaleFactor)
93
+ : canvas.headerTileScaleFactor) ??
94
+ resolveTileScaleFactor(canvas, platform);
83
95
  // Clamp a tile-scale-factor candidate to the supported range. The single guard
84
96
  // for the value before it lands in the document (slider input, restored docs).
85
97
  export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
@@ -136,11 +148,15 @@ export const isUnassociatedStamp = (m) => !m.isGroupHeader && !m.measurementId &
136
148
  // canvas's content-fit reference zoom (see the note above), so tiles scale
137
149
  // with the drawing as the user zooms.
138
150
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => STAMP_TILE_SIZE * (m.scale ?? 1) * tileScaleFactor * viewportScale;
139
- export const stampTileDims = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => {
140
- const k = (m.scale ?? 1) * tileScaleFactor * viewportScale;
151
+ // `headerTileScaleFactor` is the header chips' own document-wide knob
152
+ // (resolveHeaderTileScaleFactor); it defaults to `tileScaleFactor` so callers
153
+ // that predate the header/measurement split keep their one-knob behavior.
154
+ export const stampTileDims = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1, headerTileScaleFactor = tileScaleFactor) => {
141
155
  if (!m.isGroupHeader) {
156
+ const k = (m.scale ?? 1) * tileScaleFactor * viewportScale;
142
157
  return { width: STAMP_TILE_SIZE * k, height: STAMP_TILE_SIZE * k };
143
158
  }
159
+ const k = (m.scale ?? 1) * headerTileScaleFactor * viewportScale;
144
160
  const base = groupHeaderBaseDims(m.collapsed);
145
161
  return { width: base.width * k, height: base.height * k };
146
162
  };
@@ -51,7 +51,7 @@ export const createPanTool = (options = {}) => ({
51
51
  const measurements = ctx.document.placedMeasurements;
52
52
  for (let i = measurements.length - 1; i >= 0; i--) {
53
53
  const m = measurements[i];
54
- if (hitPlacedMeasurement(m, event.world, zoom, ctx.tileScaleFactor, ctx.tileViewportScale)) {
54
+ if (hitPlacedMeasurement(m, event.world, zoom, ctx.tileScaleFactor, ctx.tileViewportScale, ctx.headerTileScaleFactor)) {
55
55
  ctx.setSelection({ ids: [m.id] });
56
56
  return;
57
57
  }
@@ -66,11 +66,15 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
66
66
  const findHit = (doc, world, zoom, viewportTileScale = 1,
67
67
  // Platform-resolved tile-scale knob (ctx.tileScaleFactor). Defaults to the
68
68
  // doc's web/legacy field so un-threaded callers keep the pre-split behavior.
69
- tileScaleFactor = doc.tileScaleFactor) => {
69
+ tileScaleFactor = doc.tileScaleFactor,
70
+ // Platform-resolved header-scale knob (ctx.headerTileScaleFactor). Defaults
71
+ // to the doc's web header field, falling back to the measurement knob the
72
+ // way resolveHeaderTileScaleFactor does.
73
+ headerTileScaleFactor = doc.headerTileScaleFactor ?? tileScaleFactor) => {
70
74
  // Hit-test in z-order (top first): measurements > shapes > strokes.
71
75
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
72
76
  const m = doc.placedMeasurements[i];
73
- if (hitPlacedMeasurement(m, world, zoom, tileScaleFactor, viewportTileScale)) {
77
+ if (hitPlacedMeasurement(m, world, zoom, tileScaleFactor, viewportTileScale, headerTileScaleFactor)) {
74
78
  return { id: m.id, kind: 'measurement' };
75
79
  }
76
80
  }
@@ -179,11 +183,11 @@ const translatePatch = (elementKind, id, doc, delta) => {
179
183
  // is selected; without this, a handle sitting under the tile (a rectangle
180
184
  // tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
181
185
  // and the tile becomes unmovable until deselected.
182
- const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
186
+ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor, headerTileScaleFactor = doc.headerTileScaleFactor ?? tileScaleFactor) => {
183
187
  const m = doc.placedMeasurements.find((x) => x.id === id);
184
188
  if (!m)
185
189
  return false;
186
- const dims = stampTileDims(m, tileScaleFactor, viewportTileScale);
190
+ const dims = stampTileDims(m, tileScaleFactor, viewportTileScale, headerTileScaleFactor);
187
191
  const halfW = (dims.width / 2 + HIT_PADDING) / zoom;
188
192
  const halfH = (dims.height / 2 + HIT_PADDING) / zoom;
189
193
  return (Math.abs(world.x - m.anchor.x) <= halfW &&
@@ -193,14 +197,14 @@ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1, tileSc
193
197
  // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
194
198
  // Grabbing the tile of a line annotation slides it along the line; everything
195
199
  // else (bare stamps, grabs on the line body) is a group move.
196
- const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
200
+ const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor, headerTileScaleFactor = doc.headerTileScaleFactor ?? tileScaleFactor) => {
197
201
  const m = doc.placedMeasurements.find((x) => x.id === id);
198
202
  if (!m)
199
203
  return 'move';
200
204
  // Same footprint the tile is drawn at (wide pill for group headers; folds in
201
205
  // the document-wide tile scale + viewport scale so slide-grab matches the
202
206
  // draw).
203
- const dims = stampTileDims(m, tileScaleFactor, viewportTileScale);
207
+ const dims = stampTileDims(m, tileScaleFactor, viewportTileScale, headerTileScaleFactor);
204
208
  const halfW = (dims.width / 2 + HIT_PADDING) / zoom;
205
209
  const halfH = (dims.height / 2 + HIT_PADDING) / zoom;
206
210
  const onTile = Math.abs(world.x - m.anchor.x) <= halfW &&
@@ -600,7 +604,7 @@ export const createSelectTool = (options = {}) => ({
600
604
  // reusing the same hit-test and translate logic the pointer handlers below
601
605
  // use for web — one source of truth.
602
606
  dragSelection: {
603
- hitTest: (doc, world, zoom, viewportTileScale, tileScaleFactor) => findHit(doc, world, zoom, viewportTileScale, tileScaleFactor),
607
+ hitTest: (doc, world, zoom, viewportTileScale, tileScaleFactor, headerTileScaleFactor) => findHit(doc, world, zoom, viewportTileScale, tileScaleFactor, headerTileScaleFactor),
604
608
  buildTranslatePatch: (doc, id, kind, delta) => {
605
609
  const op = translatePatch(kind, id, doc, delta);
606
610
  return op ? { ops: [op] } : null;
@@ -639,7 +643,7 @@ export const createSelectTool = (options = {}) => ({
639
643
  // tile stays draggable (otherwise it can only be moved after deselecting).
640
644
  const selId = ctx.selection?.ids[0];
641
645
  if (selId &&
642
- !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor)) {
646
+ !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor, ctx.headerTileScaleFactor)) {
643
647
  const handle = findHandleHit(ctx.document, selId, world, zoom);
644
648
  if (handle) {
645
649
  ctx.setSelection({ ids: [selId] });
@@ -708,7 +712,7 @@ export const createSelectTool = (options = {}) => ({
708
712
  };
709
713
  }
710
714
  }
711
- const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor);
715
+ const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor, ctx.headerTileScaleFactor);
712
716
  if (!hit) {
713
717
  // Background layers only get a look-in when every annotation element
714
718
  // misses — they can cover the whole document, so elements above always
@@ -753,7 +757,7 @@ export const createSelectTool = (options = {}) => ({
753
757
  : null;
754
758
  ctx.setSelection({ ids: [hit.id] });
755
759
  const mode = hit.kind === 'measurement' &&
756
- classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor) === 'slide'
760
+ classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor, ctx.headerTileScaleFactor) === 'slide'
757
761
  ? 'slide'
758
762
  : 'move';
759
763
  return {
@@ -24,6 +24,7 @@ export interface AnnotationCanvasHandle {
24
24
  durationMs?: number;
25
25
  }): void;
26
26
  getTileScaleFactor(): number;
27
+ getHeaderTileScaleFactor(): number;
27
28
  getTileDocSize(factor: number): number;
28
29
  ensureMeasurementVisible(id: AnnotationElementId): void;
29
30
  placeMeasurementAtCenter(ref: MeasurementRef): void;
@@ -84,6 +85,7 @@ export interface AnnotationCanvasStateApi {
84
85
  ctx: ToolContext;
85
86
  tileViewportScale: number;
86
87
  tileScaleFactor: number;
88
+ headerTileScaleFactor: number;
87
89
  penDrawingStroke: AnnotationStroke | null;
88
90
  customPreviewState: ToolState;
89
91
  dispatchPointerDown(event: CanvasPointerEvent): void;
@@ -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, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
3
+ import { resolveHeaderTileScaleFactor, resolveTileScaleFactor, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
4
4
  import { createViewportApi, fitRectToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
5
5
  import { backgroundLayerDocRect, backgroundLayersOf, backgroundLayersUnionRect, } from './backgroundLayers.js';
6
6
  import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
@@ -157,13 +157,28 @@ export const useAnnotationCanvasState = (props) => {
157
157
  // commit writes tileScaleFactorMobile and takes over for good.
158
158
  const tileScalePlatformResolved = tileScalePlatform ?? 'web';
159
159
  let tileScaleFactor = resolveTileScaleFactor(effectiveCanvas, tileScalePlatformResolved);
160
+ // Group/form header chips carry their own document-wide knob (Asana
161
+ // 1217570500895543), resolved the same way. With no header knob set it
162
+ // equals the measurement factor (resolveHeaderTileScaleFactor's fallback) —
163
+ // the pre-split one-knob behavior.
164
+ let headerTileScaleFactor = resolveHeaderTileScaleFactor(effectiveCanvas, tileScalePlatformResolved);
160
165
  if (tileScalePlatformResolved === 'mobile' &&
161
166
  effectiveCanvas.tileScaleFactorMobile == null &&
162
167
  effectiveCanvas.tileDocSize != null &&
163
168
  effectiveCanvas.tileDocSize > 0 &&
164
169
  width > 0 &&
165
170
  height > 0) {
166
- tileScaleFactor = tileScaleFromDocSize(effectiveCanvas.tileDocSize, baseFitZoom);
171
+ const calibrated = tileScaleFromDocSize(effectiveCanvas.tileDocSize, baseFitZoom);
172
+ // Scale the header factor by the same calibration ratio so its relation
173
+ // to the measurement factor survives the override: a doc with no header
174
+ // knob keeps headers exactly at the calibrated measurement factor, and a
175
+ // doc whose web user set a header knob keeps the same header-to-tile
176
+ // proportion the web user saw.
177
+ headerTileScaleFactor =
178
+ tileScaleFactor > 0
179
+ ? headerTileScaleFactor * (calibrated / tileScaleFactor)
180
+ : calibrated;
181
+ tileScaleFactor = calibrated;
167
182
  }
168
183
  const ctx = useMemo(() => ({
169
184
  document: canvas,
@@ -171,6 +186,7 @@ export const useAnnotationCanvasState = (props) => {
171
186
  viewport: viewportApi,
172
187
  tileViewportScale,
173
188
  tileScaleFactor,
189
+ headerTileScaleFactor,
174
190
  preview(patch) {
175
191
  setPreviewPatch(patch);
176
192
  },
@@ -222,6 +238,7 @@ export const useAnnotationCanvasState = (props) => {
222
238
  viewportApi,
223
239
  tileViewportScale,
224
240
  tileScaleFactor,
241
+ headerTileScaleFactor,
225
242
  tileScalePlatformResolved,
226
243
  baseFitZoom,
227
244
  onCommit,
@@ -425,6 +442,9 @@ export const useAnnotationCanvasState = (props) => {
425
442
  getTileScaleFactor() {
426
443
  return ctxRef.current.tileScaleFactor;
427
444
  },
445
+ getHeaderTileScaleFactor() {
446
+ return ctxRef.current.headerTileScaleFactor;
447
+ },
428
448
  getTileDocSize(factor) {
429
449
  // Tiles are doc-constant, so the doc size a factor yields derives
430
450
  // from the content-fit reference, not the current zoom.
@@ -437,7 +457,7 @@ export const useAnnotationCanvasState = (props) => {
437
457
  return;
438
458
  // The tile's CURRENT on-screen half-extents: stampTileDims with the
439
459
  // zoom-relative tileViewportScale folded in.
440
- const dims = stampTileDims(m, c.tileScaleFactor, c.tileViewportScale);
460
+ const dims = stampTileDims(m, c.tileScaleFactor, c.tileViewportScale, c.headerTileScaleFactor);
441
461
  const halfW = dims.width / 2;
442
462
  const halfH = dims.height / 2;
443
463
  const s = c.viewport.worldToScreen(m.anchor);
@@ -734,6 +754,7 @@ export const useAnnotationCanvasState = (props) => {
734
754
  ctx,
735
755
  tileViewportScale,
736
756
  tileScaleFactor,
757
+ headerTileScaleFactor,
737
758
  penDrawingStroke,
738
759
  customPreviewState: toolState,
739
760
  dispatchPointerDown,
package/dist/exports.d.ts CHANGED
@@ -29,7 +29,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
29
29
  export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
30
30
  export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
31
31
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
32
- export { STAMP_TILE_SIZE, GROUP_HEADER_TILE_WIDTH, GROUP_HEADER_TILE_HEIGHT, GROUP_HEADER_COLLAPSED_WIDTH, GROUP_HEADER_COLLAPSED_HEIGHT, GROUP_HEADER_DESIGN, groupHeaderBaseDims, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, tileDocSizeForFactor, tileScaleFromDocSize, stampTileSize, stampTileDims, isUnassociatedStamp, type StampTileDims, } from './annotation/canvas/stampLayout.js';
32
+ export { STAMP_TILE_SIZE, GROUP_HEADER_TILE_WIDTH, GROUP_HEADER_TILE_HEIGHT, GROUP_HEADER_COLLAPSED_WIDTH, GROUP_HEADER_COLLAPSED_HEIGHT, GROUP_HEADER_DESIGN, groupHeaderBaseDims, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, resolveHeaderTileScaleFactor, tileDocSizeForFactor, tileScaleFromDocSize, stampTileSize, stampTileDims, isUnassociatedStamp, type StampTileDims, } from './annotation/canvas/stampLayout.js';
33
33
  export { GROUP_HEADER_STYLE, describeGroupHeader, groupHeaderSignature, type GroupHeaderBadge, type GroupHeaderDescriptor, } from './annotation/canvas/groupHeaderLayout.js';
34
34
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
35
35
  export { LEGACY_BACKGROUND_LAYER_ID, BACKGROUND_PLACEMENT_GAP_RATIO, backgroundLayersOf, backgroundLayerDocRect, backgroundLayersUnionRect, placeNewBackgroundLayer, } from './annotation/canvas/backgroundLayers.js';
package/dist/exports.js CHANGED
@@ -34,7 +34,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
34
34
  export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
35
35
  export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
36
36
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
37
- export { STAMP_TILE_SIZE, GROUP_HEADER_TILE_WIDTH, GROUP_HEADER_TILE_HEIGHT, GROUP_HEADER_COLLAPSED_WIDTH, GROUP_HEADER_COLLAPSED_HEIGHT, GROUP_HEADER_DESIGN, groupHeaderBaseDims, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, tileDocSizeForFactor, tileScaleFromDocSize, stampTileSize, stampTileDims, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
37
+ export { STAMP_TILE_SIZE, GROUP_HEADER_TILE_WIDTH, GROUP_HEADER_TILE_HEIGHT, GROUP_HEADER_COLLAPSED_WIDTH, GROUP_HEADER_COLLAPSED_HEIGHT, GROUP_HEADER_DESIGN, groupHeaderBaseDims, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, resolveHeaderTileScaleFactor, tileDocSizeForFactor, tileScaleFromDocSize, stampTileSize, stampTileDims, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
38
38
  export { GROUP_HEADER_STYLE, describeGroupHeader, groupHeaderSignature, } from './annotation/canvas/groupHeaderLayout.js';
39
39
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
40
40
  export { LEGACY_BACKGROUND_LAYER_ID, BACKGROUND_PLACEMENT_GAP_RATIO, backgroundLayersOf, backgroundLayerDocRect, backgroundLayersUnionRect, placeNewBackgroundLayer, } from './annotation/canvas/backgroundLayers.js';
@@ -120,6 +120,7 @@ export interface AnnotationViewport {
120
120
  backgroundImages?: AnnotationBackgroundLayer[];
121
121
  }
122
122
  export type TileScalePlatform = 'web' | 'mobile';
123
+ export type TileScaleTarget = 'measurement' | 'header';
123
124
  export interface AnnotationCanvasState {
124
125
  schemaVersion: 1;
125
126
  layers: AnnotationLayer[];
@@ -129,6 +130,8 @@ export interface AnnotationCanvasState {
129
130
  placedMeasurements: PlacedMeasurementRef[];
130
131
  tileScaleFactor?: number;
131
132
  tileScaleFactorMobile?: number;
133
+ headerTileScaleFactor?: number;
134
+ headerTileScaleFactorMobile?: number;
132
135
  tileDocSize?: number;
133
136
  externalPayloadPath?: string;
134
137
  }
@@ -190,6 +193,7 @@ export type AnnotationPatchOp = {
190
193
  op: 'setTileScaleFactor';
191
194
  value: number;
192
195
  platform?: TileScalePlatform;
196
+ target?: TileScaleTarget;
193
197
  } | {
194
198
  op: 'setTileDocSize';
195
199
  value: number;
@@ -120,6 +120,11 @@ const applyOp = (state, op) => {
120
120
  case 'setViewport':
121
121
  return { ...state, viewport: { ...state.viewport, ...op.patch } };
122
122
  case 'setTileScaleFactor':
123
+ if (op.target === 'header') {
124
+ return op.platform === 'mobile'
125
+ ? { ...state, headerTileScaleFactorMobile: op.value }
126
+ : { ...state, headerTileScaleFactor: op.value };
127
+ }
123
128
  return op.platform === 'mobile'
124
129
  ? { ...state, tileScaleFactorMobile: op.value }
125
130
  : { ...state, tileScaleFactor: op.value };
@@ -223,18 +228,30 @@ const invertOp = (before, op) => {
223
228
  }
224
229
  return { op: 'setViewport', patch: inversePatch };
225
230
  }
226
- case 'setTileScaleFactor':
227
- // Restore the prior EFFECTIVE factor for the same platform knob. Mobile
228
- // falls back to the shared/web value when it has no own value yet (the
229
- // pre-split doc case — mirrors resolveTileScaleFactor); absent → an
230
- // explicit 1 (visually identical), keeping the `value: number` contract.
231
+ case 'setTileScaleFactor': {
232
+ // Restore the prior EFFECTIVE factor for the same platform (and tile
233
+ // kind) knob. Mobile falls back to the shared/web value when it has no
234
+ // own value yet (the pre-split doc case — mirrors
235
+ // resolveTileScaleFactor), and the header knob further falls back to
236
+ // the measurement knob (mirrors resolveHeaderTileScaleFactor); absent →
237
+ // an explicit 1 (visually identical), keeping the `value: number`
238
+ // contract.
239
+ const measurementPrior = op.platform === 'mobile'
240
+ ? (before.tileScaleFactorMobile ?? before.tileScaleFactor)
241
+ : before.tileScaleFactor;
242
+ const prior = op.target === 'header'
243
+ ? ((op.platform === 'mobile'
244
+ ? (before.headerTileScaleFactorMobile ??
245
+ before.headerTileScaleFactor)
246
+ : before.headerTileScaleFactor) ?? measurementPrior)
247
+ : measurementPrior;
231
248
  return {
232
249
  op: 'setTileScaleFactor',
233
- value: (op.platform === 'mobile'
234
- ? (before.tileScaleFactorMobile ?? before.tileScaleFactor)
235
- : before.tileScaleFactor) ?? 1,
250
+ value: prior ?? 1,
236
251
  ...(op.platform && { platform: op.platform }),
252
+ ...(op.target && { target: op.target }),
237
253
  };
254
+ }
238
255
  case 'setTileDocSize':
239
256
  // Restore the prior calibration when there was one. A doc that never
240
257
  // had a calibration can't be reverted to "absent" through this op
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.13.2",
3
+ "version": "1.14.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",