canvu-react 0.3.37 → 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.cjs CHANGED
@@ -414,6 +414,78 @@ async function loadImageFileAsRasterSceneSource(file, maxDimensionOrOptions) {
414
414
  }
415
415
  }
416
416
 
417
+ // src/input/pan-momentum.ts
418
+ var VELOCITY_SAMPLE_WINDOW = 80;
419
+ var FRICTION = 0.94;
420
+ var MIN_VELOCITY = 0.3;
421
+ function createPanMomentumController(camera, onUpdate, sensitivity) {
422
+ const samples = [];
423
+ let animationFrameId = null;
424
+ const cancel = () => {
425
+ if (animationFrameId !== null) {
426
+ cancelAnimationFrame(animationFrameId);
427
+ animationFrameId = null;
428
+ }
429
+ };
430
+ const reset = () => {
431
+ cancel();
432
+ samples.length = 0;
433
+ };
434
+ const recordMove = (dx, dy) => {
435
+ const now = performance.now();
436
+ samples.push({ dx, dy, time: now });
437
+ const cutoff = now - VELOCITY_SAMPLE_WINDOW;
438
+ let oldestSample = samples[0];
439
+ while (oldestSample && oldestSample.time < cutoff) {
440
+ samples.shift();
441
+ oldestSample = samples[0];
442
+ }
443
+ };
444
+ const computeReleaseVelocity = () => {
445
+ if (samples.length < 2) return { vx: 0, vy: 0 };
446
+ const first = samples[0];
447
+ const last = samples[samples.length - 1];
448
+ if (!first || !last) return { vx: 0, vy: 0 };
449
+ const elapsed = last.time - first.time;
450
+ if (elapsed < 4) return { vx: 0, vy: 0 };
451
+ let totalDx = 0;
452
+ let totalDy = 0;
453
+ for (const sample of samples) {
454
+ totalDx += sample.dx;
455
+ totalDy += sample.dy;
456
+ }
457
+ const msPerFrame = 1e3 / 60;
458
+ return {
459
+ vx: totalDx / elapsed * msPerFrame * sensitivity,
460
+ vy: totalDy / elapsed * msPerFrame * sensitivity
461
+ };
462
+ };
463
+ const startMomentum = () => {
464
+ cancel();
465
+ const { vx, vy } = computeReleaseVelocity();
466
+ samples.length = 0;
467
+ if (Math.abs(vx) < MIN_VELOCITY && Math.abs(vy) < MIN_VELOCITY) {
468
+ return;
469
+ }
470
+ let currentVx = vx;
471
+ let currentVy = vy;
472
+ const animate = () => {
473
+ currentVx *= FRICTION;
474
+ currentVy *= FRICTION;
475
+ if (Math.abs(currentVx) < MIN_VELOCITY && Math.abs(currentVy) < MIN_VELOCITY) {
476
+ animationFrameId = null;
477
+ return;
478
+ }
479
+ camera.x += currentVx;
480
+ camera.y += currentVy;
481
+ onUpdate();
482
+ animationFrameId = requestAnimationFrame(animate);
483
+ };
484
+ animationFrameId = requestAnimationFrame(animate);
485
+ };
486
+ return { recordMove, startMomentum, cancel, reset };
487
+ }
488
+
417
489
  // src/input/apple-pencil-navigation.ts
