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/react.js CHANGED
@@ -4963,6 +4963,78 @@ var Camera2D = class {
4963
4963
  }
4964
4964
  };
4965
4965
 
4966
+ // src/input/pan-momentum.ts
4967
+ var VELOCITY_SAMPLE_WINDOW = 80;
4968
+ var FRICTION = 0.94;
4969
+ var MIN_VELOCITY = 0.3;
4970
+ function createPanMomentumController(camera, onUpdate, sensitivity) {
4971
+ const samples = [];
4972
+ let animationFrameId = null;
4973
+ const cancel = () => {
4974
+ if (animationFrameId !== null) {
4975
+ cancelAnimationFrame(animationFrameId);
4976
+ animationFrameId = null;
4977
+ }
4978
+ };
4979
+ const reset = () => {
4980
+ cancel();
4981
+ samples.length = 0;
4982
+ };
4983
+ const recordMove = (dx, dy) => {
4984
+ const now = performance.now();
4985
+ samples.push({ dx, dy, time: now });
4986
+ const cutoff = now - VELOCITY_SAMPLE_WINDOW;
4987
+ let oldestSample = samples[0];
4988
+ while (oldestSample && oldestSample.time < cutoff) {
4989
+ samples.shift();
4990
+ oldestSample = samples[0];
4991
+ }
4992
+ };
4993
+ const computeReleaseVelocity = () => {
4994
+ if (samples.length < 2) return { vx: 0, vy: 0 };
4995
+ const first = samples[0];
4996
+ const last = samples[samples.length - 1];
4997
+ if (!first || !last) return { vx: 0, vy: 0 };
4998
+ const elapsed = last.time - first.time;
4999
+ if (elapsed < 4) return { vx: 0, vy: 0 };
5000
+ let totalDx = 0;
5001
+ let totalDy = 0;
5002
+ for (const sample of samples) {
5003
+ totalDx += sample.dx;
5004
+ totalDy += sample.dy;
5005
+ }
5006
+ const msPerFrame = 1e3 / 60;
5007
+ return {
5008
+ vx: totalDx / elapsed * msPerFrame * sensitivity,
5009
+ vy: totalDy / elapsed * msPerFrame * sensitivity
5010
+ };
5011
+ };
5012
+ const startMomentum = () => {
5013
+ cancel();
5014
+ const { vx, vy } = computeReleaseVelocity();
5015
+ samples.length = 0;
5016
+ if (Math.abs(vx) < MIN_VELOCITY && Math.abs(vy) < MIN_VELOCITY) {
5017
+ return;
5018
+ }
5019
+ let currentVx = vx;
5020
+ let currentVy = vy;
5021
+ const animate = () => {
5022
+ currentVx *= FRICTION;
5023
+ currentVy *= FRICTION;
5024
+ if (Math.abs(currentVx) < MIN_VELOCITY && Math.abs(currentVy) < MIN_VELOCITY) {
5025
+ animationFrameId = null;
5026
+ return;
5027
+ }
5028
+ camera.x += currentVx;
5029
+ camera.y += currentVy;
5030
+ onUpdate();
5031
+ animationFrameId = requestAnimationFrame(animate);
5032
+ };
5033
+ animationFrameId = requestAnimationFrame(animate);
5034
+ };
5035
+ return { recordMove, startMomentum, cancel, reset };
5036
+ }
5037
+
4966
5038
  // src/input/apple-pencil-navigation.ts
