@reekon-tools/boldr-utils 1.11.0 → 1.13.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.
Files changed (95) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +48 -7
  2. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +2 -2
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.js +16 -3
  4. package/dist/annotation/canvas/Tool.d.ts +8 -1
  5. package/dist/annotation/canvas/backgroundLayers.d.ts +20 -0
  6. package/dist/annotation/canvas/backgroundLayers.js +98 -0
  7. package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +10 -7
  8. package/dist/annotation/canvas/elements/BackgroundImageElement.js +45 -11
  9. package/dist/annotation/canvas/elements/BackgroundSvg.d.ts +3 -4
  10. package/dist/annotation/canvas/elements/BackgroundSvg.js +23 -29
  11. package/dist/annotation/canvas/elements/backgroundUrl.js +2 -2
  12. package/dist/annotation/canvas/groupHeaderLayout.d.ts +72 -0
  13. package/dist/annotation/canvas/groupHeaderLayout.js +115 -0
  14. package/dist/annotation/canvas/stampLayout.d.ts +15 -2
  15. package/dist/annotation/canvas/stampLayout.js +46 -12
  16. package/dist/annotation/canvas/tools/selectTool.js +183 -19
  17. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +1 -0
  18. package/dist/annotation/canvas/useAnnotationCanvasState.js +40 -20
  19. package/dist/annotation/canvas/viewport.js +7 -6
  20. package/dist/annotation/data/AnnotationDataProvider.d.ts +1 -1
  21. package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +1 -1
  22. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.d.ts +7 -1
  23. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +124 -22
  24. package/dist/annotation/data/hooks/useAnnotationMutations.d.ts +1 -1
  25. package/dist/canvas/AnnotationCanvas.d.ts +11 -0
  26. package/dist/canvas/AnnotationCanvas.js +10 -0
  27. package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
  28. package/dist/canvas/AnnotationCanvas.native.js +6 -0
  29. package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
  30. package/dist/canvas/AnnotationCanvasInner.js +219 -0
  31. package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
  32. package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
  33. package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
  34. package/dist/canvas/AnnotationCanvasSkia.js +20 -0
  35. package/dist/canvas/Tool.d.ts +38 -0
  36. package/dist/canvas/Tool.js +1 -0
  37. package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
  38. package/dist/canvas/elements/BackgroundImageElement.js +37 -0
  39. package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
  40. package/dist/canvas/elements/MeasurementStampElement.js +30 -0
  41. package/dist/canvas/elements/ShapeElement.d.ts +7 -0
  42. package/dist/canvas/elements/ShapeElement.js +62 -0
  43. package/dist/canvas/elements/StrokeElement.d.ts +7 -0
  44. package/dist/canvas/elements/StrokeElement.js +18 -0
  45. package/dist/canvas/measurementPicker.d.ts +10 -0
  46. package/dist/canvas/measurementPicker.js +1 -0
  47. package/dist/canvas/measurementStampOverlay.d.ts +11 -0
  48. package/dist/canvas/measurementStampOverlay.js +1 -0
  49. package/dist/canvas/pointerAdapter.d.ts +3 -0
  50. package/dist/canvas/pointerAdapter.js +19 -0
  51. package/dist/canvas/stampLayout.d.ts +5 -0
  52. package/dist/canvas/stampLayout.js +14 -0
  53. package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
  54. package/dist/canvas/tools/measurementStampTool.js +37 -0
  55. package/dist/canvas/tools/panTool.d.ts +5 -0
  56. package/dist/canvas/tools/panTool.js +25 -0
  57. package/dist/canvas/tools/penTool.d.ts +13 -0
  58. package/dist/canvas/tools/penTool.js +68 -0
  59. package/dist/canvas/tools/selectTool.d.ts +2 -0
  60. package/dist/canvas/tools/selectTool.js +182 -0
  61. package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
  62. package/dist/canvas/useAnnotationCanvasState.js +210 -0
  63. package/dist/canvas/viewport.d.ts +16 -0
  64. package/dist/canvas/viewport.js +54 -0
  65. package/dist/data/AnnotationDataContext.d.ts +8 -0
  66. package/dist/data/AnnotationDataContext.js +11 -0
  67. package/dist/data/AnnotationDataProvider.d.ts +65 -0
  68. package/dist/data/AnnotationDataProvider.js +4 -0
  69. package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
  70. package/dist/data/InMemoryAnnotationProvider.js +197 -0
  71. package/dist/data/canvasPersistence.d.ts +3 -0
  72. package/dist/data/canvasPersistence.js +26 -0
  73. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
  74. package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
  75. package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
  76. package/dist/data/hooks/useAnnotationDoc.js +33 -0
  77. package/dist/data/hooks/useAnnotationList.d.ts +7 -0
  78. package/dist/data/hooks/useAnnotationList.js +26 -0
  79. package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
  80. package/dist/data/hooks/useAnnotationMutations.js +11 -0
  81. package/dist/exports.d.ts +3 -1
  82. package/dist/exports.js +3 -1
  83. package/dist/hooks/useParseMeasurement.d.ts +4 -0
  84. package/dist/hooks/useParseMeasurement.js +14 -0
  85. package/dist/types/annotation.d.ts +28 -0
  86. package/dist/types/annotation.js +68 -0
  87. package/dist/utils/evaluateFormula.d.ts +20 -0
  88. package/dist/utils/evaluateFormula.js +31 -0
  89. package/package.json +1 -1
  90. package/dist/theme/index.d.ts +0 -3
  91. package/dist/theme/index.js +0 -3
  92. package/dist/theme/preset.d.ts +0 -120
  93. package/dist/theme/preset.js +0 -25
  94. package/dist/theme/semantic.d.ts +0 -136
  95. package/dist/theme/semantic.js +0 -116
