@reekon-tools/boldr-utils 1.6.20 → 1.6.24
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.d.ts +3 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +35 -8
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +3 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +229 -13
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +11 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +51 -41
- package/dist/annotation/canvas/Tool.d.ts +8 -0
- package/dist/annotation/canvas/elements/ShapeElement.js +45 -25
- package/dist/annotation/canvas/elements/StrokeElement.js +26 -11
- package/dist/annotation/canvas/shapeGeometry.d.ts +7 -0
- package/dist/annotation/canvas/shapeGeometry.js +36 -0
- package/dist/annotation/canvas/stampLayout.d.ts +2 -4
- package/dist/annotation/canvas/stampLayout.js +31 -51
- package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/penTool.js +11 -1
- package/dist/annotation/canvas/tools/selectTool.js +165 -16
- package/dist/annotation/canvas/tools/shapeTool.d.ts +2 -0
- package/dist/annotation/canvas/tools/shapeTool.js +13 -2
- package/dist/annotation/canvas/tools/textEditing.js +2 -2
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +8 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +40 -6
- package/dist/types/annotation.d.ts +4 -0
- package/dist/types/annotation.js +8 -2
- package/package.json +1 -1
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { stampTileSize } from '../stampLayout.js';
|
|
2
2
|
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
|
|
3
|
-
import { hitShapeOutline } from '../shapeGeometry.js';
|
|
3
|
+
import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
|
|
4
4
|
import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
|
|
5
|
-
import { editTextShape
|
|
5
|
+
import { editTextShape } from './textEditing.js';
|
|
6
6
|
const HIT_PADDING = 6;
|
|
7
|
+
// Whether an element id refers to a text shape (findHit reports text, lines,
|
|
8
|
+
// rects and ellipses all as kind 'shape'; only text supports tap-to-edit).
|
|
9
|
+
const isTextShape = (doc, id) => doc.shapes.some((s) => s.id === id && s.kind === 'text');
|
|
10
|
+
// Re-tap-to-edit for text. The pointer-down records a text shape's id here only
|
|
11
|
+
// when that shape was *already* selected before this gesture; a release with no
|
|
12
|
+
// movement then re-opens its editor. Held at module scope (not in ToolState)
|
|
13
|
+
// because the native tap gesture synthesizes pointer-down and pointer-up in a
|
|
14
|
+
// single React tick — a ToolState set on down isn't visible to up, but this is.
|
|
15
|
+
// Only one select gesture is ever in flight, so a shared cell is safe. A first
|
|
16
|
+
// tap (shape not yet selected) leaves this null, so it only selects; the second
|
|
17
|
+
// tap edits. Cleared on drag, release, and cancel.
|
|
18
|
+
let pendingTextEditId = null;
|
|
7
19
|
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
8
20
|
// override via `hitTest` for more precision later.
|
|
9
21
|
const hitStroke = (stroke, p) => {
|
|
@@ -303,6 +315,105 @@ const rectCornerPatch = (doc, id, corner, delta) => {
|
|
|
303
315
|
],
|
|
304
316
|
};
|
|
305
317
|
};
|
|
318
|
+
// --- Geometric-shape corner resize (rect/ellipse/polygon). The shape twin of
|
|
319
|
+
// the rectangle-annotation corner drag above: drag one bounding-box corner to
|
|
320
|
+
// scale length and width about the opposite (fixed) corner. Shared by the
|
|
321
|
+
// native UI-thread drag via DragSelectionConfig AND the web pointer handlers. ---
|
|
322
|
+
// Doc-space floor on a resized shape's width/height. Keeps a corner drag from
|
|
323
|
+
// collapsing a shape to a line (and from crossing the fixed corner, which would
|
|
324
|
+
// mirror a triangle), so the scale stays positive on both axes. The native
|
|
325
|
+
// shapeResizeScale worklet inlines the same value — keep them in sync.
|
|
326
|
+
const MIN_SHAPE_EXTENT = 1;
|
|
327
|
+
// Which shapes get the corner-scale handles: rect (two-corner geometry, scaled
|
|
328
|
+
// exactly like the rectangle annotation), polygon/triangle (all points scaled
|
|
329
|
+
// about the fixed corner) and ellipse. Lines/arrows resize via their endpoint
|
|
330
|
+
// handles and text via its own font-size handle, so both are excluded here.
|
|
331
|
+
const isCornerResizableShape = (s) => (s.kind === 'rect' || s.kind === 'ellipse' || s.kind === 'polygon') &&
|
|
332
|
+
s.geometry.points.length >= 2;
|
|
333
|
+
// The shape's visual bounds expressed as the {a,b} rect the rectCornerPoint/
|
|
334
|
+
// oppositeRectCorner helpers consume (so shapes reuse the exact corner math the
|
|
335
|
+
// rectangle annotation already uses). Uses visualShapeBounds, so the corner
|
|
336
|
+
// handles/hit-test sit on the same box the selection outline draws — for an
|
|
337
|
+
// ellipse that's the circle's bounding square, not the raw drag rect.
|
|
338
|
+
const shapeCornerRect = (s) => {
|
|
339
|
+
const vb = visualShapeBounds(s);
|
|
340
|
+
if (!vb)
|
|
341
|
+
return null;
|
|
342
|
+
return { a: { x: vb.minX, y: vb.minY }, b: { x: vb.maxX, y: vb.maxY } };
|
|
343
|
+
};
|
|
344
|
+
// Which bounding-box corner of a (selected) resizable shape is under `world`,
|
|
345
|
+
// or null. Mirrors findRectCornerHit but reads the box from the shape's points.
|
|
346
|
+
const findShapeCornerHit = (doc, id, world, zoom) => {
|
|
347
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
348
|
+
if (!s || !isCornerResizableShape(s))
|
|
349
|
+
return null;
|
|
350
|
+
const rect = shapeCornerRect(s);
|
|
351
|
+
if (!rect)
|
|
352
|
+
return null;
|
|
353
|
+
const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
|
|
354
|
+
let best = null;
|
|
355
|
+
for (const corner of ['tl', 'tr', 'bl', 'br']) {
|
|
356
|
+
const p = rectCornerPoint(rect, corner);
|
|
357
|
+
const d = (world.x - p.x) ** 2 + (world.y - p.y) ** 2;
|
|
358
|
+
if (d <= r2 && (!best || d < best.d))
|
|
359
|
+
best = { corner, d };
|
|
360
|
+
}
|
|
361
|
+
if (!best)
|
|
362
|
+
return null;
|
|
363
|
+
return {
|
|
364
|
+
corner: best.corner,
|
|
365
|
+
moving: rectCornerPoint(rect, best.corner),
|
|
366
|
+
fixed: rectCornerPoint(rect, oppositeRectCorner(best.corner)),
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
// Scale every geometry point about the fixed (opposite) corner so the grabbed
|
|
370
|
+
// corner follows the drag. The moving corner is clamped so it can't cross or
|
|
371
|
+
// collapse onto the fixed corner (min extent on each axis), keeping the scale
|
|
372
|
+
// positive — no flip, no zero-size shape.
|
|
373
|
+
//
|
|
374
|
+
// rect/polygon scale length and width INDEPENDENTLY (a two-point rect's grabbed
|
|
375
|
+
// corner tracks the finger; a polygon's whole outline scales). An ellipse
|
|
376
|
+
// renders as a circle (radius = half its larger extent), so it scales
|
|
377
|
+
// UNIFORMLY off the diagonal instead — otherwise a non-uniform drag would
|
|
378
|
+
// preview as an oval and then snap back to a circle on commit.
|
|
379
|
+
const shapeCornerPatch = (doc, id, corner, delta) => {
|
|
380
|
+
const s = doc.shapes.find((x) => x.id === id);
|
|
381
|
+
if (!s || !isCornerResizableShape(s))
|
|
382
|
+
return null;
|
|
383
|
+
const rect = shapeCornerRect(s);
|
|
384
|
+
if (!rect)
|
|
385
|
+
return null;
|
|
386
|
+
const fixed = rectCornerPoint(rect, oppositeRectCorner(corner));
|
|
387
|
+
const moving = rectCornerPoint(rect, corner);
|
|
388
|
+
const denomX = moving.x - fixed.x;
|
|
389
|
+
const denomY = moving.y - fixed.y;
|
|
390
|
+
// Clamp the dragged offset to the same side of the fixed corner, magnitude
|
|
391
|
+
// >= MIN_SHAPE_EXTENT, so the scale factor never flips sign or hits zero.
|
|
392
|
+
const clampOffset = (offset, sign) => sign >= 0
|
|
393
|
+
? Math.max(MIN_SHAPE_EXTENT, offset)
|
|
394
|
+
: Math.min(-MIN_SHAPE_EXTENT, offset);
|
|
395
|
+
const offX = clampOffset(moving.x + delta.x - fixed.x, denomX >= 0 ? 1 : -1);
|
|
396
|
+
const offY = clampOffset(moving.y + delta.y - fixed.y, denomY >= 0 ? 1 : -1);
|
|
397
|
+
let sx = denomX !== 0 ? offX / denomX : 1;
|
|
398
|
+
let sy = denomY !== 0 ? offY / denomY : 1;
|
|
399
|
+
if (s.kind === 'ellipse') {
|
|
400
|
+
// Uniform: ratio of the new corner-distance to the old, so the circle
|
|
401
|
+
// grows/shrinks about the fixed corner without distorting.
|
|
402
|
+
const oldDiag = Math.hypot(denomX, denomY);
|
|
403
|
+
const s0 = oldDiag !== 0 ? Math.hypot(offX, offY) / oldDiag : 1;
|
|
404
|
+
sx = s0;
|
|
405
|
+
sy = s0;
|
|
406
|
+
}
|
|
407
|
+
const points = s.geometry.points.map((p) => ({
|
|
408
|
+
x: fixed.x + (p.x - fixed.x) * sx,
|
|
409
|
+
y: fixed.y + (p.y - fixed.y) * sy,
|
|
410
|
+
}));
|
|
411
|
+
return {
|
|
412
|
+
ops: [
|
|
413
|
+
{ op: 'updateShape', id, patch: { geometry: { ...s.geometry, points } } },
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
};
|
|
306
417
|
// --- Text-shape resize (corner-scale about the top-left anchor; shared by the
|
|
307
418
|
// native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
|
|
308
419
|
// Resize geometry when the (selected) text shape's corner handle is under
|
|
@@ -355,6 +466,9 @@ const dragPatch = (s, doc, delta, zoom) => {
|
|
|
355
466
|
if (s.mode === 'rect-corner' && s.corner) {
|
|
356
467
|
return rectCornerPatch(doc, s.id, s.corner, delta);
|
|
357
468
|
}
|
|
469
|
+
if (s.mode === 'shape-corner' && s.corner) {
|
|
470
|
+
return shapeCornerPatch(doc, s.id, s.corner, delta);
|
|
471
|
+
}
|
|
358
472
|
const op = translatePatch(s.elementKind, s.id, doc, delta);
|
|
359
473
|
return op ? { ops: [op] } : null;
|
|
360
474
|
};
|
|
@@ -382,6 +496,8 @@ export const createSelectTool = () => ({
|
|
|
382
496
|
buildResizePatch: resizePatch,
|
|
383
497
|
hitTestRectCorner: findRectCornerHit,
|
|
384
498
|
buildRectCornerPatch: rectCornerPatch,
|
|
499
|
+
hitTestShapeCorner: findShapeCornerHit,
|
|
500
|
+
buildShapeCornerPatch: shapeCornerPatch,
|
|
385
501
|
},
|
|
386
502
|
// Web pointer path. Mirrors the native UI-thread drag using the same shared
|
|
387
503
|
// helpers: an endpoint handle on the selected annotation resizes the line;
|
|
@@ -389,6 +505,10 @@ export const createSelectTool = () => ({
|
|
|
389
505
|
onPointerDown(event, ctx) {
|
|
390
506
|
const { world } = event;
|
|
391
507
|
const zoom = ctx.viewport.state.zoom;
|
|
508
|
+
// Reset the re-tap-to-edit latch; only a body grab of an already-selected
|
|
509
|
+
// text shape (below) re-arms it. Grabbing a handle or empty canvas leaves
|
|
510
|
+
// it cleared, so neither can leak an edit into the next release.
|
|
511
|
+
pendingTextEditId = null;
|
|
392
512
|
// Endpoint/resize handles show only on the selected element — check first,
|
|
393
513
|
// UNLESS the grab is on that element's tile: the tile is the move/slide
|
|
394
514
|
// affordance and must win over a handle sitting under it, so a selected
|
|
@@ -432,6 +552,19 @@ export const createSelectTool = () => ({
|
|
|
432
552
|
delta: { x: 0, y: 0 },
|
|
433
553
|
};
|
|
434
554
|
}
|
|
555
|
+
const shapeCorner = findShapeCornerHit(ctx.document, selId, world, zoom);
|
|
556
|
+
if (shapeCorner) {
|
|
557
|
+
ctx.setSelection({ ids: [selId] });
|
|
558
|
+
return {
|
|
559
|
+
kind: 'dragging',
|
|
560
|
+
id: selId,
|
|
561
|
+
elementKind: 'shape',
|
|
562
|
+
mode: 'shape-corner',
|
|
563
|
+
corner: shapeCorner.corner,
|
|
564
|
+
start: world,
|
|
565
|
+
delta: { x: 0, y: 0 },
|
|
566
|
+
};
|
|
567
|
+
}
|
|
435
568
|
const rectCorner = findRectCornerHit(ctx.document, selId, world, zoom);
|
|
436
569
|
if (rectCorner) {
|
|
437
570
|
ctx.setSelection({ ids: [selId] });
|
|
@@ -451,6 +584,15 @@ export const createSelectTool = () => ({
|
|
|
451
584
|
ctx.setSelection(null);
|
|
452
585
|
return { kind: 'idle' };
|
|
453
586
|
}
|
|
587
|
+
// Re-tapping an already-selected text shape (without dragging) opens its
|
|
588
|
+
// editor on release — see onPointerUp. `selId` is the selection from before
|
|
589
|
+
// this down, so a first tap only selects; the second tap edits.
|
|
590
|
+
pendingTextEditId =
|
|
591
|
+
hit.kind === 'shape' &&
|
|
592
|
+
hit.id === selId &&
|
|
593
|
+
isTextShape(ctx.document, hit.id)
|
|
594
|
+
? hit.id
|
|
595
|
+
: null;
|
|
454
596
|
ctx.setSelection({ ids: [hit.id] });
|
|
455
597
|
const mode = hit.kind === 'measurement' &&
|
|
456
598
|
classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
|
|
@@ -474,32 +616,39 @@ export const createSelectTool = () => ({
|
|
|
474
616
|
x: event.world.x - s.start.x,
|
|
475
617
|
y: event.world.y - s.start.y,
|
|
476
618
|
};
|
|
619
|
+
// Any real movement turns this into a drag, not a re-tap — disarm the edit.
|
|
620
|
+
if (delta.x !== 0 || delta.y !== 0)
|
|
621
|
+
pendingTextEditId = null;
|
|
477
622
|
const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
|
|
478
623
|
if (patch)
|
|
479
624
|
ctx.preview(patch);
|
|
480
625
|
return { ...s, delta };
|
|
481
626
|
},
|
|
482
627
|
onPointerUp(_event, ctx, state) {
|
|
628
|
+
const editId = pendingTextEditId;
|
|
629
|
+
pendingTextEditId = null;
|
|
483
630
|
const s = state;
|
|
484
|
-
|
|
631
|
+
// A moved selection commits its drag and is never a tap-to-edit.
|
|
632
|
+
if (s?.kind === 'dragging' && (s.delta.x !== 0 || s.delta.y !== 0)) {
|
|
633
|
+
const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
|
|
634
|
+
if (patch)
|
|
635
|
+
ctx.commit(patch);
|
|
485
636
|
return;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
637
|
+
}
|
|
638
|
+
// No movement: re-tapping an already-selected text shape re-opens its
|
|
639
|
+
// editor (the same edit flow as tapping it with the text tool, via the
|
|
640
|
+
// shared editTextShape). `editId` was latched on the down, so this survives
|
|
641
|
+
// the native tap's synchronous down+up where `state` cannot.
|
|
642
|
+
if (editId) {
|
|
643
|
+
const shape = ctx.document.shapes.find((sh) => sh.id === editId);
|
|
644
|
+
if (shape && shape.kind === 'text')
|
|
645
|
+
editTextShape(ctx, shape);
|
|
646
|
+
}
|
|
491
647
|
},
|
|
492
648
|
onCancel(_state, ctx) {
|
|
649
|
+
pendingTextEditId = null;
|
|
493
650
|
ctx.preview({ ops: [] });
|
|
494
651
|
},
|
|
495
|
-
// Long-pressing a placed text shape re-opens the editor (the same edit flow
|
|
496
|
-
// as tapping it with the text tool, via the shared editTextShape). A hold on
|
|
497
|
-
// any other element — or empty canvas — is ignored.
|
|
498
|
-
onLongPress(event, ctx) {
|
|
499
|
-
const shape = findTextShapeAt(ctx.document, event.world);
|
|
500
|
-
if (shape)
|
|
501
|
-
editTextShape(ctx, shape);
|
|
502
|
-
},
|
|
503
652
|
hitTest(element, p) {
|
|
504
653
|
if (element.kind === 'measurement')
|
|
505
654
|
return hitPlacedMeasurement(element, p);
|
|
@@ -8,6 +8,7 @@ export interface ShapeToolOptions {
|
|
|
8
8
|
color?: string;
|
|
9
9
|
width?: number;
|
|
10
10
|
cap?: StrokeCap;
|
|
11
|
+
startCap?: StrokeCap;
|
|
11
12
|
dash?: boolean;
|
|
12
13
|
minDragPx?: number;
|
|
13
14
|
}
|
|
@@ -18,6 +19,7 @@ export declare const buildShapeFromDrag: (opts: {
|
|
|
18
19
|
color: string;
|
|
19
20
|
width: number;
|
|
20
21
|
cap?: StrokeCap;
|
|
22
|
+
startCap?: StrokeCap;
|
|
21
23
|
dash?: boolean;
|
|
22
24
|
layerId: string;
|
|
23
25
|
id?: string;
|
|
@@ -24,10 +24,12 @@ export const buildShapeFromDrag = (opts) => ({
|
|
|
24
24
|
strokeWidth: opts.width,
|
|
25
25
|
...(opts.dash && { dash: true }),
|
|
26
26
|
// Caps only mean something on an open line; 'round' is the implicit
|
|
27
|
-
// default so it stays un-persisted.
|
|
27
|
+
// default so it stays un-persisted. Only 'arrow' matters for the start.
|
|
28
28
|
...(opts.kind === 'line' &&
|
|
29
29
|
opts.cap &&
|
|
30
30
|
opts.cap !== 'round' && { cap: opts.cap }),
|
|
31
|
+
...(opts.kind === 'line' &&
|
|
32
|
+
opts.startCap === 'arrow' && { startCap: 'arrow' }),
|
|
31
33
|
},
|
|
32
34
|
createdAt: Date.now(),
|
|
33
35
|
});
|
|
@@ -42,6 +44,7 @@ export const createShapeTool = (options = {}) => {
|
|
|
42
44
|
const color = options.color ?? '#111827';
|
|
43
45
|
const width = options.width ?? 2;
|
|
44
46
|
const cap = options.cap;
|
|
47
|
+
const startCap = options.startCap;
|
|
45
48
|
const dash = options.dash ?? false;
|
|
46
49
|
const minDragPx = options.minDragPx ?? 4;
|
|
47
50
|
return {
|
|
@@ -49,7 +52,14 @@ export const createShapeTool = (options = {}) => {
|
|
|
49
52
|
label: options.label ?? DEFAULT_LABELS[kind],
|
|
50
53
|
cursor: 'crosshair',
|
|
51
54
|
// Drives UI-thread rubber-banding on native (see ShapeDrawConfig).
|
|
52
|
-
shapeDraw: {
|
|
55
|
+
shapeDraw: {
|
|
56
|
+
kind,
|
|
57
|
+
color,
|
|
58
|
+
width,
|
|
59
|
+
...(cap && { cap }),
|
|
60
|
+
...(startCap === 'arrow' && { startCap: 'arrow' }),
|
|
61
|
+
dash,
|
|
62
|
+
},
|
|
53
63
|
onPointerDown(event, ctx) {
|
|
54
64
|
return {
|
|
55
65
|
kind: 'shape-drawing',
|
|
@@ -60,6 +70,7 @@ export const createShapeTool = (options = {}) => {
|
|
|
60
70
|
color,
|
|
61
71
|
width,
|
|
62
72
|
cap,
|
|
73
|
+
startCap,
|
|
63
74
|
dash,
|
|
64
75
|
layerId: firstLayerId(ctx.document),
|
|
65
76
|
}),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { hitTestTextShape } from '../textGeometry.js';
|
|
2
2
|
// Topmost text shape under a world point (z-order, top first), or null. Shared
|
|
3
|
-
// by the text tool (tap-to-edit) and the select tool (
|
|
4
|
-
// press resolves to the same element from either tool.
|
|
3
|
+
// by the text tool (tap-to-edit) and the select tool (tap-a-selected-shape-to-
|
|
4
|
+
// edit) so a press resolves to the same element from either tool.
|
|
5
5
|
export const findTextShapeAt = (doc, world) => {
|
|
6
6
|
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
7
7
|
const s = doc.shapes[i];
|
|
@@ -16,6 +16,14 @@ export interface AnnotationCanvasHandle {
|
|
|
16
16
|
}): void;
|
|
17
17
|
setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
|
|
18
18
|
associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
|
|
19
|
+
bindColumn(id: AnnotationElementId, binding: {
|
|
20
|
+
groupId: string;
|
|
21
|
+
columnId: string;
|
|
22
|
+
}): void;
|
|
23
|
+
placeColumnTileAtCenter(binding: {
|
|
24
|
+
groupId: string;
|
|
25
|
+
columnId: string;
|
|
26
|
+
}): AnnotationElementId;
|
|
19
27
|
deleteSelected(): void;
|
|
20
28
|
}
|
|
21
29
|
export interface UseAnnotationCanvasStateProps {
|
|
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
3
|
import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
4
4
|
import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
5
|
-
import { viewportTileScale } from './stampLayout.js';
|
|
6
5
|
// Platform-agnostic state machine for the annotation canvas. Web and native
|
|
7
6
|
// inners share this hook; each wraps it with platform-specific event
|
|
8
7
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
@@ -22,11 +21,13 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
22
21
|
return map;
|
|
23
22
|
}, [measurements]);
|
|
24
23
|
const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the
|
|
29
|
-
|
|
24
|
+
// Tiles are sized independently of the canvas: their footprint is base ×
|
|
25
|
+
// per-tile scale × the document-wide `tileScaleFactor` ("Tile size" slider),
|
|
26
|
+
// never the canvas pixel size — so resizing the pane no longer rescales tiles.
|
|
27
|
+
// Retained as a (constant 1) value because the overlays (drawn size) and tools
|
|
28
|
+
// (hit box) thread it; keeping them in lockstep at 1 means both still agree.
|
|
29
|
+
// (See stampLayout.ts for why the old per-canvas multiplier was retired.)
|
|
30
|
+
const tileViewportScale = 1;
|
|
30
31
|
const ctx = useMemo(() => ({
|
|
31
32
|
document: canvas,
|
|
32
33
|
selection,
|
|
@@ -317,6 +318,39 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
317
318
|
],
|
|
318
319
|
});
|
|
319
320
|
},
|
|
321
|
+
bindColumn(id, binding) {
|
|
322
|
+
const c = ctxRef.current;
|
|
323
|
+
c.commit({
|
|
324
|
+
ops: [
|
|
325
|
+
{
|
|
326
|
+
op: 'updateMeasurement',
|
|
327
|
+
id,
|
|
328
|
+
patch: { groupId: binding.groupId, columnId: binding.columnId },
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
placeColumnTileAtCenter(binding) {
|
|
334
|
+
const c = ctxRef.current;
|
|
335
|
+
const anchor = c.viewport.screenToWorld({
|
|
336
|
+
x: width / 2,
|
|
337
|
+
y: height / 2,
|
|
338
|
+
});
|
|
339
|
+
const placed = {
|
|
340
|
+
id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
|
|
341
|
+
layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
342
|
+
groupId: binding.groupId,
|
|
343
|
+
columnId: binding.columnId,
|
|
344
|
+
anchor,
|
|
345
|
+
showLabel: true,
|
|
346
|
+
showValue: true,
|
|
347
|
+
createdAt: Date.now(),
|
|
348
|
+
};
|
|
349
|
+
c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
|
|
350
|
+
// Select it so the consumer can immediately move it / open its editor.
|
|
351
|
+
c.setSelection({ ids: [placed.id] });
|
|
352
|
+
return placed.id;
|
|
353
|
+
},
|
|
320
354
|
deleteSelected() {
|
|
321
355
|
const c = ctxRef.current;
|
|
322
356
|
const ids = c.selection?.ids;
|
|
@@ -13,6 +13,7 @@ export interface AnnotationStroke {
|
|
|
13
13
|
color: string;
|
|
14
14
|
width: number;
|
|
15
15
|
cap?: StrokeCap;
|
|
16
|
+
startCap?: StrokeCap;
|
|
16
17
|
dash?: boolean;
|
|
17
18
|
points: number[];
|
|
18
19
|
pressure?: number[];
|
|
@@ -29,6 +30,7 @@ export interface AnnotationShapeStyle {
|
|
|
29
30
|
dash?: boolean;
|
|
30
31
|
textDecoration?: AnnotationTextDecoration;
|
|
31
32
|
cap?: StrokeCap;
|
|
33
|
+
startCap?: StrokeCap;
|
|
32
34
|
}
|
|
33
35
|
export interface AnnotationShape {
|
|
34
36
|
id: AnnotationElementId;
|
|
@@ -50,6 +52,7 @@ export interface PlacedMeasurementRef {
|
|
|
50
52
|
measurementPath?: string;
|
|
51
53
|
measurementId?: string;
|
|
52
54
|
groupId?: string;
|
|
55
|
+
columnId?: string;
|
|
53
56
|
anchor: Vec2;
|
|
54
57
|
placement?: MeasurementPlacement;
|
|
55
58
|
line?: {
|
|
@@ -64,6 +67,7 @@ export interface PlacedMeasurementRef {
|
|
|
64
67
|
lineColor?: string;
|
|
65
68
|
lineWidth?: number;
|
|
66
69
|
lineCap?: StrokeCap;
|
|
70
|
+
lineStartCap?: StrokeCap;
|
|
67
71
|
lineDash?: boolean;
|
|
68
72
|
leader?: {
|
|
69
73
|
from: Vec2;
|
package/dist/types/annotation.js
CHANGED
|
@@ -12,8 +12,14 @@ export const createEmptyCanvasState = (viewport) => ({
|
|
|
12
12
|
viewport: {
|
|
13
13
|
width: viewport?.width ?? 1000,
|
|
14
14
|
height: viewport?.height ?? 1000,
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Only include the optional keys when defined — emitting explicit
|
|
16
|
+
// `undefined` values breaks Firestore writes (RN rejects undefined fields).
|
|
17
|
+
...(viewport?.backgroundImage !== undefined
|
|
18
|
+
? { backgroundImage: viewport.backgroundImage }
|
|
19
|
+
: {}),
|
|
20
|
+
...(viewport?.backgroundFit !== undefined
|
|
21
|
+
? { backgroundFit: viewport.backgroundFit }
|
|
22
|
+
: {}),
|
|
17
23
|
},
|
|
18
24
|
strokes: [],
|
|
19
25
|
shapes: [],
|