@reekon-tools/boldr-utils 1.11.0 → 1.12.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.
@@ -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) {
@@ -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;
@@ -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
  }
@@ -18,7 +18,7 @@ export declare class InMemoryAnnotationProvider implements AnnotationDataProvide
18
18
  list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void): Unsubscribe;
19
19
  subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
20
20
  subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
21
- uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
21
+ uploadImage(scope: AnnotationScope, fileId: string, role: string, blob: ImageBlob): Promise<UploadedImageRef>;
22
22
  getImageUrl(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
23
23
  deleteImage(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
24
24
  private getBucket;
@@ -1,4 +1,4 @@
1
- import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport, type BackgroundFit } from '../../../types/annotation.js';
1
+ import { type AnnotationBackgroundLayer, type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport, type BackgroundFit } from '../../../types/annotation.js';
2
2
  import type { AnnotationScope, ImageBlob } from '../AnnotationDataProvider.js';
3
3
  export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
4
4
  export interface UseAnnotationCanvasDocOptions {
@@ -26,6 +26,12 @@ export interface UseAnnotationCanvasDocResult {
26
26
  save: () => Promise<void>;
27
27
  refreshThumbnail: () => Promise<void>;
28
28
  ensureFileId: () => Promise<string>;
29
+ addBackgroundImage: (blob: ImageBlob, dims: {
30
+ width: number;
31
+ height: number;
32
+ }) => Promise<string>;
33
+ updateBackgroundImage: (id: string, patch: Partial<Omit<AnnotationBackgroundLayer, 'id'>>) => void;
34
+ removeBackgroundImage: (id: string) => Promise<void>;
29
35
  setBackgroundImage: (blob: ImageBlob, dims: {
30
36
  width: number;
31
37
  height: number;
@@ -1,5 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, createEmptyCanvasState, } from '../../../types/annotation.js';
3
+ import { backgroundLayersOf, placeNewBackgroundLayer, } from '../../canvas/backgroundLayers.js';
4
+ import { imageDocRect } from '../../canvas/viewport.js';
3
5
  import { FileUploadType } from '../../../types/firestore.js';
4
6
  import { useAnnotationDoc } from './useAnnotationDoc.js';
5
7
  import { useAnnotationMutations } from './useAnnotationMutations.js';
@@ -26,6 +28,15 @@ const buildFileData = (fileType, isLabel, canvas, canvasRev) => ({
26
28
  // AnnotationFileData). Uniqueness only needs to hold across the handful of
27
29
  // clients that ever touch one annotation doc.
28
30
  const makeClientId = () => `${Date.now().toString(36)}-${Math.floor(Math.random() * 0x100000000).toString(36)}`;
31
+ // Background layer ids carry the same uniqueness contract as the tools'
32
+ // stroke/shape ids (timestamp + module counter): unique within one document
33
+ // across the handful of clients that touch it. Never 'background-legacy' —
34
+ // that id is reserved for the layer synthesized from the legacy single slot.
35
+ let backgroundIdCounter = 0;
36
+ const makeBackgroundLayerId = () => `background-${Date.now().toString(36)}-${(backgroundIdCounter++).toString(36)}`;
37
+ // The renderer decodes 'svg' via Skia's SVG module and everything else via
38
+ // the bitmap decoder, so an SVG left unstamped silently fails to draw.
39
+ const backgroundFormatOf = (blob) => blob.contentType === 'image/svg+xml' ? 'svg' : 'raster';
29
40
  // Orchestrates load + auto-save for the annotation canvas. Hydrates the working
30
41
  // state from the persisted doc, applies commits optimistically, and persists
31
42
  // (debounced) through the data provider — creating the file on first save when
@@ -354,45 +365,133 @@ export const useAnnotationCanvasDoc = (options) => {
354
365
  }
355
366
  return id;
356
367
  }, [flush, fallbackViewport]);
357
- const setBackgroundImage = useCallback(async (blob, dims, fit = 'contain') => {
358
- const id = await ensureFileId();
359
- const ref = await uploadImage(id, 'background', blob);
368
+ const addBackgroundImage = useCallback(async (blob, dims) => {
369
+ const fileId = await ensureFileId();
370
+ const layerId = makeBackgroundLayerId();
371
+ const ref = await uploadImage(fileId, `backgrounds/${layerId}`, blob);
372
+ // ensureFileId seeds the working state for a never-saved canvas, but a
373
+ // still-hydrating existing doc can reach here with no working state —
374
+ // fall back to the same empty canvas hydration would start from.
375
+ const { viewport } = workingRef.current ?? createEmptyCanvasState(fallbackViewport);
360
376
  onCommit({
361
377
  ops: [
362
378
  {
363
- op: 'setViewport',
364
- patch: {
365
- backgroundImage: {
366
- storagePath: ref.storagePath,
367
- downloadUrl: ref.downloadUrl,
368
- widthPx: dims.width,
369
- heightPx: dims.height,
370
- },
371
- backgroundFit: fit,
379
+ op: 'addBackgroundImage',
380
+ image: {
381
+ id: layerId,
382
+ storagePath: ref.storagePath,
383
+ downloadUrl: ref.downloadUrl,
384
+ widthPx: dims.width,
385
+ heightPx: dims.height,
386
+ format: backgroundFormatOf(blob),
387
+ transform: placeNewBackgroundLayer(backgroundLayersOf(viewport), dims, viewport),
372
388
  },
373
389
  },
374
390
  ],
375
391
  });
376
- }, [ensureFileId, uploadImage, onCommit]);
377
- const clearBackgroundImage = useCallback(async () => {
378
- const bg = workingRef.current?.viewport.backgroundImage;
379
- const id = fileIdRef.current ?? createdIdRef.current;
380
- if (bg && id) {
392
+ return layerId;
393
+ }, [ensureFileId, uploadImage, onCommit, fallbackViewport]);
394
+ const updateBackgroundImage = useCallback((id, patch) => {
395
+ onCommit({ ops: [{ op: 'updateBackgroundImage', id, patch }] });
396
+ }, [onCommit]);
397
+ const removeBackgroundImage = useCallback(async (id) => {
398
+ const viewport = workingRef.current?.viewport;
399
+ const layer = viewport
400
+ ? backgroundLayersOf(viewport).find((l) => l.id === id)
401
+ : undefined;
402
+ const fileId = fileIdRef.current ?? createdIdRef.current;
403
+ if (layer && fileId) {
381
404
  try {
382
- await deleteImage(id, bg.storagePath);
405
+ await deleteImage(fileId, layer.storagePath);
383
406
  }
384
407
  catch (e) {
385
- // Non-fatal: still clear the viewport reference even if the storage
386
- // object is already gone.
408
+ // Non-fatal: still remove the layer even if the storage object is
409
+ // already gone.
387
410
  console.warn('[useAnnotationCanvasDoc] failed to delete background image', e);
388
411
  }
389
412
  }
413
+ onCommit({ ops: [{ op: 'removeBackgroundImage', id }] });
414
+ }, [deleteImage, onCommit]);
415
+ const setBackgroundImage = useCallback(async (blob, dims, fit = 'contain') => {
416
+ const fileId = await ensureFileId();
417
+ const prior = workingRef.current
418
+ ? backgroundLayersOf(workingRef.current.viewport)
419
+ : [];
420
+ // Upload before touching the prior layers' objects: a failed upload
421
+ // must leave the doc — and every object it references — intact. The
422
+ // prior objects are deleted only after the replacement is committed.
423
+ const layerId = makeBackgroundLayerId();
424
+ const ref = await uploadImage(fileId, `backgrounds/${layerId}`, blob);
425
+ const { viewport } = workingRef.current ?? createEmptyCanvasState(fallbackViewport);
426
+ // The fit argument still decides where the replacement lands (the same
427
+ // imageDocRect placement the legacy single slot rendered at), so
428
+ // existing callers keep their visual behavior. `backgroundFit` is also
429
+ // still written: old clients render the mirrored legacy slot with it.
430
+ const rect = imageDocRect(dims.width, dims.height, viewport.width, viewport.height, fit);
390
431
  onCommit({
391
432
  ops: [
433
+ ...prior.map((l) => ({
434
+ op: 'removeBackgroundImage',
435
+ id: l.id,
436
+ })),
392
437
  {
393
- op: 'setViewport',
394
- patch: { backgroundImage: undefined, backgroundFit: undefined },
438
+ op: 'addBackgroundImage',
439
+ image: {
440
+ id: layerId,
441
+ storagePath: ref.storagePath,
442
+ downloadUrl: ref.downloadUrl,
443
+ widthPx: dims.width,
444
+ heightPx: dims.height,
445
+ format: backgroundFormatOf(blob),
446
+ transform: {
447
+ x: rect.x,
448
+ y: rect.y,
449
+ scaleX: dims.width > 0 ? rect.width / dims.width : 1,
450
+ scaleY: dims.height > 0 ? rect.height / dims.height : 1,
451
+ },
452
+ },
395
453
  },
454
+ { op: 'setViewport', patch: { backgroundFit: fit } },
455
+ ],
456
+ });
457
+ for (const layer of prior) {
458
+ try {
459
+ await deleteImage(fileId, layer.storagePath);
460
+ }
461
+ catch (e) {
462
+ // Non-fatal: the replacement is already committed; an undeleted
463
+ // prior object is merely orphaned.
464
+ console.warn('[useAnnotationCanvasDoc] failed to delete background image', e);
465
+ }
466
+ }
467
+ }, [ensureFileId, uploadImage, deleteImage, onCommit, fallbackViewport]);
468
+ const clearBackgroundImage = useCallback(async () => {
469
+ const layers = workingRef.current
470
+ ? backgroundLayersOf(workingRef.current.viewport)
471
+ : [];
472
+ const id = fileIdRef.current ?? createdIdRef.current;
473
+ if (id) {
474
+ for (const layer of layers) {
475
+ try {
476
+ await deleteImage(id, layer.storagePath);
477
+ }
478
+ catch (e) {
479
+ // Non-fatal: still clear the viewport reference even if the storage
480
+ // object is already gone.
481
+ console.warn('[useAnnotationCanvasDoc] failed to delete background image', e);
482
+ }
483
+ }
484
+ }
485
+ onCommit({
486
+ ops: [
487
+ ...layers.map((l) => ({
488
+ op: 'removeBackgroundImage',
489
+ id: l.id,
490
+ })),
491
+ // A fit means nothing without a background; clearing it preserves
492
+ // this method's original contract. The explicit `undefined` is
493
+ // stripped by the write path's JSON round-trip.
494
+ { op: 'setViewport', patch: { backgroundFit: undefined } },
396
495
  ],
397
496
  });
398
497
  }, [deleteImage, onCommit]);
@@ -414,6 +513,9 @@ export const useAnnotationCanvasDoc = (options) => {
414
513
  save,
415
514
  refreshThumbnail,
416
515
  ensureFileId,
516
+ addBackgroundImage,
517
+ updateBackgroundImage,
518
+ removeBackgroundImage,
417
519
  setBackgroundImage,
418
520
  clearBackgroundImage,
419
521
  };
@@ -3,7 +3,7 @@ export interface AnnotationMutations {
3
3
  create(seed: Partial<AnnotationFile>): Promise<string>;
4
4
  update(fileId: string, patch: AnnotationFilePatch): Promise<void>;
5
5
  remove(fileId: string): Promise<void>;
6
- uploadImage(fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
6
+ uploadImage(fileId: string, role: string, blob: ImageBlob): Promise<UploadedImageRef>;
7
7
  deleteImage(fileId: string, storagePath: string): Promise<void>;
8
8
  }
9
9
  export declare const useAnnotationMutations: (scope: AnnotationScope) => AnnotationMutations;
package/dist/exports.d.ts CHANGED
@@ -31,6 +31,7 @@ export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measu
31
31
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
32
32
  export { STAMP_TILE_SIZE, GROUP_HEADER_TILE_WIDTH, GROUP_HEADER_TILE_HEIGHT, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, tileDocSizeForFactor, tileScaleFromDocSize, stampTileSize, stampTileDims, isUnassociatedStamp, type StampTileDims, } from './annotation/canvas/stampLayout.js';
33
33
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
34
+ export { LEGACY_BACKGROUND_LAYER_ID, BACKGROUND_PLACEMENT_GAP_RATIO, backgroundLayersOf, backgroundLayerDocRect, backgroundLayersUnionRect, placeNewBackgroundLayer, } from './annotation/canvas/backgroundLayers.js';
34
35
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
35
36
  export { createSelectTool, type SelectToolOptions, } from './annotation/canvas/tools/selectTool.js';
36
37
  export { createMeasurementStampTool, type MeasurementStampToolOptions, } from './annotation/canvas/tools/measurementStampTool.js';