@@ -0,0 +1,115 @@
1
+ // Presentation model for the group-header chip placed on an annotation canvas
2
+ // (`PlacedMeasurementRef.isGroupHeader`), shared by mobile and web.
3
+ //
4
+ // The chip itself can't be shared — this package ships DOM and React Native
5
+ // forks of the canvas host, and there is no react-native-web in the graph, so
6
+ // a single component would only render on one of them. What CAN be shared is
7
+ // everything that isn't a view node: which badges a header shows, what they
8
+ // say, what colour they are, and every measurement in the Figma frames. Both
9
+ // hosts render the SAME descriptor into ~40 lines of platform view code, so a
10
+ // change to the header's content or metrics lands in one place.
11
+ //
12
+ // Figma (BOLDR PRO / APP STRUCTURE, "HEADER SETTINGS - MVP" 490:4189):
13
+ // 491:5371 expanded group · 491:5654 collapsed group
14
+ // 491:5381 expanded form · 491:5659 collapsed form
15
+ // Asana 1217382797393698.
16
+ import { GroupType } from '../../types/firestore.js';
17
+ import { GeneralColors, TileColors, YellowColors } from '../../theme/colors.js';
18
+ import { GROUP_HEADER_DESIGN } from './stampLayout.js';
19
+ // Every length here is in the DESIGN space the frames were drawn in — the
20
+ // 200×56 / 90×65 boxes of GROUP_HEADER_DESIGN. Hosts lay the chip out at that
21
+ // natural size and uniformly transform-scale it into the overlay box
22
+ // stampTileDims hands them, so these numbers never need scaling by hand.
23
+ export const GROUP_HEADER_STYLE = {
24
+ // Outer chip.
25
+ background: GeneralColors.grey2,
26
+ radius: 8,
27
+ padding: 5,
28
+ // Selection ring. Drawn INSIDE the chip's box (the footprint is the
29
+ // hit-test box too, so an outset ring would lie about what is grabbable).
30
+ selectedBorderWidth: 3,
31
+ selectedBorderColor: YellowColors.mainYellow,
32
+ // Index badges.
33
+ badgeHeight: 25,
34
+ badgeRadius: 4,
35
+ badgeFontSize: 13,
36
+ badgeTextColor: GeneralColors.white,
37
+ // Gap between the two badges of a form header: horizontal when expanded
38
+ // (they sit side by side), vertical when collapsed (they stack).
39
+ badgeGapExpanded: 5,
40
+ badgeGapCollapsed: 5,
41
+ // Group name, expanded only.
42
+ nameGap: 3,
43
+ nameFontSize: 15,
44
+ nameColor: GeneralColors.white,
45
+ // Globe glyph that stands in for the index badge on the default group.
46
+ globeSize: 17,
47
+ globeColor: GeneralColors.white,
48
+ };
49
+ /**
50
+ * Resolve what a header chip shows. `group` is looked up live by the host from
51
+ * `placed.groupId` so renames and re-indexes appear without touching the
52
+ * canvas document; `section` is that group's section, needed only for a FORM
53
+ * header's "# Table N". Both may be undefined — a group deleted after its
54
+ * header was placed still has to render something rather than crash.
55
+ */
56
+ export const describeGroupHeader = ({ group, section, collapsed = false, }) => {
57
+ const design = collapsed
58
+ ? GROUP_HEADER_DESIGN.collapsed
59
+ : GROUP_HEADER_DESIGN.expanded;
60
+ const base = {
61
+ collapsed,
62
+ name: collapsed ? null : (group?.name ?? 'Group'),
63
+ design,
64
+ };
65
+ if (!group)
66
+ return { ...base, badges: [], showGlobe: false };
67
+ // Default group (index 0): no number to show — the globe chip stands in.
68
+ if (group.groupIndex === 0) {
69
+ return {
70
+ ...base,
71
+ showGlobe: true,
72
+ badges: [{ key: 'group', text: '', background: TileColors.darkBlue }],
73
+ };
74
+ }
75
+ const badges = [];
76
+ if (group.type === GroupType.FORM) {
77
+ // "# Table N" only when the section resolved — a form header with a
78
+ // missing section drops to its "# Form N" badge alone rather than
79
+ // rendering an empty chip.
80
+ if (typeof section?.index === 'number') {
81
+ badges.push({
82
+ key: 'table',
83
+ text: `# Table ${section.index + 1}`,
84
+ background: TileColors.lightBlue1,
85
+ });
86
+ }
87
+ badges.push({
88
+ key: 'form',
89
+ text: `# Form ${group.groupIndex + 1}`,
90
+ background: TileColors.activeBlue,
91
+ });
92
+ }
93
+ else {
94
+ badges.push({
95
+ key: 'group',
96
+ text: `# Group ${group.groupIndex + 1}`,
97
+ background: TileColors.darkBlue,
98
+ });
99
+ }
100
+ return { ...base, badges, showGlobe: false };
101
+ };
102
+ /**
103
+ * Signature of everything `describeGroupHeader` would render, for hosts that
104
+ * cache on tile content (mobile re-bakes its canvas thumbnail when this
105
+ * changes). Kept beside the resolver so the two can't drift.
106
+ */
107
+ export const groupHeaderSignature = (input) => {
108
+ const d = describeGroupHeader(input);
109
+ return [
110
+ d.collapsed ? 'c' : 'e',
111
+ d.showGlobe ? 'globe' : '',
112
+ d.badges.map((b) => `${b.key}:${b.text}`).join(','),
113
+ d.name ?? '',
114
+ ].join('~');
115
+ };
@@ -1,7 +1,20 @@
1
1
  import type { AnnotationCanvasState, PlacedMeasurementRef, TileScalePlatform } from '../../types/annotation.js';
