canvu-react 0.3.36 → 0.3.38

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/index.d.cts CHANGED
@@ -195,12 +195,23 @@ declare class SvgVectorRenderer {
195
195
  private readonly svg;
196
196
  private readonly rootG;
197
197
  private readonly itemNodeCache;
198
+ private liveOverlay;
198
199
  private readonly resizeObserver;
199
200
  constructor(options: SvgVectorRendererOptions);
200
201
  /**
201
202
  * Reads container size, culls items, and updates the SVG (incrementally when possible).
202
203
  */
203
204
  render(): void;
205
+ /**
206
+ * Updates only the in-progress (live) stroke node without re-culling or
207
+ * re-syncing the committed scene items. Drawing tools call this on every
208
+ * pointer move, so it must stay O(1) regardless of how many items the
209
+ * board already contains.
210
+ *
211
+ * Pass `null` to remove the live overlay (e.g. when the stroke is committed).
212
+ */
213
+ renderLiveItem(item: VectorSceneItem | null): void;
214
+ private keepLiveOverlayOnTop;
204
215
  private syncVisibleItems;
205
216
  destroy(): void;
206
217
  /** Toggle whether the scene SVG receives pointer events (vs overlay handling them). */
package/dist/index.d.ts CHANGED
@@ -195,12 +195,23 @@ declare class SvgVectorRenderer {
195
195
  private readonly svg;
196
196
  private readonly rootG;
197
197
  private readonly itemNodeCache;
198
+ private liveOverlay;
198
199
  private readonly resizeObserver;
199
200
  constructor(options: SvgVectorRendererOptions);
200
201
  /**
201
202
  * Reads container size, culls items, and updates the SVG (incrementally when possible).
202
203
  */
203
204
  render(): void;
205
+ /**
206
+ * Updates only the in-progress (live) stroke node without re-culling or
207
+ * re-syncing the committed scene items. Drawing tools call this on every
208
+ * pointer move, so it must stay O(1) regardless of how many items the
209
+ * board already contains.
210
+ *
211
+ * Pass `null` to remove the live overlay (e.g. when the stroke is committed).
212
+ */
213
+ renderLiveItem(item: VectorSceneItem | null): void;
214
+ private keepLiveOverlayOnTop;
204
215
  private syncVisibleItems;
205
216
  destroy(): void;
206
217
  /** Toggle whether the scene SVG receives pointer events (vs overlay handling them). */
package/dist/index.js CHANGED
@@ -407,6 +407,78 @@ async function loadImageFileAsRasterSceneSource(file, maxDimensionOrOptions) {
407
407
  }
408
408
  }
409
409
 
410
+ // src/input/pan-momentum.ts
411
+ var VELOCITY_SAMPLE_WINDOW = 80;
412
+ var FRICTION = 0.94;
413
+ var MIN_VELOCITY = 0.3;
414
+ function createPanMomentumController(camera, onUpdate, sensitivity) {
415
+ const samples = [];
416
+ let animationFrameId = null;
417
+ const cancel = () => {
418
+ if (animationFrameId !== null) {
419
+ cancelAnimationFrame(animationFrameId);
420
+ animationFrameId = null;
421
+ }
422
+ };
423
+ const reset = () => {
424
+ cancel();
425
+ samples.length = 0;
426
+ };
427
+ const recordMove = (dx, dy) => {
428
+ const now = performance.now();
429
+ samples.push({ dx, dy, time: now });
430
+ const cutoff = now - VELOCITY_SAMPLE_WINDOW;
431
+ let oldestSample = samples[0];
432
+ while (oldestSample && oldestSample.time < cutoff) {
433
+ samples.shift();
434
+ oldestSample = samples[0];
435
+ }
436
+ };
437
+ const computeReleaseVelocity = () => {
438
+ if (samples.length < 2) return { vx: 0, vy: 0 };
439
+ const first = samples[0];
440
+ const last = samples[samples.length - 1];
441
+ if (!first || !last) return { vx: 0, vy: 0 };
442
+ const elapsed = last.time - first.time;
443
+ if (elapsed < 4) return { vx: 0, vy: 0 };
444
+ let totalDx = 0;
445
+ let totalDy = 0;
446
+ for (const sample of samples) {
447
+ totalDx += sample.dx;
448
+ totalDy += sample.dy;
449
+ }
450
+ const msPerFrame = 1e3 / 60;
451
+ return {
452
+ vx: totalDx / elapsed * msPerFrame * sensitivity,
453
+ vy: totalDy / elapsed * msPerFrame * sensitivity
454
+ };
455
+ };
456
+ const startMomentum = () => {
457
+ cancel();
458
+ const { vx, vy } = computeReleaseVelocity();
459
+ samples.length = 0;
460
+ if (Math.abs(vx) < MIN_VELOCITY && Math.abs(vy) < MIN_VELOCITY) {
461
+ return;
462
+ }
463
+ let currentVx = vx;
464
+ let currentVy = vy;
465
+ const animate = () => {
466
+ currentVx *= FRICTION;
467
+ currentVy *= FRICTION;
468
+ if (Math.abs(currentVx) < MIN_VELOCITY && Math.abs(currentVy) < MIN_VELOCITY) {
469
+ animationFrameId = null;
470
+ return;
471
+ }
472
+ camera.x += currentVx;
473
+ camera.y += currentVy;
474
+ onUpdate();
475
+ animationFrameId = requestAnimationFrame(animate);
476
+ };
477
+ animationFrameId = requestAnimationFrame(animate);
478
+ };
479
+ return { recordMove, startMomentum, cancel, reset };
480
+ }
481
+
410
482
  // src/input/apple-pencil-navigation.ts
411
483
  var DRAWING_LIKE_TOOLS = /* @__PURE__ */ new Set([
412
484
  "draw",
@@ -437,6 +509,7 @@ function attachApplePencilNavigation(options) {
437
509
  let pinchStartDist = 0;
438
510
  let pinchStartZoom = 1;
439
511
  let panLast = null;
512
+ let touchMomentum = null;
440
513
  const shouldIntercept = (e) => {
441
514
  if (e.pointerType !== "touch") return false;
442
515
  const tool = getCurrentToolId();
@@ -455,6 +528,10 @@ function attachApplePencilNavigation(options) {
455
528
  pointers.clear();
456
529
  mode = "idle";
457
530
  panLast = null;
531
+ if (touchMomentum) {
532
+ touchMomentum.cancel();
533
+ touchMomentum = null;
534
+ }
458
535
  return;
459
536
  }
460
537
  if (e.pointerType === "touch" && activePenPointerIds.size > 0) {
@@ -464,10 +541,18 @@ function attachApplePencilNavigation(options) {
464
541
  return;
465
542
  }
466
543
  if (!shouldIntercept(e)) return;
544
+ if (touchMomentum) {
545
+ touchMomentum.cancel();
546
+ }
467
547
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
468
548
  if (pointers.size === 1) {
469
549
  mode = "pan";
470
550
  panLast = { x: e.clientX, y: e.clientY };
551
+ touchMomentum = createPanMomentumController(
552
+ camera,
553
+ onUpdate,
554
+ touchPanSensitivity
555
+ );
471
556
  } else if (pointers.size === 2) {
472
557
  const vals = Array.from(pointers.values());
473
558
  const a = vals[0];
@@ -495,6 +580,7 @@ function attachApplePencilNavigation(options) {
495
580
  panLast = { x: e.clientX, y: e.clientY };
496
581
  camera.x += dx * touchPanSensitivity;
497
582
  camera.y += dy * touchPanSensitivity;
583
+ touchMomentum?.recordMove(dx, dy);
498
584
  onUpdate();
499
585
  e.preventDefault();
500
586
  e.stopImmediatePropagation();
@@ -541,12 +627,20 @@ function attachApplePencilNavigation(options) {
541
627
  if (!pointers.has(e.pointerId)) return;
542
628
  pointers.delete(e.pointerId);
543
629
  if (pointers.size === 0) {
630
+ const wasPanning = mode === "pan";
544
631
  mode = "idle";
545
632
  panLast = null;
633
+ if (wasPanning && touchMomentum) {
634
+ touchMomentum.startMomentum();
635
+ touchMomentum = null;
636
+ }
546
637
  } else if (pointers.size === 1 && mode === "pinch") {
547
638
  mode = "pan";
548
639
  const r = Array.from(pointers.values())[0];
549
640
  panLast = r ?? null;
641
+ if (touchMomentum) {
642
+ touchMomentum.reset();
643
+ }
550
644
  }
551
645
  e.preventDefault();
552
646
  e.stopImmediatePropagation();
@@ -556,6 +650,10 @@ function attachApplePencilNavigation(options) {
556
650
  element.addEventListener("pointerup", onPointerUp, { capture: true });
557
651
  element.addEventListener("pointercancel", onPointerUp, { capture: true });
558
652
  return () => {
653
+ if (touchMomentum) {
654
+ touchMomentum.cancel();
655
+ touchMomentum = null;
656
+ }
559
657
  element.removeEventListener("pointerdown", onPointerDown, { capture: true });
560
658
  element.removeEventListener("pointermove", onPointerMove, { capture: true });
561
659
  element.removeEventListener("pointerup", onPointerUp, { capture: true });
@@ -608,6 +706,7 @@ function attachViewportInput(options) {
608
706
  let pinchStartZoom = 1;
609
707
  let panLast = null;
610
708
  let mousePanButton = null;
709
+ let touchMomentum = null;
611
710
  const onWheel = (e) => {
612
711
  if (e.ctrlKey || e.metaKey) {
613
712
  e.preventDefault();
@@ -634,6 +733,9 @@ function attachViewportInput(options) {
634
733
  if (touchHandledElsewhere && e.pointerType === "touch") {
635
734
  return;
636
735
  }
736
+ if (touchMomentum) {
737
+ touchMomentum.cancel();
738
+ }
637
739
  const panOk = allowPrimaryPointerPan();
638
740
  if (e.pointerType === "mouse" && e.button === 0) {
639
741
  if (!panOk) {
@@ -659,6 +761,11 @@ function attachViewportInput(options) {
659
761
  if (panOk) {
660
762
  mode = "pan";
661
763
  panLast = { x: e.clientX, y: e.clientY };
764
+ touchMomentum = createPanMomentumController(
765
+ camera,
766
+ onUpdate,
767
+ touchPanSensitivity
768
+ );
662
769
  e.preventDefault();
663
770
  }
664
771
  } else if (pointers.size === 2) {
@@ -695,6 +802,7 @@ function attachViewportInput(options) {
695
802
  panLast = { x: e.clientX, y: e.clientY };
696
803
  camera.x += dx * touchPanSensitivity;
697
804
  camera.y += dy * touchPanSensitivity;
805
+ touchMomentum?.recordMove(dx, dy);
698
806
  onUpdate();
699
807
  e.preventDefault();
700
808
  return;
@@ -746,15 +854,27 @@ function attachViewportInput(options) {
746
854
  }
747
855
  pointers.delete(e.pointerId);
748
856
  if (pointers.size === 0) {
857
+ const wasPanning = mode === "pan";
749
858
  mode = "idle";
750
859
  panLast = null;
860
+ if (wasPanning && touchMomentum) {
861
+ touchMomentum.startMomentum();
862
+ touchMomentum = null;
863
+ }
751
864
  } else if (pointers.size === 1 && mode === "pinch") {
752
865
  mode = "pan";
753
866
  const remaining = Array.from(pointers.values())[0];
754
867
  panLast = remaining ?? null;
868
+ if (touchMomentum) {
869
+ touchMomentum.reset();
870
+ }
755
871
  }
756
872
  };
757
873
  const onPointerCancel = (e) => {
874
+ if (touchMomentum) {
875
+ touchMomentum.cancel();
876
+ touchMomentum = null;
877
+ }
758
878
  onPointerUp(e);
759
879
  };
760
880
  wheelTarget.addEventListener("wheel", onWheel, { passive: false });
@@ -763,6 +883,10 @@ function attachViewportInput(options) {
763
883
  element.addEventListener("pointerup", onPointerUp);
764
884
  element.addEventListener("pointercancel", onPointerCancel);
765
885
  return () => {
886
+ if (touchMomentum) {
887
+ touchMomentum.cancel();
888
+ touchMomentum = null;
889
+ }
766
890
  wheelTarget.removeEventListener("wheel", onWheel);
767
891
  element.removeEventListener("pointerdown", onPointerDown);
768
892
  element.removeEventListener("pointermove", onPointerMove);
@@ -2475,6 +2599,7 @@ var SvgVectorRenderer = class {
2475
2599
  svg;
2476
2600
  rootG;
2477
2601
  itemNodeCache = /* @__PURE__ */ new Map();
2602
+ liveOverlay = null;
2478
2603
  resizeObserver;
2479
2604
  constructor(options) {
2480
2605
  this.container = options.container;
@@ -2509,6 +2634,50 @@ var SvgVectorRenderer = class {
2509
2634
  const items = cullItemsByViewport(this.scene.getItems(), visible);
2510
2635
  this.syncVisibleItems(items);
2511
2636
  this.rootG.setAttribute("transform", formatCameraTransform(this.camera));
2637
+ this.keepLiveOverlayOnTop();
2638
+ }
2639
+ /**
2640
+ * Updates only the in-progress (live) stroke node without re-culling or
2641
+ * re-syncing the committed scene items. Drawing tools call this on every
2642
+ * pointer move, so it must stay O(1) regardless of how many items the
2643
+ * board already contains.
2644
+ *
2645
+ * Pass `null` to remove the live overlay (e.g. when the stroke is committed).
2646
+ */
2647
+ renderLiveItem(item) {
2648
+ if (!item) {
2649
+ if (this.liveOverlay) {
2650
+ this.liveOverlay.g.remove();
2651
+ this.liveOverlay = null;
2652
+ }
2653
+ return;
2654
+ }
2655
+ if (!this.liveOverlay) {
2656
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
2657
+ g.setAttribute("data-live-overlay", "true");
2658
+ this.liveOverlay = {
2659
+ g,
2660
+ lastChildrenSvg: "",
2661
+ lastTransform: ""
2662
+ };
2663
+ this.rootG.appendChild(g);
2664
+ }
2665
+ const cached = this.liveOverlay;
2666
+ const t = formatItemPlacementTransform(item);
2667
+ if (cached.lastTransform !== t) {
2668
+ cached.g.setAttribute("transform", t);
2669
+ cached.lastTransform = t;
2670
+ }
2671
+ if (cached.lastChildrenSvg !== item.childrenSvg) {
2672
+ cached.g.innerHTML = item.childrenSvg;
2673
+ cached.lastChildrenSvg = item.childrenSvg;
2674
+ }
2675
+ this.keepLiveOverlayOnTop();
2676
+ }
2677
+ keepLiveOverlayOnTop() {
2678
+ if (this.liveOverlay && this.rootG.lastChild !== this.liveOverlay.g) {
2679
+ this.rootG.appendChild(this.liveOverlay.g);
2680
+ }
2512
2681
  }
2513
2682
  syncVisibleItems(items) {
2514
2683
  const visibleIds = /* @__PURE__ */ new Set();
@@ -2553,6 +2722,7 @@ var SvgVectorRenderer = class {
2553
2722
  destroy() {
2554
2723
  this.resizeObserver.disconnect();
2555
2724
  this.itemNodeCache.clear();
2725
+ this.liveOverlay = null;
2556
2726
  this.svg.remove();
2557
2727
  }
2558
2728
  /** Toggle whether the scene SVG receives pointer events (vs overlay handling them). */