@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.
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +48 -7
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +16 -3
- package/dist/annotation/canvas/Tool.d.ts +8 -1
- package/dist/annotation/canvas/backgroundLayers.d.ts +20 -0
- package/dist/annotation/canvas/backgroundLayers.js +98 -0
- package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +10 -7
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +45 -11
- package/dist/annotation/canvas/elements/BackgroundSvg.d.ts +3 -4
- package/dist/annotation/canvas/elements/BackgroundSvg.js +23 -29
- package/dist/annotation/canvas/elements/backgroundUrl.js +2 -2
- package/dist/annotation/canvas/tools/selectTool.js +183 -19
- package/dist/annotation/canvas/useAnnotationCanvasState.js +29 -20
- package/dist/annotation/canvas/viewport.js +7 -6
- package/dist/annotation/data/AnnotationDataProvider.d.ts +1 -1
- package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +1 -1
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.d.ts +7 -1
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +124 -22
- package/dist/annotation/data/hooks/useAnnotationMutations.d.ts +1 -1
- package/dist/exports.d.ts +1 -0
- package/dist/exports.js +1 -0
- package/dist/types/annotation.d.ts +27 -0
- package/dist/types/annotation.js +68 -0
- package/package.json +1 -1
|
@@ -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
|
|
423
|
-
// `world`, else null.
|
|
424
|
-
// bottom-right corner
|
|
425
|
-
//
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
439
|
-
//
|
|
440
|
-
// the scale
|
|
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
|
-
|
|
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
|
|
512
|
-
//
|
|
513
|
-
//
|
|
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
|
-
|
|
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
|
|
634
|
-
|
|
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,
|
|
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
|
|
8
|
-
//
|
|
9
|
-
// never the native-resolution top-left crop a 1:1 viewport
|
|
10
|
-
// image); otherwise we fit the document rect. Placed tiles
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
68
|
-
// rect when present, the document rect otherwise, deliberately
|
|
69
|
-
// placed tiles — into the full canvas box. This is the stable
|
|
70
|
-
// for tile sizing (see tileViewportScale below): at this zoom
|
|
71
|
-
// at exactly its slider size. computeContentFitRect's
|
|
72
|
-
// would make the reference move whenever a tile is
|
|
73
|
-
// (every tile on the canvas would resize); the
|
|
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
|
|
79
|
-
const baseRect =
|
|
80
|
-
?
|
|
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 (
|
|
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,
|
|
33
|
-
// intrinsic pixel size, the document dimensions, and the
|
|
34
|
-
//
|
|
35
|
-
// so "fit the image to the screen" lines up exactly with what
|
|
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
|
|
38
|
-
//
|
|
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:
|
|
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:
|
|
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
|
|
358
|
-
const
|
|
359
|
-
const
|
|
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: '
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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(
|
|
405
|
+
await deleteImage(fileId, layer.storagePath);
|
|
383
406
|
}
|
|
384
407
|
catch (e) {
|
|
385
|
-
// Non-fatal: still
|
|
386
|
-
//
|
|
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: '
|
|
394
|
-
|
|
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:
|
|
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';
|