2
2
  export declare const STAMP_TILE_SIZE = 96;
3
3
  export declare const GROUP_HEADER_TILE_WIDTH = 240;
4
- export declare const GROUP_HEADER_TILE_HEIGHT = 64;
4
+ export declare const GROUP_HEADER_TILE_HEIGHT = 67.2;
5
+ export declare const GROUP_HEADER_DESIGN: {
6
+ readonly expanded: {
7
+ readonly width: 200;
8
+ readonly height: 56;
9
+ };
10
+ readonly collapsed: {
11
+ readonly width: 90;
12
+ readonly height: 65;
13
+ };
14
+ };
15
+ export declare const GROUP_HEADER_COLLAPSED_WIDTH: number;
16
+ export declare const GROUP_HEADER_COLLAPSED_HEIGHT: number;
17
+ export declare const groupHeaderBaseDims: (collapsed?: boolean) => StampTileDims;
5
18
  export declare const DEFAULT_TILE_SCALE = 1;
6
19
  export declare const TILE_SCALE_MIN = 0.4;
7
20
  export declare const TILE_SCALE_MAX = 2;
@@ -15,4 +28,4 @@ export interface StampTileDims {
15
28
  width: number;
16
29
  height: number;
17
30
  }
18
- export declare const stampTileDims: (m: Pick<PlacedMeasurementRef, "scale" | "isGroupHeader">, tileScaleFactor?: number, viewportScale?: number) => StampTileDims;
31
+ export declare const stampTileDims: (m: Pick<PlacedMeasurementRef, "scale" | "isGroupHeader" | "collapsed">, tileScaleFactor?: number, viewportScale?: number) => StampTileDims;
@@ -15,13 +15,48 @@
15
15
  // tile-scale slider against blank tiles saw their tiles balloon once
16
16
  // populated (Asana 1216233394800417).
17
17
  export const STAMP_TILE_SIZE = 96;
18
- // Base footprint of a group-header chip (`isGroupHeader`) — a wide pill
19
- // rather than the square tile, same 3.75:1 ratio as the legacy Nutrient
20
- // header stamp (150×40). Sized exactly like STAMP_TILE_SIZE: `placed.scale`
21
- // × the document-wide tile-scale factor, scaling with zoom from the
22
- // content-fit reference.
18
+ // Base footprint of an EXPANDED group-header chip (`isGroupHeader`) — a wide
19
+ // pill rather than the square tile. Sized exactly like STAMP_TILE_SIZE:
20
+ // `placed.scale` × the document-wide tile-scale factor, scaling with zoom from
21
+ // the content-fit reference. The width is the header's established base and
22
+ // does not change; the height now follows the design's 200×56 proportion
23
+ // (see GROUP_HEADER_DESIGN below) rather than the legacy Nutrient stamp's
24
+ // 150×40, a ~5% difference on existing documents.
23
25
  export const GROUP_HEADER_TILE_WIDTH = 240;
