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.cjs CHANGED
@@ -4970,6 +4970,78 @@ var Camera2D = class {
4970
4970
  }
4971
4971
  };
4972
4972
 
4973
+ // src/input/pan-momentum.ts
4974
+ var VELOCITY_SAMPLE_WINDOW = 80;
4975
+ var FRICTION = 0.94;
4976
+ var MIN_VELOCITY = 0.3;
4977
+ function createPanMomentumController(camera, onUpdate, sensitivity) {
4978
+ const samples = [];
4979
+ let animationFrameId = null;
4980
+ const cancel = () => {
4981
+ if (animationFrameId !== null) {
4982
+ cancelAnimationFrame(animationFrameId);
4983
+ animationFrameId = null;
4984
+ }
4985
+ };
4986
+ const reset = () => {
4987
+ cancel();
4988
+ samples.length = 0;
4989
+ };
4990
+ const recordMove = (dx, dy) => {
4991
+ const now = performance.now();
4992
+ samples.push({ dx, dy, time: now });
4993
+ const cutoff = now - VELOCITY_SAMPLE_WINDOW;
4994
+ let oldestSample = samples[0];
4995
+ while (oldestSample && oldestSample.time < cutoff) {
4996
+ samples.shift();
4997
+ oldestSample = samples[0];
4998
+ }
4999
+ };
5000
+ const computeReleaseVelocity = () => {
5001
+ if (samples.length < 2) return { vx: 0, vy: 0 };
5002
+ const first = samples[0];
5003
+ const last = samples[samples.length - 1];
5004
+ if (!first || !last) return { vx: 0, vy: 0 };
5005
+ const elapsed = last.time - first.time;
5006
+ if (elapsed < 4) return { vx: 0, vy: 0 };
5007
+ let totalDx = 0;
5008
+ let totalDy = 0;
5009
+ for (const sample of samples) {
5010
+ totalDx += sample.dx;
5011
+ totalDy += sample.dy;
5012
+ }
5013
+ const msPerFrame = 1e3 / 60;
5014
+ return {
5015
+ vx: totalDx / elapsed * msPerFrame * sensitivity,
5016
+ vy: totalDy / elapsed * msPerFrame * sensitivity
5017
+ };
5018
+ };
5019
+ const startMomentum = () => {
5020
+ cancel();
5021
+ const { vx, vy } = computeReleaseVelocity();
5022
+ samples.length = 0;
5023
+ if (Math.abs(vx) < MIN_VELOCITY && Math.abs(vy) < MIN_VELOCITY) {
5024
+ return;
5025
+ }
5026
+ let currentVx = vx;
5027
+ let currentVy = vy;
5028
+ const animate = () => {
5029
+ currentVx *= FRICTION;
5030
+ currentVy *= FRICTION;
5031
+ if (Math.abs(currentVx) < MIN_VELOCITY && Math.abs(currentVy) < MIN_VELOCITY) {
5032
+ animationFrameId = null;
5033
+ return;
5034
+ }
5035
+ camera.x += currentVx;
5036
+ camera.y += currentVy;
5037
+ onUpdate();
5038
+ animationFrameId = requestAnimationFrame(animate);
5039
+ };
5040
+ animationFrameId = requestAnimationFrame(animate);
5041
+ };
5042
+ return { recordMove, startMomentum, cancel, reset };
5043
+ }
5044
+
4973
5045
  // src/input/apple-pencil-navigation.ts