418
490
  var DRAWING_LIKE_TOOLS = /* @__PURE__ */ new Set([
419
491
  "draw",
@@ -444,6 +516,7 @@ function attachApplePencilNavigation(options) {
444
516
  let pinchStartDist = 0;
445
517
  let pinchStartZoom = 1;
446
518
  let panLast = null;
519
+ let touchMomentum = null;
447
520
  const shouldIntercept = (e) => {
448
521
  if (e.pointerType !== "touch") return false;
449
522
  const tool = getCurrentToolId();
@@ -462,6 +535,10 @@ function attachApplePencilNavigation(options) {
462
535
  pointers.clear();
463
536
  mode = "idle";
464
537
  panLast = null;
538
+ if (touchMomentum) {
539
+ touchMomentum.cancel();
540
+ touchMomentum = null;
541
+ }
465
542
  return;
466
543
  }
467
544
  if (e.pointerType === "touch" && activePenPointerIds.size > 0) {
@@ -471,10 +548,18 @@ function attachApplePencilNavigation(options) {
471
548
  return;
472
549
  }
473
550
  if (!shouldIntercept(e)) return;
551
+ if (touchMomentum) {
552
+ touchMomentum.cancel();
553
+ }
474
554
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
475
555
  if (pointers.size === 1) {
476
556
  mode = "pan";
477
557
  panLast = { x: e.clientX, y: e.clientY };
558
+ touchMomentum = createPanMomentumController(
559
+ camera,
560
+ onUpdate,
561
+ touchPanSensitivity
562
+ );
478
563
  } else if (pointers.size === 2) {
479
564
  const vals = Array.from(pointers.values());
480
565
  const a = vals[0];
@@ -502,6 +587,7 @@ function attachApplePencilNavigation(options) {
502
587
  panLast = { x: e.clientX, y: e.clientY };
503
588
  camera.x += dx * touchPanSensitivity;
504
589
  camera.y += dy * touchPanSensitivity;
590
+ touchMomentum?.recordMove(dx, dy);
505
591
  onUpdate();
506
592
  e.preventDefault();
507
593
  e.stopImmediatePropagation();
@@ -548,12 +634,20 @@ function attachApplePencilNavigation(options) {
548
634
  if (!pointers.has(e.pointerId)) return;
549
635
  pointers.delete(e.pointerId);
550
636
  if (pointers.size === 0) {
637
+ const wasPanning = mode === "pan";
551
638
  mode = "idle";
552
639
  panLast = null;
640
+ if (wasPanning && touchMomentum) {
641
+ touchMomentum.startMomentum();
642
+ touchMomentum = null;
643
+ }
553
644
  } else if (pointers.size === 1 && mode === "pinch") {
554
645
  mode = "pan";
555
646
  const r = Array.from(pointers.values())[0];
556
647
  panLast = r ?? null;
648
+ if (touchMomentum) {
649
+ touchMomentum.reset();
650
+ }
557
651
  }
558
652
  e.preventDefault();
559
653
  e.stopImmediatePropagation();
@@ -563,6 +657,10 @@ function attachApplePencilNavigation(options) {
563
657
  element.addEventListener("pointerup", onPointerUp, { capture: true });
564
658
  element.addEventListener("pointercancel", onPointerUp, { capture: true });
565
659
  return () => {
660
+ if (touchMomentum) {
661
+ touchMomentum.cancel();
662
+ touchMomentum = null;
663
+ }
566
664
  element.removeEventListener("pointerdown", onPointerDown, { capture: true });
567
665
  element.removeEventListener("pointermove", onPointerMove, { capture: true });
568
666
  element.removeEventListener("pointerup", onPointerUp, { capture: true });
@@ -615,6 +713,7 @@ function attachViewportInput(options) {
615
713
  let pinchStartZoom = 1;
616
714
  let panLast = null;
617
715
  let mousePanButton = null;
716
+ let touchMomentum = null;
618
717
  const onWheel = (e) => {
619
718
  if (e.ctrlKey || e.metaKey) {
620
719
  e.preventDefault();
@@ -641,6 +740,9 @@ function attachViewportInput(options) {
641
740
  if (touchHandledElsewhere && e.pointerType === "touch") {
642
741
  return;
643
742
  }
743
+ if (touchMomentum) {
744
+ touchMomentum.cancel();
745
+ }
644
746
  const panOk = allowPrimaryPointerPan();
645
747
  if (e.pointerType === "mouse" && e.button === 0) {
646
748
  if (!panOk) {
@@ -666,6 +768,11 @@ function attachViewportInput(options) {
666
768
  if (panOk) {
667
769
  mode = "pan";
668
770
  panLast = { x: e.clientX, y: e.clientY };
771
+ touchMomentum = createPanMomentumController(
772
+ camera,
773
+ onUpdate,
774
+ touchPanSensitivity
775
+ );
669
776
  e.preventDefault();
670
777
  }
671
778
  } else if (pointers.size === 2) {
@@ -702,6 +809,7 @@ function attachViewportInput(options) {
702
809
  panLast = { x: e.clientX, y: e.clientY };
703
810
  camera.x += dx * touchPanSensitivity;
704
811
  camera.y += dy * touchPanSensitivity;
812
+ touchMomentum?.recordMove(dx, dy);
705
813
  onUpdate();
706
814
  e.preventDefault();
707
815
  return;
@@ -753,15 +861,27 @@ function attachViewportInput(options) {
753
861
  }
754
862
  pointers.delete(e.pointerId);
755
863
  if (pointers.size === 0) {
864
+ const wasPanning = mode === "pan";
756
865
  mode = "idle";
757
866
  panLast = null;
867
+ if (wasPanning && touchMomentum) {
868
+ touchMomentum.startMomentum();
869
+ touchMomentum = null;
870
+ }
758
871
  } else if (pointers.size === 1 && mode === "pinch") {
759
872
  mode = "pan";
760
873
  const remaining = Array.from(pointers.values())[0];
761
874
  panLast = remaining ?? null;
875
+ if (touchMomentum) {
876
+ touchMomentum.reset();
877
+ }
762
878
  }
763
879
  };
764
880
  const onPointerCancel = (e) => {
881
+ if (touchMomentum) {
882
+ touchMomentum.cancel();
883
+ touchMomentum = null;
884
+ }
765
885
  onPointerUp(e);
766
886
  };
767
887
  wheelTarget.addEventListener("wheel", onWheel, { passive: false });
@@ -770,6 +890,10 @@ function attachViewportInput(options) {
770
890
  element.addEventListener("pointerup", onPointerUp);
771
891
  element.addEventListener("pointercancel", onPointerCancel);
772
892
  return () => {
893
+ if (touchMomentum) {
894
+ touchMomentum.cancel();
895
+ touchMomentum = null;
896
+ }
773
897
  wheelTarget.removeEventListener("wheel", onWheel);
774
898
  element.removeEventListener("pointerdown", onPointerDown);
775
899
  element.removeEventListener("pointermove", onPointerMove);
@@ -2482,6 +2606,7 @@ var SvgVectorRenderer = class {
2482
2606
  svg;
2483
2607
  rootG;
2484
2608
  itemNodeCache = /* @__PURE__ */ new Map();
2609
+ liveOverlay = null;
2485
2610
  resizeObserver;
2486
2611
  constructor(options) {
2487
2612
  this.container = options.container;
@@ -2516,6 +2641,50 @@ var SvgVectorRenderer = class {
2516
2641
  const items = cullItemsByViewport(this.scene.getItems(), visible);
2517
2642
  this.syncVisibleItems(items);
2518
2643
  this.rootG.setAttribute("transform", formatCameraTransform(this.camera));
2644
+ this.keepLiveOverlayOnTop();
2645
+ }
2646
+ /**
2647
+ * Updates only the in-progress (live) stroke node without re-culling or
2648
+ * re-syncing the committed scene items. Drawing tools call this on every
2649
+ * pointer move, so it must stay O(1) regardless of how many items the
2650
+ * board already contains.
2651
+ *
2652
+ * Pass `null` to remove the live overlay (e.g. when the stroke is committed).
2653
+ */
2654
+ renderLiveItem(item) {
2655
+ if (!item) {
2656
+ if (this.liveOverlay) {
2657
+ this.liveOverlay.g.remove();
2658
+ this.liveOverlay = null;
2659
+ }
2660
+ return;
2661
+ }
2662
+ if (!this.liveOverlay) {
2663
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
2664
+ g.setAttribute("data-live-overlay", "true");
2665
+ this.liveOverlay = {
2666
+ g,
2667
+ lastChildrenSvg: "",
2668
+ lastTransform: ""
2669
+ };
2670
+ this.rootG.appendChild(g);
2671
+ }
2672
+ const cached = this.liveOverlay;
2673
+ const t = formatItemPlacementTransform(item);
2674
+ if (cached.lastTransform !== t) {
2675
+ cached.g.setAttribute("transform", t);
2676
+ cached.lastTransform = t;
2677
+ }
2678
+ if (cached.lastChildrenSvg !== item.childrenSvg) {
2679
+ cached.g.innerHTML = item.childrenSvg;
2680
+ cached.lastChildrenSvg = item.childrenSvg;
2681
+ }
2682
+ this.keepLiveOverlayOnTop();
2683
+ }
2684
+ keepLiveOverlayOnTop() {
2685
+ if (this.liveOverlay && this.rootG.lastChild !== this.liveOverlay.g) {
2686
+ this.rootG.appendChild(this.liveOverlay.g);
2687
+ }
2519
2688
  }
2520
2689
  syncVisibleItems(items) {
2521
2690
  const visibleIds = /* @__PURE__ */ new Set();
@@ -2560,6 +2729,7 @@ var SvgVectorRenderer = class {
2560
2729
  destroy() {
2561
2730
  this.resizeObserver.disconnect();
2562
2731
  this.itemNodeCache.clear();
2732
+ this.liveOverlay = null;
2563
2733
  this.svg.remove();
2564
2734
  }
2565
2735
  /** Toggle whether the scene SVG receives pointer events (vs overlay handling them). */