24
- export const GROUP_HEADER_TILE_HEIGHT = 64;
26
+ export const GROUP_HEADER_TILE_HEIGHT = 67.2;
27
+ // Natural design-space size of each header state, straight off the Figma
28
+ // frames (491:5371 expanded / 491:5654 collapsed — the two form variants,
29
+ // 491:5381 / 491:5659, use the same boxes). Renderers lay the chip out at
30
+ // THESE dimensions and uniformly transform-scale it into the overlay box, the
31
+ // same natural-size-then-scale pattern the measurement tiles use, so type and
32
+ // badges keep their proportions under the tile-scale slider. Collapsed is one
33
+ // fixed box for both group and form headers — a form stacks its two badges to
34
+ // fill it, a group centers its single badge — so a canvas of collapsed
35
+ // headers reads as a tidy row of equal chips.
36
+ export const GROUP_HEADER_DESIGN = {
37
+ expanded: { width: 200, height: 56 },
38
+ collapsed: { width: 90, height: 65 },
39
+ };
40
+ // Design units → base units. Pinned by the expanded header keeping its
41
+ // established 240-unit width, and applied to BOTH states so the two share one
42
+ // scale: a badge is the same size whether its header is expanded or collapsed.
43
+ const HEADER_BASE_SCALE = GROUP_HEADER_TILE_WIDTH / GROUP_HEADER_DESIGN.expanded.width;
44
+ // Base footprint of a COLLAPSED group-header chip — the design's 90×65 at the
45
+ // same scale. Wider-than-tall no longer holds here: a collapsed header is
46
+ // slightly TALLER than an expanded one (it stacks badges where the expanded
47
+ // one puts them side by side and hangs the name underneath).
48
+ export const GROUP_HEADER_COLLAPSED_WIDTH = GROUP_HEADER_DESIGN.collapsed.width * HEADER_BASE_SCALE;
49
+ export const GROUP_HEADER_COLLAPSED_HEIGHT = GROUP_HEADER_DESIGN.collapsed.height * HEADER_BASE_SCALE;
50
+ // Unscaled footprint of a group header in each state — the one place the
51
+ // expanded/collapsed box choice is made. Callers that already hold a
52
+ // PlacedMeasurementRef should go through stampTileDims instead, which folds in
53
+ // `scale`, the document-wide factor and the zoom.
54
+ export const groupHeaderBaseDims = (collapsed) => collapsed
55
+ ? {
56
+ width: GROUP_HEADER_COLLAPSED_WIDTH,
57
+ height: GROUP_HEADER_COLLAPSED_HEIGHT,
58
+ }
59
+ : { width: GROUP_HEADER_TILE_WIDTH, height: GROUP_HEADER_TILE_HEIGHT };
25
60
  // Document-wide tile scale factor: one knob that shrinks/grows EVERY
26
61
  // measurement tile on the canvas at once, on top of each tile's own `scale`.
27
62
  // Lets a user pull tiles down on a dense drawing where lines crowd together,
@@ -103,10 +138,9 @@ export const isUnassociatedStamp = (m) => !m.isGroupHeader && !m.measurementId &
103
138
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => STAMP_TILE_SIZE * (m.scale ?? 1) * tileScaleFactor * viewportScale;
104
139
  export const stampTileDims = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => {
105
140
  const k = (m.scale ?? 1) * tileScaleFactor * viewportScale;
106
- return m.isGroupHeader
107
- ? {
108
- width: GROUP_HEADER_TILE_WIDTH * k,
109
- height: GROUP_HEADER_TILE_HEIGHT * k,
110
- }
111
- : { width: STAMP_TILE_SIZE * k, height: STAMP_TILE_SIZE * k };
141
+ if (!m.isGroupHeader) {
142
+ return { width: STAMP_TILE_SIZE * k, height: STAMP_TILE_SIZE * k };
143
+ }
144
+ const base = groupHeaderBaseDims(m.collapsed);
145
+ return { width: base.width * k, height: base.height * k };
112
146
  };
@@ -1,3 +1,4 @@
1
+ import { backgroundLayerDocRect, backgroundLayersOf, } from '../backgroundLayers.js';
1
2
  import { stampTileDims } from '../stampLayout.js';
2
3
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
4
  import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
