canvu-react 0.3.37 → 0.3.39

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). */
@@ -6293,6 +6463,18 @@ var SvgVectorRenderer = class {
6293
6463
  }
6294
6464
  };
6295
6465
 
6466
+ // src/scene/link-item.ts
6467
+ var LINK_PLUGIN_KEY = "canvuLink";
6468
+ var isCanvuLinkData = (value) => {
6469
+ if (!value || typeof value !== "object") return false;
6470
+ const candidate = value;
6471
+ return typeof candidate.href === "string" && candidate.href.length > 0;
6472
+ };
6473
+ function getLinkData(item) {
6474
+ const entry = item.pluginData?.[LINK_PLUGIN_KEY];
6475
+ return isCanvuLinkData(entry) ? entry : null;
6476
+ }
6477
+
6296
6478
  // src/scene/scene.ts
6297
6479
  var VectorScene = class {
6298
6480
  items = [];
@@ -7469,6 +7651,7 @@ var VectorViewport = react.forwardRef(
7469
7651
  selectedIds: selectedIdsProp,
7470
7652
  onSelectionChange,
7471
7653
  onItemsChange: consumerOnItemsChange,
7654
+ onActivateLink,
7472
7655
  onWorldPointerDown: consumerOnWorldPointerDown,
7473
7656
  toolbar,
7474
7657
  navMenu,
@@ -7649,6 +7832,8 @@ var VectorViewport = react.forwardRef(
7649
7832
  const itemsRef = react.useRef(items);
7650
7833
  const onWorldPointerDownRef = react.useRef(onWorldPointerDown);
7651
7834
  const onItemsChangeRef = react.useRef(onItemsChange);
7835
+ const onActivateLinkRef = react.useRef(onActivateLink);
7836
+ onActivateLinkRef.current = onActivateLink;
7652
7837
  const assetStoreRef = react.useRef(assetStore);
7653
7838
  assetStoreRef.current = assetStore;
7654
7839
  const customPlacementRef = react.useRef(customPlacement);
@@ -7761,8 +7946,6 @@ var VectorViewport = react.forwardRef(
7761
7946
  const hiddenIds = new Set(eraserPreviewIds);
7762
7947
  return resolvedItems.filter((it) => !hiddenIds.has(it.id));
7763
7948
  }, [eraserPreviewIds, resolvedItems]);
7764
- const resolvedSceneItemsRef = react.useRef(resolvedSceneItems);
7765
- resolvedSceneItemsRef.current = resolvedSceneItems;
7766
7949
  const livePenStrokeItemRef = react.useRef(null);
7767
7950
  const [eraserActive, setEraserActive] = react.useState(false);
7768
7951
  const [editingTextId, setEditingTextId] = react.useState(null);
@@ -7983,12 +8166,9 @@ var VectorViewport = react.forwardRef(
7983
8166
  const renderSceneWithLivePenStroke = react.useCallback(
7984
8167
  (item) => {
7985
8168
  livePenStrokeItemRef.current = item;
7986
- const scene = sceneRef.current;
7987
8169
  const renderer = rendererRef.current;
7988
- if (!scene || !renderer) return;
7989
- const base2 = resolvedSceneItemsRef.current;
7990
- scene.setItems(item ? [...base2, item] : base2);
7991
- renderer.render();
8170
+ if (!renderer) return;
8171
+ renderer.renderLiveItem(item);
7992
8172
  },
7993
8173
  []
7994
8174
  );
@@ -8219,16 +8399,15 @@ var VectorViewport = react.forwardRef(
8219
8399
  }, []);
8220
8400
  react.useEffect(() => {
8221
8401
  const scene = sceneRef.current;
8402
+ const renderer = rendererRef.current;
8222
8403
  if (scene) {
8223
8404
  const live = livePenStrokeItemRef.current;
8224
8405
  if (live && resolvedSceneItems.some((it) => it.id === live.id)) {
8225
8406
  livePenStrokeItemRef.current = null;
8226
8407
  }
8227
- const currentLive = livePenStrokeItemRef.current;
8228
- scene.setItems(
8229
- currentLive ? [...resolvedSceneItems, currentLive] : resolvedSceneItems
8230
- );
8408
+ scene.setItems(resolvedSceneItems);
8231
8409
  renderFrame();
8410
+ renderer?.renderLiveItem(livePenStrokeItemRef.current);
8232
8411
  }
8233
8412
  }, [resolvedSceneItems, renderFrame]);
8234
8413
  react.useEffect(() => {
@@ -8804,11 +8983,12 @@ var VectorViewport = react.forwardRef(
8804
8983
  setEditingTextId(null);
8805
8984
  }, []);
8806
8985
  const placeImageFilesAtWorld = react.useCallback(
8807
- async (files, worldX, worldY) => {
8986
+ async (files, worldX, worldY, options) => {
8808
8987
  const change = onItemsChangeRef.current;
8809
8988
  if (!change || files.length === 0) return;
8810
8989
  const store = imageStoreRef.current;
8811
8990
  if (!store) return;
8991
+ const stackBelowExistingItems = options?.stackBelowExistingItems ?? true;
8812
8992
  try {
8813
8993
  const pdfFiles = files.filter((file) => file.type === "application/pdf");
8814
8994
  if (pdfFiles.length > 0) {
@@ -8834,7 +9014,7 @@ var VectorViewport = react.forwardRef(
8834
9014
  }
8835
9015
  const result = await ingestAssetFilesToSceneItems({
8836
9016
  files,
8837
- existingItems: itemsRef.current,
9017
+ existingItems: stackBelowExistingItems ? itemsRef.current : [],
8838
9018
  worldCenter: {
8839
9019
  x: worldX,
8840
9020
  y: worldY
@@ -8921,19 +9101,35 @@ var VectorViewport = react.forwardRef(
8921
9101
  );
8922
9102
  if (files.length === 0) return;
8923
9103
  const { worldX, worldY } = screenToWorld(e.clientX, e.clientY);
8924
- await placeImageFilesAtWorld(files, worldX, worldY);
9104
+ await placeImageFilesAtWorld(files, worldX, worldY, {
9105
+ stackBelowExistingItems: false
9106
+ });
8925
9107
  },
8926
9108
  [screenToWorld, placeImageFilesAtWorld]
8927
9109
  );
8928
9110
  const handleOverlayDoubleClick = react.useCallback(
8929
9111
  (e) => {
8930
- if (!interactiveRef.current || !onItemsChangeRef.current) return;
8931
- if (toolIdRef.current !== "select") return;
8932
- e.preventDefault();
8933
9112
  const cam = cameraRef.current;
8934
9113
  if (!cam) return;
8935
9114
  const { worldX, worldY } = screenToWorld(e.clientX, e.clientY);
8936
9115
  const lineHitWorld = 10 / cam.zoom;
9116
+ const linkHit = hitTestWorldPoint(resolvedItemsRef.current, worldX, worldY, {
9117
+ lineHitWorld,
9118
+ ignoreLocked: false
9119
+ });
9120
+ const link = linkHit ? getLinkData(linkHit) : null;
9121
+ if (linkHit && link) {
9122
+ e.preventDefault();
9123
+ if (onActivateLinkRef.current) {
9124
+ onActivateLinkRef.current({ link, item: linkHit });
9125
+ } else if (typeof window !== "undefined" && typeof window.open === "function") {
9126
+ window.open(link.href, "_blank", "noopener,noreferrer");
9127
+ }
9128
+ return;
9129
+ }
9130
+ if (!interactiveRef.current || !onItemsChangeRef.current) return;
9131
+ if (toolIdRef.current !== "select") return;
9132
+ e.preventDefault();
8937
9133
  const hit = hitTestWorldPoint(resolvedItemsRef.current, worldX, worldY, {
8938
9134
  lineHitWorld,
8939
9135
  ignoreLocked: true