4974
5046
  var DRAWING_LIKE_TOOLS = /* @__PURE__ */ new Set([
4975
5047
  "draw",
@@ -5000,6 +5072,7 @@ function attachApplePencilNavigation(options) {
5000
5072
  let pinchStartDist = 0;
5001
5073
  let pinchStartZoom = 1;
5002
5074
  let panLast = null;
5075
+ let touchMomentum = null;
5003
5076
  const shouldIntercept = (e) => {
5004
5077
  if (e.pointerType !== "touch") return false;
5005
5078
  const tool = getCurrentToolId();
@@ -5018,6 +5091,10 @@ function attachApplePencilNavigation(options) {
5018
5091
  pointers.clear();
5019
5092
  mode = "idle";
5020
5093
  panLast = null;
5094
+ if (touchMomentum) {
5095
+ touchMomentum.cancel();
5096
+ touchMomentum = null;
5097
+ }
5021
5098
  return;
5022
5099
  }
5023
5100
  if (e.pointerType === "touch" && activePenPointerIds.size > 0) {
@@ -5027,10 +5104,18 @@ function attachApplePencilNavigation(options) {
5027
5104
  return;
5028
5105
  }
5029
5106
  if (!shouldIntercept(e)) return;
5107
+ if (touchMomentum) {
5108
+ touchMomentum.cancel();
5109
+ }
5030
5110
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
5031
5111
  if (pointers.size === 1) {
5032
5112
  mode = "pan";
5033
5113
  panLast = { x: e.clientX, y: e.clientY };
5114
+ touchMomentum = createPanMomentumController(
5115
+ camera,
5116
+ onUpdate,
5117
+ touchPanSensitivity
5118
+ );
5034
5119
  } else if (pointers.size === 2) {
5035
5120
  const vals = Array.from(pointers.values());
5036
5121
  const a = vals[0];
@@ -5058,6 +5143,7 @@ function attachApplePencilNavigation(options) {
5058
5143
  panLast = { x: e.clientX, y: e.clientY };
5059
5144
  camera.x += dx * touchPanSensitivity;
5060
5145
  camera.y += dy * touchPanSensitivity;
5146
+ touchMomentum?.recordMove(dx, dy);
5061
5147
  onUpdate();
5062
5148
  e.preventDefault();
5063
5149
  e.stopImmediatePropagation();
@@ -5104,12 +5190,20 @@ function attachApplePencilNavigation(options) {
5104
5190
  if (!pointers.has(e.pointerId)) return;
5105
5191
  pointers.delete(e.pointerId);
5106
5192
  if (pointers.size === 0) {
5193
+ const wasPanning = mode === "pan";
5107
5194
  mode = "idle";
5108
5195
  panLast = null;
5196
+ if (wasPanning && touchMomentum) {
5197
+ touchMomentum.startMomentum();
5198
+ touchMomentum = null;
5199
+ }
5109
5200
  } else if (pointers.size === 1 && mode === "pinch") {
5110
5201
  mode = "pan";
5111
5202
  const r = Array.from(pointers.values())[0];
5112
5203
  panLast = r ?? null;
5204
+ if (touchMomentum) {
5205
+ touchMomentum.reset();
5206
+ }
5113
5207
  }
5114
5208
  e.preventDefault();
5115
5209
  e.stopImmediatePropagation();
@@ -5119,6 +5213,10 @@ function attachApplePencilNavigation(options) {
5119
5213
  element.addEventListener("pointerup", onPointerUp, { capture: true });
5120
5214
  element.addEventListener("pointercancel", onPointerUp, { capture: true });
5121
5215
  return () => {
5216
+ if (touchMomentum) {
5217
+ touchMomentum.cancel();
5218
+ touchMomentum = null;
5219
+ }
5122
5220
  element.removeEventListener("pointerdown", onPointerDown, { capture: true });
5123
5221
  element.removeEventListener("pointermove", onPointerMove, { capture: true });
5124
5222
  element.removeEventListener("pointerup", onPointerUp, { capture: true });
@@ -5171,6 +5269,7 @@ function attachViewportInput(options) {
5171
5269
  let pinchStartZoom = 1;
5172
5270
  let panLast = null;
5173
5271
  let mousePanButton = null;
5272
+ let touchMomentum = null;
5174
5273
  const onWheel = (e) => {
5175
5274
  if (e.ctrlKey || e.metaKey) {
5176
5275
  e.preventDefault();
@@ -5197,6 +5296,9 @@ function attachViewportInput(options) {
5197
5296
  if (touchHandledElsewhere && e.pointerType === "touch") {
5198
5297
  return;
5199
5298
  }
5299
+ if (touchMomentum) {
5300
+ touchMomentum.cancel();
5301
+ }
5200
5302
  const panOk = allowPrimaryPointerPan();
5201
5303
  if (e.pointerType === "mouse" && e.button === 0) {
5202
5304
  if (!panOk) {
@@ -5222,6 +5324,11 @@ function attachViewportInput(options) {
5222
5324
  if (panOk) {
5223
5325
  mode = "pan";
5224
5326
  panLast = { x: e.clientX, y: e.clientY };
5327
+ touchMomentum = createPanMomentumController(
5328
+ camera,
5329
+ onUpdate,
5330
+ touchPanSensitivity
5331
+ );
5225
5332
  e.preventDefault();
5226
5333
  }
5227
5334
  } else if (pointers.size === 2) {
@@ -5258,6 +5365,7 @@ function attachViewportInput(options) {
5258
5365
  panLast = { x: e.clientX, y: e.clientY };
5259
5366
  camera.x += dx * touchPanSensitivity;
5260
5367
  camera.y += dy * touchPanSensitivity;
5368
+ touchMomentum?.recordMove(dx, dy);
5261
5369
  onUpdate();
5262
5370
  e.preventDefault();
5263
5371
  return;
@@ -5309,15 +5417,27 @@ function attachViewportInput(options) {
5309
5417
  }
5310
5418
  pointers.delete(e.pointerId);
5311
5419
  if (pointers.size === 0) {
5420
+ const wasPanning = mode === "pan";
5312
5421
  mode = "idle";
5313
5422
  panLast = null;
5423
+ if (wasPanning && touchMomentum) {
5424
+ touchMomentum.startMomentum();
5425
+ touchMomentum = null;
5426
+ }
5314
5427
  } else if (pointers.size === 1 && mode === "pinch") {
5315
5428
  mode = "pan";
5316
5429
  const remaining = Array.from(pointers.values())[0];
5317
5430
  panLast = remaining ?? null;
5431
+ if (touchMomentum) {
5432
+ touchMomentum.reset();
5433
+ }
5318
5434
  }
5319
5435
  };
5320
5436
  const onPointerCancel = (e) => {
5437
+ if (touchMomentum) {
5438
+ touchMomentum.cancel();
5439
+ touchMomentum = null;
5440
+ }
5321
5441
  onPointerUp(e);
5322
5442
  };
5323
5443
  wheelTarget.addEventListener("wheel", onWheel, { passive: false });
@@ -5326,6 +5446,10 @@ function attachViewportInput(options) {
5326
5446
  element.addEventListener("pointerup", onPointerUp);
5327
5447
  element.addEventListener("pointercancel", onPointerCancel);
5328
5448
  return () => {
5449
+ if (touchMomentum) {
5450
+ touchMomentum.cancel();
5451
+ touchMomentum = null;
5452
+ }
5329
5453
  wheelTarget.removeEventListener("wheel", onWheel);
5330
5454
  element.removeEventListener("pointerdown", onPointerDown);
5331
5455
  element.removeEventListener("pointermove", onPointerMove);
@@ -6207,6 +6331,7 @@ var SvgVectorRenderer = class {
6207
6331
  svg;
6208
6332
  rootG;
6209
6333
  itemNodeCache = /* @__PURE__ */ new Map();
6334
+ liveOverlay = null;
6210
6335
  resizeObserver;
6211
6336
  constructor(options) {
6212
6337
  this.container = options.container;
@@ -6241,6 +6366,50 @@ var SvgVectorRenderer = class {
6241
6366
  const items = cullItemsByViewport(this.scene.getItems(), visible);
6242
6367
  this.syncVisibleItems(items);
6243
6368
  this.rootG.setAttribute("transform", formatCameraTransform(this.camera));
6369
+ this.keepLiveOverlayOnTop();
6370
+ }
6371
+ /**
6372
+ * Updates only the in-progress (live) stroke node without re-culling or
6373
+ * re-syncing the committed scene items. Drawing tools call this on every
6374
+ * pointer move, so it must stay O(1) regardless of how many items the
6375
+ * board already contains.
6376
+ *
6377
+ * Pass `null` to remove the live overlay (e.g. when the stroke is committed).
6378
+ */
6379
+ renderLiveItem(item) {
6380
+ if (!item) {
6381
+ if (this.liveOverlay) {
6382
+ this.liveOverlay.g.remove();
6383
+ this.liveOverlay = null;
6384
+ }
6385
+ return;
6386
+ }
6387
+ if (!this.liveOverlay) {
6388
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
6389
+ g.setAttribute("data-live-overlay", "true");
6390
+ this.liveOverlay = {
6391
+ g,
6392
+ lastChildrenSvg: "",
6393
+ lastTransform: ""
6394
+ };
6395
+ this.rootG.appendChild(g);
6396
+ }
6397
+ const cached = this.liveOverlay;
6398
+ const t = formatItemPlacementTransform(item);
6399
+ if (cached.lastTransform !== t) {
6400
+ cached.g.setAttribute("transform", t);
6401
+ cached.lastTransform = t;
6402
+ }
6403
+ if (cached.lastChildrenSvg !== item.childrenSvg) {
6404
+ cached.g.innerHTML = item.childrenSvg;
6405
+ cached.lastChildrenSvg = item.childrenSvg;
6406
+ }
6407
+ this.keepLiveOverlayOnTop();
6408
+ }
6409
+ keepLiveOverlayOnTop() {
6410
+ if (this.liveOverlay && this.rootG.lastChild !== this.liveOverlay.g) {
6411
+ this.rootG.appendChild(this.liveOverlay.g);
6412
+ }
6244
6413
  }
6245
6414
  syncVisibleItems(items) {
6246
6415
  const visibleIds = /* @__PURE__ */ new Set();
@@ -6285,6 +6454,7 @@ var SvgVectorRenderer = class {
6285
6454
  destroy() {
6286
6455
  this.resizeObserver.disconnect();
6287
6456
  this.itemNodeCache.clear();
6457
+ this.liveOverlay = null;
6288
6458
  this.svg.remove();
6289
6459
  }
6290
6460
  /** Toggle whether the scene SVG receives pointer events (vs overlay handling them). */
@@ -7761,8 +7931,6 @@ var VectorViewport = react.forwardRef(
7761
7931
  const hiddenIds = new Set(eraserPreviewIds);
7762
7932
  return resolvedItems.filter((it) => !hiddenIds.has(it.id));
7763
7933
  }, [eraserPreviewIds, resolvedItems]);
7764
- const resolvedSceneItemsRef = react.useRef(resolvedSceneItems);
7765
- resolvedSceneItemsRef.current = resolvedSceneItems;
7766
7934
  const livePenStrokeItemRef = react.useRef(null);
7767
7935
  const [eraserActive, setEraserActive] = react.useState(false);
7768
7936
  const [editingTextId, setEditingTextId] = react.useState(null);
@@ -7983,12 +8151,9 @@ var VectorViewport = react.forwardRef(
7983
8151
  const renderSceneWithLivePenStroke = react.useCallback(
7984
8152
  (item) => {
7985
8153
  livePenStrokeItemRef.current = item;
7986
- const scene = sceneRef.current;
7987
8154
  const renderer = rendererRef.current;
7988
- if (!scene || !renderer) return;
7989
- const base2 = resolvedSceneItemsRef.current;
7990
- scene.setItems(item ? [...base2, item] : base2);
7991
- renderer.render();
8155
+ if (!renderer) return;
8156
+ renderer.renderLiveItem(item);
7992
8157
  },
7993
8158
  []
7994
8159
  );
@@ -8219,16 +8384,15 @@ var VectorViewport = react.forwardRef(
8219
8384
  }, []);
8220
8385
  react.useEffect(() => {
8221
8386
  const scene = sceneRef.current;
8387
+ const renderer = rendererRef.current;
8222
8388
  if (scene) {
8223
8389
  const live = livePenStrokeItemRef.current;
8224
8390
  if (live && resolvedSceneItems.some((it) => it.id === live.id)) {
8225
8391
  livePenStrokeItemRef.current = null;
8226
8392
  }
8227
- const currentLive = livePenStrokeItemRef.current;
8228
- scene.setItems(
8229
- currentLive ? [...resolvedSceneItems, currentLive] : resolvedSceneItems
8230
- );
8393
+ scene.setItems(resolvedSceneItems);
8231
8394
  renderFrame();
8395
+ renderer?.renderLiveItem(livePenStrokeItemRef.current);
8232
8396
  }
8233
8397
  }, [resolvedSceneItems, renderFrame]);
8234
8398
  react.useEffect(() => {
@@ -8804,11 +8968,12 @@ var VectorViewport = react.forwardRef(
8804
8968
  setEditingTextId(null);
8805
8969
  }, []);
8806
8970
  const placeImageFilesAtWorld = react.useCallback(
8807
- async (files, worldX, worldY) => {
8971
+ async (files, worldX, worldY, options) => {
8808
8972
  const change = onItemsChangeRef.current;
8809
8973
  if (!change || files.length === 0) return;
8810
8974
  const store = imageStoreRef.current;
8811
8975
  if (!store) return;
8976
+ const stackBelowExistingItems = options?.stackBelowExistingItems ?? true;
8812
8977
  try {
8813
8978
  const pdfFiles = files.filter((file) => file.type === "application/pdf");
8814
8979
  if (pdfFiles.length > 0) {
@@ -8834,7 +8999,7 @@ var VectorViewport = react.forwardRef(
8834
8999
  }
8835
9000
  const result = await ingestAssetFilesToSceneItems({
8836
9001
  files,
8837
- existingItems: itemsRef.current,
9002
+ existingItems: stackBelowExistingItems ? itemsRef.current : [],
8838
9003
  worldCenter: {
8839
9004
  x: worldX,
8840
9005
  y: worldY
@@ -8921,7 +9086,9 @@ var VectorViewport = react.forwardRef(
8921
9086
  );
8922
9087
  if (files.length === 0) return;
8923
9088
  const { worldX, worldY } = screenToWorld(e.clientX, e.clientY);
8924
- await placeImageFilesAtWorld(files, worldX, worldY);
9089
+ await placeImageFilesAtWorld(files, worldX, worldY, {
9090
+ stackBelowExistingItems: false
9091
+ });
8925
9092
  },
8926
9093
  [screenToWorld, placeImageFilesAtWorld]
8927
9094
  );