@@ -16,6 +17,12 @@ const isTextShape = (doc, id) => doc.shapes.some((s) => s.id === id && s.kind ==
16
17
  // tap (shape not yet selected) leaves this null, so it only selects; the second
17
18
  // tap edits. Cleared on drag, release, and cancel.
18
19
  let pendingTextEditId = null;
20
+ // Tap-toggle for background layers, latched the same way (and for the same
21
+ // native synchronous down+up reason) as pendingTextEditId: a pointer-down on
22
+ // an ALREADY-selected background records its id here; a release with no
23
+ // movement then deselects it. Movement disarms it — the gesture was a body
24
+ // drag, not a toggle. Cleared on drag, release, and cancel.
25
+ let pendingBackgroundDeselectId = null;
19
26
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
20
27
  // override via `hitTest` for more precision later.
21
28
  const hitStroke = (stroke, p) => {
@@ -100,6 +107,25 @@ tileScaleFactor = doc.tileScaleFactor) => {
100
107
  return null;
101
108
  };
102
109
  const translatePatch = (elementKind, id, doc, delta) => {
110
+ if (elementKind === 'background') {
111
+ // Looked up through the normalized view so a legacy single-image doc's
112
+ // synthesized layer is draggable too — the dedicated op materializes the
113
+ // array on apply.
114
+ const layer = backgroundLayerById(doc, id);
115
+ if (!layer)
116
+ return null;
117
+ return {
118
+ op: 'updateBackgroundImage',
119
+ id,
120
+ patch: {
121
+ transform: {
122
+ ...layer.transform,
123
+ x: layer.transform.x + delta.x,
124
+ y: layer.transform.y + delta.y,
125
+ },
126
+ },
127
+ };
128
+ }
103
129
  if (elementKind === 'measurement') {
104
130
  const m = doc.placedMeasurements.find((x) => x.id === id);
105
131
  if (!m)
@@ -417,17 +443,83 @@ const shapeCornerPatch = (doc, id, corner, delta) => {
417
443
  ],
418
444
  };
419
445
  };
446
+ // --- Background layers (shared by the native UI-thread drag via
447
+ // DragSelectionConfig AND the web pointer handlers — one source of truth).
448
+ // Backgrounds can cover the whole document, so they never join the general
449
+ // hit-test: a tap selects one only when every stroke/shape/measurement
450
+ // misses, and a drag translates one only when it is ALREADY selected — the
451
+ // layout tool's two-step model, which keeps a full-bleed background from
452
+ // hijacking pan/marquee gestures. ---
453
+ // Doc-space clamp on a resized background's larger doc dimension. The floor
454
+ // keeps a corner drag from collapsing a layer below grabbable size; the
455
+ // ceiling keeps the doc rect (which feeds fit math and the web SVG raster
456
+ // planner) from exploding to numerically silly sizes.
457
+ const MIN_BACKGROUND_DOC_EXTENT = 16;
458
+ const MAX_BACKGROUND_DOC_EXTENT = 65536;
459
+ const backgroundLayerById = (doc, id) => backgroundLayersOf(doc.viewport).find((l) => l.id === id) ?? null;
460
+ const pointInBackgroundLayer = (layer, p) => {
461
+ const r = backgroundLayerDocRect(layer);
462
+ return (p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height);
463
+ };
464
+ // Topmost background layer under a world point (reverse array order — later
465
+ // layers draw on top). Point-in-doc-rect, no padding: a background is a big
466
+ // target already.
467
+ const findBackgroundHit = (doc, world) => {
468
+ const layers = backgroundLayersOf(doc.viewport);
469
+ for (let i = layers.length - 1; i >= 0; i--) {
470
+ if (pointInBackgroundLayer(layers[i], world)) {
471
+ return { id: layers[i].id };
472
+ }
473
+ }
474
+ return null;
475
+ };
476
+ // Topmost ALREADY-selected background layer under a world point. The drag
477
+ // paths call this only after the general hit-test misses, so annotation
478
+ // elements always win a grab over the background beneath them.
479
+ const findSelectedBackgroundHit = (doc, world, selectedIds) => {
480
+ const layers = backgroundLayersOf(doc.viewport);
481
+ for (let i = layers.length - 1; i >= 0; i--) {
482
+ const layer = layers[i];
483
+ if (!selectedIds.includes(layer.id))
484
+ continue;
485
+ if (pointInBackgroundLayer(layer, world)) {
486
+ return { id: layer.id, kind: 'background' };
487
+ }
488
+ }
489
+ return null;
490
+ };
491
+ // Corner-scale geometry for a background layer: pivot = the doc rect's
492
+ // top-left — the layer's transform origin, so scaling about it multiplies
493
+ // scaleX/scaleY while x/y stay put — and handle = the EXACT bottom-right
494
+ // corner (the selection chrome draws it un-padded, unlike the padded text
495
+ // box; the grab tolerance lives in the hit radius, not the geometry).
496
+ const backgroundResizeGeometry = (layer) => {
497
+ const r = backgroundLayerDocRect(layer);
498
+ const maxDim = Math.max(r.width, r.height);
499
+ if (!(maxDim > 0))
500
+ return null;
501
+ return {
502
+ pivot: { x: r.x, y: r.y },
503
+ handle: { x: r.x + r.width, y: r.y + r.height },
504
+ minScale: MIN_BACKGROUND_DOC_EXTENT / maxDim,
505
+ maxScale: MAX_BACKGROUND_DOC_EXTENT / maxDim,
506
+ };
507
+ };
420
508
  // --- Text-shape resize (corner-scale about the top-left anchor; shared by the
421
509
  // native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
422
- // Resize geometry when the (selected) text shape's corner handle is under
423
- // `world`, else null. The handle is drawn on the padded selection box's
424
- // bottom-right corner (see AnnotationCanvasSkia); grab radius matches the
425
- // measurement endpoint handles.
510
+ // Resize geometry when the (selected) text shape's or background layer's
511
+ // corner handle is under `world`, else null. A text handle sits on the padded
512
+ // selection box's bottom-right corner, a background handle on its exact doc
513
+ // rect corner (see AnnotationCanvasSkia); grab radius matches the measurement
514
+ // endpoint handles either way.
426
515
  const findResizeHandleHit = (doc, id, world, zoom) => {
427
516
  const s = doc.shapes.find((x) => x.id === id);
428
- if (!s)
429
- return null;
430
- const geom = textResizeGeometry(s);
517
+ const layer = s ? null : backgroundLayerById(doc, id);
518
+ const geom = s
519
+ ? textResizeGeometry(s)
520
+ : layer
521
+ ? backgroundResizeGeometry(layer)
522
+ : null;
431
523
  if (!geom)
432
524
  return null;
433
525
  const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
@@ -435,13 +527,38 @@ const findResizeHandleHit = (doc, id, world, zoom) => {
435
527
  const dy = world.y - geom.handle.y;
436
528
  return dx * dx + dy * dy <= r2 ? geom : null;
437
529
  };
438
- // Scale the text shape's fontSize by the drag (clamped to the geometry's
439
- // scale range, so it matches the native live preview exactly). The anchor
440
- // the scale pivot is untouched.
530
+ // Scale the element by the drag (clamped to the geometry's scale range, so it
531
+ // matches the native live preview exactly). The pivot is untouched: a text
532
+ // shape keeps its anchor and bakes the scale into fontSize; a background keeps
533
+ // transform.x/y — its top-left IS the scale pivot — and bakes the scale into
534
+ // scaleX/scaleY (both axes, so the aspect ratio is preserved even on layers
535
+ // synthesized from a legacy 'stretch' fit).
441
536
  const resizePatch = (doc, id, delta) => {
442
537
  const s = doc.shapes.find((x) => x.id === id);
443
- if (!s)
444
- return null;
538
+ if (!s) {
539
+ const layer = backgroundLayerById(doc, id);
540
+ if (!layer)
541
+ return null;
542
+ const geom = backgroundResizeGeometry(layer);
543
+ if (!geom)
544
+ return null;
545
+ const scale = resizeScaleFromDrag(geom, delta);
546
+ return {
547
+ ops: [
548
+ {
549
+ op: 'updateBackgroundImage',
550
+ id,
551
+ patch: {
552
+ transform: {
553
+ ...layer.transform,
554
+ scaleX: layer.transform.scaleX * scale,
555
+ scaleY: layer.transform.scaleY * scale,
556
+ },
557
+ },
558
+ },
559
+ ],
560
+ };
561
+ }
445
562
  const geom = textResizeGeometry(s);
446
563
  if (!geom)
447
564
  return null;
@@ -497,6 +614,8 @@ export const createSelectTool = (options = {}) => ({
497
614
  buildShapeEndpointPatch: shapeEndpointPatch,
498
615
  hitTestResizeHandle: findResizeHandleHit,
499
616
  buildResizePatch: resizePatch,
617
+ hitTestBackground: findBackgroundHit,
618
+ hitTestSelectedBackground: findSelectedBackgroundHit,
500
619
  hitTestRectCorner: findRectCornerHit,
501
620
  buildRectCornerPatch: rectCornerPatch,
502
621
  hitTestShapeCorner: findShapeCornerHit,
@@ -508,10 +627,12 @@ export const createSelectTool = (options = {}) => ({
508
627
  onPointerDown(event, ctx) {
509
628
  const { world } = event;
510
629
  const zoom = ctx.viewport.state.zoom;
511
- // Reset the re-tap-to-edit latch; only a body grab of an already-selected
512
- // text shape (below) re-arms it. Grabbing a handle or empty canvas leaves
513
- // it cleared, so neither can leak an edit into the next release.
630
+ // Reset the re-tap latches; only a body grab of an already-selected text
631
+ // shape / background layer (below) re-arms them. Grabbing a handle or
632
+ // empty canvas leaves them cleared, so neither can leak an edit or a
633
+ // deselect into the next release.
514
634
  pendingTextEditId = null;
635
+ pendingBackgroundDeselectId = null;
515
636
  // Endpoint/resize handles show only on the selected element — check first,
516
637
  // UNLESS the grab is on that element's tile: the tile is the move/slide
517
638
  // affordance and must win over a handle sitting under it, so a selected
@@ -549,7 +670,12 @@ export const createSelectTool = (options = {}) => ({
549
670
  return {
550
671
  kind: 'dragging',
551
672
  id: selId,
552
- elementKind: 'shape',
673
+ // findResizeHandleHit serves both text shapes and background
674
+ // layers; only the state's bookkeeping cares which one this is
675
+ // (dragPatch keys 'resize' off the mode alone).
676
+ elementKind: backgroundLayerById(ctx.document, selId)
677
+ ? 'background'
678
+ : 'shape',
553
679
  mode: 'resize',
554
680
  start: world,
555
681
  delta: { x: 0, y: 0 },
@@ -584,6 +710,30 @@ export const createSelectTool = (options = {}) => ({
584
710
  }
585
711
  const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor);
586
712
  if (!hit) {
713
+ // Background layers only get a look-in when every annotation element
714
+ // misses — they can cover the whole document, so elements above always
715
+ // win. An already-selected background grabs as a body drag (and arms
716
+ // the tap-toggle: a release with no movement deselects it).
717
+ const selectedBg = findSelectedBackgroundHit(ctx.document, world, ctx.selection?.ids ?? []);
718
+ if (selectedBg) {
719
+ pendingBackgroundDeselectId = selectedBg.id;
720
+ return {
721
+ kind: 'dragging',
722
+ id: selectedBg.id,
723
+ elementKind: 'background',
724
+ mode: 'move',
725
+ start: world,
726
+ delta: { x: 0, y: 0 },
727
+ };
728
+ }
729
+ // An unselected background selects only — the gesture is consumed (no
730
+ // pan, no drag): translating it takes a second gesture once selected,
731
+ // the layout tool's two-step model.
732
+ const bg = findBackgroundHit(ctx.document, world);
733
+ if (bg) {
734
+ ctx.setSelection({ ids: [bg.id] });
735
+ return { kind: 'idle' };
736
+ }
587
737
  ctx.setSelection(null);
588
738
  // Nothing under the pointer: with panOnEmptyDrag the gesture pans the
589
739
  // viewport instead of dead-ending, so bare drags navigate the canvas.
@@ -630,9 +780,12 @@ export const createSelectTool = (options = {}) => ({
630
780
  x: event.world.x - s.start.x,
631
781
  y: event.world.y - s.start.y,
632
782
  };
633
- // Any real movement turns this into a drag, not a re-tap — disarm the edit.
634
- if (delta.x !== 0 || delta.y !== 0)
783
+ // Any real movement turns this into a drag, not a re-tap — disarm the
784
+ // edit and the background deselect toggle.
785
+ if (delta.x !== 0 || delta.y !== 0) {
635
786
  pendingTextEditId = null;
787
+ pendingBackgroundDeselectId = null;
788
+ }
636
789
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
637
790
  if (patch)
638
791
  ctx.preview(patch);
@@ -641,14 +794,24 @@ export const createSelectTool = (options = {}) => ({
641
794
  onPointerUp(_event, ctx, state) {
642
795
  const editId = pendingTextEditId;
643
796
  pendingTextEditId = null;
797
+ const deselectId = pendingBackgroundDeselectId;
798
+ pendingBackgroundDeselectId = null;
644
799
  const s = state;
645
- // A moved selection commits its drag and is never a tap-to-edit.
800
+ // A moved selection commits its drag and is never a tap-to-edit/-toggle.
646
801
  if (s?.kind === 'dragging' && (s.delta.x !== 0 || s.delta.y !== 0)) {
647
802
  const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
648
803
  if (patch)
649
804
  ctx.commit(patch);
650
805
  return;
651
806
  }
807
+ // No movement on an already-selected background: the tap toggles it back
808
+ // off. `deselectId` was latched on the down for the same synchronous
809
+ // native down+up reason as `editId` below.
810
+ if (deselectId) {
811
+ if (ctx.selection?.ids.includes(deselectId))
812
+ ctx.setSelection(null);
813
+ return;
814
+ }
652
815
  // No movement: re-tapping an already-selected text shape re-opens its
653
816
  // editor (the same edit flow as tapping it with the text tool, via the
654
817
  // shared editTextShape). `editId` was latched on the down, so this survives
@@ -661,6 +824,7 @@ export const createSelectTool = (options = {}) => ({
661
824
  },
662
825
  onCancel(_state, ctx) {
663
826
  pendingTextEditId = null;
827
+ pendingBackgroundDeselectId = null;
664
828
  ctx.preview({ ops: [] });
665
829
  },
666
830
  hitTest(element, p) {
@@ -46,6 +46,7 @@ export interface AnnotationCanvasHandle {
46
46
  placeGroupHeaderAtCenter(ref: {
47
47
  groupId: string;
48
48
  }): AnnotationElementId;
49
+ setGroupHeaderCollapsed(id: AnnotationElementId, collapsed: boolean): void;
49
50
  deleteSelected(): void;
50
51
  }
51
52
  export interface UseAnnotationCanvasStateProps {
@@ -1,13 +1,15 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
3
  import { resolveTileScaleFactor, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
4
- import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
+ import { createViewportApi, fitRectToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
5
+ import { backgroundLayerDocRect, backgroundLayersOf, backgroundLayersUnionRect, } from './backgroundLayers.js';
5
6
  import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
6
7
  // The viewport that frames the document's content inside a screen rect of the
7
- // canvas box. When a background image is present we fit its rendered rect (so
8
- // the whole image shows, filling the rect regardless of its pixel resolution —
9
- // never the native-resolution top-left crop a 1:1 viewport gives a high-res
10
- // image); otherwise we fit the document rect. Placed tiles count as content
8
+ // canvas box. When background images are present we fit the union of their
9
+ // rendered rects (so every image shows whole, filling the rect regardless of
10
+ // pixel resolution — never the native-resolution top-left crop a 1:1 viewport
11
+ // gives a high-res image); otherwise we fit the document rect. Placed tiles
12
+ // count as content
11
13
  // too: authors routinely drop them AROUND the image (dimension labels sit
12
14
  // outside the object they measure), and a fit framed on the image alone opens
13
15
  // with those tiles off-screen — so the rect is widened to the union of the
@@ -22,10 +24,12 @@ const computeContentFitRect = (canvas, rect) => {
22
24
  if (!(rect.width > 0) || !(rect.height > 0))
23
25
  return DEFAULT_VIEWPORT;
24
26
  const { viewport } = canvas;
25
- const bg = viewport.backgroundImage;
26
- const baseRect = bg
27
- ? imageDocRect(bg.widthPx, bg.heightPx, viewport.width, viewport.height, viewport.backgroundFit ?? 'contain')
28
- : { x: 0, y: 0, width: viewport.width, height: viewport.height };
27
+ const baseRect = backgroundLayersUnionRect(backgroundLayersOf(viewport)) ?? {
28
+ x: 0,
29
+ y: 0,
30
+ width: viewport.width,
31
+ height: viewport.height,
32
+ };
29
33
  let minX = baseRect.x;
30
34
  let minY = baseRect.y;
31
35
  let maxX = baseRect.x + baseRect.width;
@@ -64,20 +68,25 @@ const computeContentFitRect = (canvas, rect) => {
64
68
  };
65
69
  };
66
70
  const computeContentFit = (canvas, width, height) => computeContentFitRect(canvas, { x: 0, y: 0, width, height });
67
- // Content-fit zoom of the DOCUMENT alone — the background image's rendered
68
- // rect when present, the document rect otherwise, deliberately ignoring
69
- // placed tiles — into the full canvas box. This is the stable reference zoom
70
- // for tile sizing (see tileViewportScale below): at this zoom a tile renders
71
- // at exactly its slider size. computeContentFitRect's tile-extended union
72
- // would make the reference move whenever a tile is dragged outside the image
73
- // (every tile on the canvas would resize); the document rect can't.
71
+ // Content-fit zoom of the DOCUMENT alone — the FIRST background layer's
72
+ // rendered rect when present, the document rect otherwise, deliberately
73
+ // ignoring placed tiles — into the full canvas box. This is the stable
74
+ // reference zoom for tile sizing (see tileViewportScale below): at this zoom
75
+ // a tile renders at exactly its slider size. computeContentFitRect's
76
+ // tile-extended union would make the reference move whenever a tile is
77
+ // dragged outside the image (every tile on the canvas would resize); the
78
+ // document rect can't. The reference is pinned to the first layer — never
79
+ // the layer union — for the same reason: adding, dragging, or scaling a
80
+ // SECOND image must not silently resize (and re-hit-test) every placed tile
81
+ // on the canvas. For a single-image doc the first layer's rect is exactly
82
+ // the legacy fit rect, so existing canvases keep their tile sizes.
74
83
  const computeBaseFitZoom = (canvas, width, height) => {
75
84
  if (!(width > 0) || !(height > 0))
76
85
  return 1;
77
86
  const { viewport } = canvas;
78
- const bg = viewport.backgroundImage;
79
- const baseRect = bg
80
- ? imageDocRect(bg.widthPx, bg.heightPx, viewport.width, viewport.height, viewport.backgroundFit ?? 'contain')
87
+ const first = backgroundLayersOf(viewport)[0];
88
+ const baseRect = first
89
+ ? backgroundLayerDocRect(first)
81
90
  : { x: 0, y: 0, width: viewport.width, height: viewport.height };
82
91
  return fitRectToScreen(baseRect, width, height).zoom;
83
92
  };
@@ -120,7 +129,7 @@ export const useAnnotationCanvasState = (props) => {
120
129
  useEffect(() => {
121
130
  if (didInitialFitRef.current)
122
131
  return;
123
- if (!canvas.viewport.backgroundImage)
132
+ if (backgroundLayersOf(canvas.viewport).length === 0)
124
133
  return;
125
134
  if (!(width > 0) || !(height > 0))
126
135
  return;
@@ -652,6 +661,17 @@ export const useAnnotationCanvasState = (props) => {
652
661
  c.setSelection({ ids: [placed.id] });
653
662
  return placed.id;
654
663
  },
664
+ setGroupHeaderCollapsed(id, collapsed) {
665
+ const c = ctxRef.current;
666
+ const target = c.document.placedMeasurements.find((m) => m.id === id);
667
+ if (!target?.isGroupHeader)
668
+ return;
669
+ if (!!target.collapsed === collapsed)
670
+ return;
671
+ c.commit({
672
+ ops: [{ op: 'updateMeasurement', id, patch: { collapsed } }],
673
+ });
674
+ },
655
675
  deleteSelected() {
656
676
  const c = ctxRef.current;
657
677
  const ids = c.selection?.ids;
@@ -29,13 +29,14 @@ export const fitToScreen = (docWidth, docHeight, screenWidth, screenHeight, padd
29
29
  pan: { x: -offsetX / zoom, y: -offsetY / zoom },
30
30
  };
31
31
  };
32
- // The doc-space rectangle a background image occupies, given the image's
33
- // intrinsic pixel size, the document dimensions, and the fit mode. This mirrors
34
- // the math in BackgroundImageElement (which renders the image into this rect),
35
- // so "fit the image to the screen" lines up exactly with what is drawn.
32
+ // The doc-space rectangle a legacy fit-placed background image occupies,
33
+ // given the image's intrinsic pixel size, the document dimensions, and the
34
+ // fit mode. backgroundLayersOf synthesizes the legacy slot's layer transform
35
+ // from this rect, so "fit the image to the screen" lines up exactly with what
36
+ // is drawn.
36
37
  export const imageDocRect = (imgW, imgH, docW, docH, fit = 'contain') => {
37
- // Unknown/zero image dimensions (or an explicit stretch) fill the doc — the
38
- // same guard BackgroundImageElement uses to avoid NaN geometry.
38
+ // Unknown/zero image dimensions (or an explicit stretch) fill the doc,
39
+ // avoiding NaN geometry from a division by zero.
39
40
  if (!(imgW > 0) || !(imgH > 0) || fit === 'stretch') {
40
41
  return { x: 0, y: 0, width: docW, height: docH };
41
42
  }
@@ -75,7 +75,7 @@ export interface AnnotationDataProvider {
75
75
  list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void, onError?: (err: Error) => void): Unsubscribe;
76
76
  subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
77
77
  subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
78
- uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
78
+ uploadImage(scope: AnnotationScope, fileId: string, role: string, blob: ImageBlob): Promise<UploadedImageRef>;
79
79
  getImageUrl(scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
80
80
  deleteImage(scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
81
81
  }