4967
5039
  var DRAWING_LIKE_TOOLS = /* @__PURE__ */ new Set([
4968
5040
  "draw",
@@ -4993,6 +5065,7 @@ function attachApplePencilNavigation(options) {
4993
5065
  let pinchStartDist = 0;
4994
5066
  let pinchStartZoom = 1;
4995
5067
  let panLast = null;
5068
+ let touchMomentum = null;
4996
5069
  const shouldIntercept = (e) => {
4997
5070
  if (e.pointerType !== "touch") return false;
4998
5071
  const tool = getCurrentToolId();
@@ -5011,6 +5084,10 @@ function attachApplePencilNavigation(options) {
5011
5084
  pointers.clear();
5012
5085
  mode = "idle";
5013
5086
  panLast = null;
5087
+ if (touchMomentum) {
5088
+ touchMomentum.cancel();
5089
+ touchMomentum = null;
5090
+ }
5014
5091
  return;
5015
5092
  }
5016
5093
  if (e.pointerType === "touch" && activePenPointerIds.size > 0) {
@@ -5020,10 +5097,18 @@ function attachApplePencilNavigation(options) {
5020
5097
  return;
5021
5098
  }
5022
5099
  if (!shouldIntercept(e)) return;
5100
+ if (touchMomentum) {
5101
+ touchMomentum.cancel();
5102
+ }
5023
5103
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
5024
5104
  if (pointers.size === 1) {
5025
5105
  mode = "pan";
5026
5106
  panLast = { x: e.clientX, y: e.clientY };
5107
+ touchMomentum = createPanMomentumController(
5108
+ camera,
5109
+ onUpdate,
5110
+ touchPanSensitivity
5111
+ );
5027
5112
  } else if (pointers.size === 2) {
5028
5113
  const vals = Array.from(pointers.values());
5029
5114
  const a = vals[0];
@@ -5051,6 +5136,7 @@ function attachApplePencilNavigation(options) {
5051
5136
  panLast = { x: e.clientX, y: e.clientY };
5052
5137
  camera.x += dx * touchPanSensitivity;
5053
5138
  camera.y += dy * touchPanSensitivity;
5139
+ touchMomentum?.recordMove(dx, dy);
5054
5140
  onUpdate();
5055
5141
  e.preventDefault();
5056
5142
  e.stopImmediatePropagation();
@@ -5097,12 +5183,20 @@ function attachApplePencilNavigation(options) {
5097
5183
  if (!pointers.has(e.pointerId)) return;
5098
5184
  pointers.delete(e.pointerId);
5099
5185
  if (pointers.size === 0) {
5186
+ const wasPanning = mode === "pan";
5100
5187
  mode = "idle";
5101
5188
  panLast = null;
5189
+ if (wasPanning && touchMomentum) {
5190
+ touchMomentum.startMomentum();
5191
+ touchMomentum = null;
5192
+ }
5102
5193
  } else if (pointers.size === 1 && mode === "pinch") {
5103
5194
  mode = "pan";
5104
5195
  const r = Array.from(pointers.values())[0];
5105
5196
  panLast = r ?? null;
5197
+ if (touchMomentum) {
5198
+ touchMomentum.reset();
5199
+ }
5106
5200
  }
5107
5201
  e.preventDefault();
5108
5202
  e.stopImmediatePropagation();
@@ -5112,6 +5206,10 @@ function attachApplePencilNavigation(options) {
5112
5206
  element.addEventListener("pointerup", onPointerUp, { capture: true });
5113
5207
  element.addEventListener("pointercancel", onPointerUp, { capture: true });
5114
5208
  return () => {
5209
+ if (touchMomentum) {
5210
+ touchMomentum.cancel();
5211
+ touchMomentum = null;
5212
+ }
5115
5213
  element.removeEventListener("pointerdown", onPointerDown, { capture: true });
5116
5214
  element.removeEventListener("pointermove", onPointerMove, { capture: true });
5117
5215
  element.removeEventListener("pointerup", onPointerUp, { capture: true });
@@ -5164,6 +5262,7 @@ function attachViewportInput(options) {
5164
5262
  let pinchStartZoom = 1;
5165
5263
  let panLast = null;
5166
5264
  let mousePanButton = null;
5265
+ let touchMomentum = null;
5167
5266
  const onWheel = (e) => {
5168
5267
  if (e.ctrlKey || e.metaKey) {
5169
5268
  e.preventDefault();
@@ -5190,6 +5289,9 @@ function attachViewportInput(options) {
5190
5289
  if (touchHandledElsewhere && e.pointerType === "touch") {
5191
5290
  return;
5192
5291
  }
5292
+ if (touchMomentum) {
5293
+ touchMomentum.cancel();
5294
+ }
5193
5295
  const panOk = allowPrimaryPointerPan();
5194
5296
  if (e.pointerType === "mouse" && e.button === 0) {
5195
5297
  if (!panOk) {
@@ -5215,6 +5317,11 @@ function attachViewportInput(options) {
5215
5317
  if (panOk) {
5216
5318
  mode = "pan";
5217
5319
  panLast = { x: e.clientX, y: e.clientY };
5320
+ touchMomentum = createPanMomentumController(
5321
+ camera,
5322
+ onUpdate,
5323
+ touchPanSensitivity
5324
+ );
5218
5325
  e.preventDefault();
5219
5326
  }
5220
5327
  } else if (pointers.size === 2) {
@@ -5251,6 +5358,7 @@ function attachViewportInput(options) {
5251
5358
  panLast = { x: e.clientX, y: e.clientY };
5252
5359
  camera.x += dx * touchPanSensitivity;
5253
5360
  camera.y += dy * touchPanSensitivity;
5361
+ touchMomentum?.recordMove(dx, dy);
5254
5362
  onUpdate();
5255
5363
  e.preventDefault();
5256
5364
  return;
@@ -5302,15 +5410,27 @@ function attachViewportInput(options) {
5302
5410
  }
5303
5411
  pointers.delete(e.pointerId);
5304
5412
  if (pointers.size === 0) {
5413
+ const wasPanning = mode === "pan";
5305
5414
  mode = "idle";
5306
5415
  panLast = null;
5416
+ if (wasPanning && touchMomentum) {
5417
+ touchMomentum.startMomentum();
5418
+ touchMomentum = null;
5419
+ }
5307
5420
  } else if (pointers.size === 1 && mode === "pinch") {
5308
5421
  mode = "pan";
5309
5422
  const remaining = Array.from(pointers.values())[0];
5310
5423
  panLast = remaining ?? null;
5424
+ if (touchMomentum) {
5425
+ touchMomentum.reset();
5426
+ }
5311
5427
  }
5312
5428
  };
5313
5429
  const onPointerCancel = (e) => {
5430
+ if (touchMomentum) {
5431
+ touchMomentum.cancel();
5432
+ touchMomentum = null;
5433
+ }
5314
5434
  onPointerUp(e);
5315
5435
  };
5316
5436
  wheelTarget.addEventListener("wheel", onWheel, { passive: false });
@@ -5319,6 +5439,10 @@ function attachViewportInput(options) {
5319
5439
  element.addEventListener("pointerup", onPointerUp);
5320
5440
  element.addEventListener("pointercancel", onPointerCancel);
5321
5441
  return () => {
5442
+ if (touchMomentum) {
5443
+ touchMomentum.cancel();
5444
+ touchMomentum = null;
5445
+ }
5322
5446
  wheelTarget.removeEventListener("wheel", onWheel);
5323
5447
  element.removeEventListener("pointerdown", onPointerDown);
5324
5448
  element.removeEventListener("pointermove", onPointerMove);
@@ -6200,6 +6324,7 @@ var SvgVectorRenderer = class {
6200
6324
  svg;
6201
6325
  rootG;
6202
6326
  itemNodeCache = /* @__PURE__ */ new Map();
6327
+ liveOverlay = null;
6203
6328
  resizeObserver;
6204
6329
  constructor(options) {
6205
6330
  this.container = options.container;
@@ -6234,6 +6359,50 @@ var SvgVectorRenderer = class {
6234
6359
  const items = cullItemsByViewport(this.scene.getItems(), visible);
6235
6360
  this.syncVisibleItems(items);
6236
6361
  this.rootG.setAttribute("transform", formatCameraTransform(this.camera));
6362
+ this.keepLiveOverlayOnTop();
6363
+ }
6364
+ /**
6365
+ * Updates only the in-progress (live) stroke node without re-culling or
6366
+ * re-syncing the committed scene items. Drawing tools call this on every
6367
+ * pointer move, so it must stay O(1) regardless of how many items the
6368
+ * board already contains.
6369
+ *
6370
+ * Pass `null` to remove the live overlay (e.g. when the stroke is committed).
6371
+ */
6372
+ renderLiveItem(item) {
6373
+ if (!item) {
6374
+ if (this.liveOverlay) {
6375
+ this.liveOverlay.g.remove();
6376
+ this.liveOverlay = null;
6377
+ }
6378
+ return;
6379
+ }
6380
+ if (!this.liveOverlay) {
6381
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
6382
+ g.setAttribute("data-live-overlay", "true");
6383
+ this.liveOverlay = {
6384
+ g,
6385
+ lastChildrenSvg: "",
6386
+ lastTransform: ""
6387
+ };
6388
+ this.rootG.appendChild(g);
6389
+ }
6390
+ const cached = this.liveOverlay;
6391
+ const t = formatItemPlacementTransform(item);
6392
+ if (cached.lastTransform !== t) {
6393
+ cached.g.setAttribute("transform", t);
6394
+ cached.lastTransform = t;
6395
+ }
6396
+ if (cached.lastChildrenSvg !== item.childrenSvg) {
6397
+ cached.g.innerHTML = item.childrenSvg;
6398
+ cached.lastChildrenSvg = item.childrenSvg;
6399
+ }
6400
+ this.keepLiveOverlayOnTop();
6401
+ }
6402
+ keepLiveOverlayOnTop() {
6403
+ if (this.liveOverlay && this.rootG.lastChild !== this.liveOverlay.g) {
6404
+ this.rootG.appendChild(this.liveOverlay.g);
6405
+ }
6237
6406
  }
6238
6407
  syncVisibleItems(items) {
6239
6408
  const visibleIds = /* @__PURE__ */ new Set();
@@ -6278,6 +6447,7 @@ var SvgVectorRenderer = class {
6278
6447
  destroy() {
6279
6448
  this.resizeObserver.disconnect();
6280
6449
  this.itemNodeCache.clear();
6450
+ this.liveOverlay = null;
6281
6451
  this.svg.remove();
6282
6452
  }
6283
6453
  /** Toggle whether the scene SVG receives pointer events (vs overlay handling them). */
@@ -7754,8 +7924,6 @@ var VectorViewport = forwardRef(
7754
7924
  const hiddenIds = new Set(eraserPreviewIds);
7755
7925
  return resolvedItems.filter((it) => !hiddenIds.has(it.id));
7756
7926
  }, [eraserPreviewIds, resolvedItems]);
7757
- const resolvedSceneItemsRef = useRef(resolvedSceneItems);
7758
- resolvedSceneItemsRef.current = resolvedSceneItems;
7759
7927
  const livePenStrokeItemRef = useRef(null);
7760
7928
  const [eraserActive, setEraserActive] = useState(false);
7761
7929
  const [editingTextId, setEditingTextId] = useState(null);
@@ -7976,12 +8144,9 @@ var VectorViewport = forwardRef(
7976
8144
  const renderSceneWithLivePenStroke = useCallback(
7977
8145
  (item) => {
7978
8146
  livePenStrokeItemRef.current = item;
7979
- const scene = sceneRef.current;
7980
8147
  const renderer = rendererRef.current;
7981
- if (!scene || !renderer) return;
7982
- const base2 = resolvedSceneItemsRef.current;
7983
- scene.setItems(item ? [...base2, item] : base2);
7984
- renderer.render();
8148
+ if (!renderer) return;
8149
+ renderer.renderLiveItem(item);
7985
8150
  },
7986
8151
  []
7987
8152
  );
@@ -8212,16 +8377,15 @@ var VectorViewport = forwardRef(
8212
8377
  }, []);
8213
8378
  useEffect(() => {
8214
8379
  const scene = sceneRef.current;
8380
+ const renderer = rendererRef.current;
8215
8381
  if (scene) {
8216
8382
  const live = livePenStrokeItemRef.current;
8217
8383
  if (live && resolvedSceneItems.some((it) => it.id === live.id)) {
8218
8384
  livePenStrokeItemRef.current = null;
8219
8385
  }
8220
- const currentLive = livePenStrokeItemRef.current;
8221
- scene.setItems(
8222
- currentLive ? [...resolvedSceneItems, currentLive] : resolvedSceneItems
8223
- );
8386
+ scene.setItems(resolvedSceneItems);
8224
8387
  renderFrame();
8388
+ renderer?.renderLiveItem(livePenStrokeItemRef.current);
8225
8389
  }
8226
8390
  }, [resolvedSceneItems, renderFrame]);
8227
8391
  useEffect(() => {
@@ -8797,11 +8961,12 @@ var VectorViewport = forwardRef(
8797
8961
  setEditingTextId(null);
8798
8962
  }, []);
8799
8963
  const placeImageFilesAtWorld = useCallback(
8800
- async (files, worldX, worldY) => {
8964
+ async (files, worldX, worldY, options) => {
8801
8965
  const change = onItemsChangeRef.current;
8802
8966
  if (!change || files.length === 0) return;
8803
8967
  const store = imageStoreRef.current;
8804
8968
  if (!store) return;
8969
+ const stackBelowExistingItems = options?.stackBelowExistingItems ?? true;
8805
8970
  try {
8806
8971
  const pdfFiles = files.filter((file) => file.type === "application/pdf");
8807
8972
  if (pdfFiles.length > 0) {
@@ -8827,7 +8992,7 @@ var VectorViewport = forwardRef(
8827
8992
  }
8828
8993
  const result = await ingestAssetFilesToSceneItems({
8829
8994
  files,
8830
- existingItems: itemsRef.current,
8995
+ existingItems: stackBelowExistingItems ? itemsRef.current : [],
8831
8996
  worldCenter: {
8832
8997
  x: worldX,
8833
8998
  y: worldY
@@ -8914,7 +9079,9 @@ var VectorViewport = forwardRef(
8914
9079
  );
8915
9080
  if (files.length === 0) return;
8916
9081
  const { worldX, worldY } = screenToWorld(e.clientX, e.clientY);
8917
- await placeImageFilesAtWorld(files, worldX, worldY);
9082
+ await placeImageFilesAtWorld(files, worldX, worldY, {
9083
+ stackBelowExistingItems: false
9084
+ });
8918
9085
  },
8919
9086
  [screenToWorld, placeImageFilesAtWorld]
8920
9087
  );