canvu-react 0.3.23 → 0.3.25

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
@@ -718,6 +718,47 @@ var init_shape_builders = __esm({
718
718
  }
719
719
  });
720
720
 
721
+ // src/image/canvas-encode.ts
722
+ var DEFAULT_FALLBACK_MIME_TYPES = ["image/png"];
723
+ var tryCanvasToBlob = (canvas, mimeType, quality) => new Promise((resolve) => {
724
+ canvas.toBlob((blob) => resolve(blob), mimeType, quality);
725
+ });
726
+ var blobFromDataUrl = async (dataUrl) => {
727
+ const response = await fetch(dataUrl);
728
+ const blob = await response.blob();
729
+ if (blob.size === 0) {
730
+ throw new Error("Failed to encode canvas to blob");
731
+ }
732
+ return blob;
733
+ };
734
+ async function encodeCanvasToBlob(canvas, options) {
735
+ const primaryMimeType = options?.mimeType ?? "image/png";
736
+ const quality = options?.quality;
737
+ const mimeTypes = [
738
+ primaryMimeType,
739
+ ...options?.fallbackMimeTypes ?? DEFAULT_FALLBACK_MIME_TYPES
740
+ ].filter(
741
+ (mimeType, index, mimeTypeList) => mimeTypeList.indexOf(mimeType) === index
742
+ );
743
+ for (const mimeType of mimeTypes) {
744
+ const blob = await tryCanvasToBlob(
745
+ canvas,
746
+ mimeType,
747
+ mimeType === primaryMimeType ? quality : void 0
748
+ );
749
+ if (blob) {
750
+ return blob;
751
+ }
752
+ }
753
+ for (const mimeType of mimeTypes) {
754
+ const dataUrl = canvas.toDataURL(mimeType, quality);
755
+ if (dataUrl && dataUrl !== "data:,") {
756
+ return blobFromDataUrl(dataUrl);
757
+ }
758
+ }
759
+ throw new Error("Failed to encode canvas to blob");
760
+ }
761
+
721
762
  // src/image/indexed-db-image-store.ts
722
763
  var DB_NAME = "canvu-image-store";
723
764
  var DB_VERSION = 1;
@@ -836,19 +877,7 @@ function decodeImageToCanvas(blob, maxDimension) {
836
877
  });
837
878
  }
838
879
  function canvasToBlob(canvas, mime, quality) {
839
- return new Promise((resolve, reject) => {
840
- canvas.toBlob(
841
- (blob) => {
842
- if (!blob) {
843
- reject(new Error("Could not encode blob"));
844
- return;
845
- }
846
- resolve(blob);
847
- },
848
- mime,
849
- quality
850
- );
851
- });
880
+ return encodeCanvasToBlob(canvas, { mimeType: mime, quality });
852
881
  }
853
882
  async function loadImageToStore(file, store) {
854
883
  const originalBlob = file;
@@ -916,21 +945,6 @@ async function renderPageToCanvas(page, scale) {
916
945
  await page.render({ canvas, viewport }).promise;
917
946
  return { canvas, width: w, height: h };
918
947
  }
919
- function canvasToBlob2(canvas, mime, quality) {
920
- return new Promise((resolve, reject) => {
921
- canvas.toBlob(
922
- (blob) => {
923
- if (!blob) {
924
- reject(new Error("Could not encode blob"));
925
- return;
926
- }
927
- resolve(blob);
928
- },
929
- mime,
930
- quality
931
- );
932
- });
933
- }
934
948
  function normalizePdfPageNumbers(pageNumbers, pageCount) {
935
949
  if (!pageNumbers || pageNumbers.length === 0) {
936
950
  return Array.from({ length: pageCount }, (_, index) => index + 1);
@@ -992,7 +1006,7 @@ async function loadPdfToStore(file, store, options) {
992
1006
  const page = await pdf.getPage(pageNumber);
993
1007
  const { canvas, width, height } = await renderPageToCanvas(page, scale);
994
1008
  const mime = "image/png";
995
- const pageBlob = await canvasToBlob2(canvas, mime);
1009
+ const pageBlob = await encodeCanvasToBlob(canvas, { mimeType: mime });
996
1010
  const blobId = await store.storeOriginal(pageBlob);
997
1011
  const thumbnailBlobId = storeThumbnails ? await (async () => {
998
1012
  const thumbScale = Math.min(1, 256 / Math.max(width, height));
@@ -1007,7 +1021,9 @@ async function loadPdfToStore(file, store, options) {
1007
1021
  tCtx.imageSmoothingQuality = "high";
1008
1022
  tCtx.drawImage(canvas, 0, 0, tw, th);
1009
1023
  }
1010
- const thumbBlob = await canvasToBlob2(thumbCanvas, mime);
1024
+ const thumbBlob = await encodeCanvasToBlob(thumbCanvas, {
1025
+ mimeType: mime
1026
+ });
1011
1027
  return await store.storeThumbnail(thumbBlob);
1012
1028
  })() : "";
1013
1029
  const pageResult = {
@@ -1611,6 +1627,21 @@ function reorderManagedImages(items, orderedManagedIds) {
1611
1627
  });
1612
1628
  return restackManagedImages(next);
1613
1629
  }
1630
+ var OPEN_KEYFRAME_ID = "canvu-images-menu-open-keyframe";
1631
+ var OPEN_KEYFRAME_CSS = `
1632
+ @keyframes canvu-images-menu-open {
1633
+ from { opacity: 0; transform: scale(0.6); }
1634
+ to { opacity: 1; transform: scale(1); }
1635
+ }
1636
+ `;
1637
+ function ensureOpenKeyframe() {
1638
+ if (typeof document === "undefined") return;
1639
+ if (document.getElementById(OPEN_KEYFRAME_ID)) return;
1640
+ const style = document.createElement("style");
1641
+ style.id = OPEN_KEYFRAME_ID;
1642
+ style.textContent = OPEN_KEYFRAME_CSS;
1643
+ document.head.appendChild(style);
1644
+ }
1614
1645
  var panelStyle = {
1615
1646
  width: "fit-content",
1616
1647
  maxHeight: "min(85dvh, 820px)",
@@ -1621,7 +1652,9 @@ var panelStyle = {
1621
1652
  boxShadow: "0 10px 40px rgba(15, 23, 42, 0.12)",
1622
1653
  fontFamily: "system-ui, sans-serif",
1623
1654
  fontSize: 14,
1624
- color: "#0f172a"
1655
+ color: "#0f172a",
1656
+ transformOrigin: "top right",
1657
+ animation: "canvu-images-menu-open 180ms cubic-bezier(0.2, 0.8, 0.2, 1)"
1625
1658
  };
1626
1659
  var headerStyle = {
1627
1660
  display: "flex",
@@ -1743,33 +1776,16 @@ var collapsedButtonStyle = {
1743
1776
  display: "inline-flex",
1744
1777
  alignItems: "center",
1745
1778
  justifyContent: "center",
1746
- gap: 6,
1779
+ width: 44,
1747
1780
  height: 44,
1748
- minWidth: 44,
1749
- padding: "0 12px",
1781
+ padding: 0,
1750
1782
  border: "1px solid #e2e8f0",
1751
- borderRadius: 22,
1783
+ borderRadius: 10,
1752
1784
  background: "#ffffff",
1753
1785
  color: "#0f172a",
1754
1786
  cursor: "pointer",
1755
- fontFamily: "system-ui, sans-serif",
1756
- fontSize: 13,
1757
- fontWeight: 600,
1758
1787
  boxShadow: "0 8px 24px rgba(15, 23, 42, 0.12)"
1759
1788
  };
1760
- var collapsedCountStyle = {
1761
- display: "inline-flex",
1762
- alignItems: "center",
1763
- justifyContent: "center",
1764
- minWidth: 20,
1765
- height: 20,
1766
- padding: "0 6px",
1767
- borderRadius: 10,
1768
- backgroundColor: "#0f172a",
1769
- color: "#ffffff",
1770
- fontSize: 11,
1771
- fontWeight: 600
1772
- };
1773
1789
  var defaultLabels = {
1774
1790
  title: "Images",
1775
1791
  dragHandle: "Drag to reorder",
@@ -1793,12 +1809,15 @@ function ImagesMenu({
1793
1809
  })
1794
1810
  );
1795
1811
  const [collapsed, setCollapsed] = react.useState(false);
1812
+ react.useEffect(() => {
1813
+ ensureOpenKeyframe();
1814
+ }, []);
1796
1815
  if (managed.length === 0) {
1797
1816
  return null;
1798
1817
  }
1799
1818
  const resolvedLabels = { ...defaultLabels, ...labels };
1800
1819
  if (collapsed) {
1801
- return /* @__PURE__ */ jsxRuntime.jsxs(
1820
+ return /* @__PURE__ */ jsxRuntime.jsx(
1802
1821
  "button",
1803
1822
  {
1804
1823
  type: "button",
@@ -1807,10 +1826,7 @@ function ImagesMenu({
1807
1826
  "aria-label": resolvedLabels.expand,
1808
1827
  title: resolvedLabels.expand,
1809
1828
  onClick: () => setCollapsed(false),
1810
- children: [
1811
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Images, { size: 20 }),
1812
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: collapsedCountStyle, children: managed.length })
1813
- ]
1829
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Images, { size: 20 })
1814
1830
  }
1815
1831
  );
1816
1832
  }
@@ -1879,11 +1895,20 @@ function ImagesMenuRow({
1879
1895
  onRotate,
1880
1896
  onDelete
1881
1897
  }) {
1882
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = sortable.useSortable({ id: item.id });
1898
+ const {
1899
+ attributes,
1900
+ listeners,
1901
+ setNodeRef,
1902
+ setActivatorNodeRef,
1903
+ transform,
1904
+ transition,
1905
+ isDragging
1906
+ } = sortable.useSortable({ id: item.id });
1907
+ const feedbackTransition = "background-color 140ms ease, opacity 140ms ease";
1883
1908
  const wrapperStyle = {
1884
1909
  ...rowStyle,
1885
1910
  transform: utilities.CSS.Transform.toString(transform),
1886
- transition,
1911
+ transition: transition ? `${transition}, ${feedbackTransition}` : feedbackTransition,
1887
1912
  background: isDragging ? "#eef2f7" : "transparent",
1888
1913
  opacity: isDragging ? 0.85 : 1
1889
1914
  };
@@ -1892,6 +1917,7 @@ function ImagesMenuRow({
1892
1917
  /* @__PURE__ */ jsxRuntime.jsx(
1893
1918
  "button",
1894
1919
  {
1920
+ ref: setActivatorNodeRef,
1895
1921
  type: "button",
1896
1922
  style: handleStyle,
1897
1923
  "aria-label": labels.dragHandle,
@@ -4786,16 +4812,23 @@ function distanceBetween(a, b) {
4786
4812
  function midpoint(a, b) {
4787
4813
  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
4788
4814
  }
4789
- function wheelDeltaYPixels(e) {
4790
- switch (e.deltaMode) {
4815
+ function wheelDeltaPixels(delta, deltaMode) {
4816
+ switch (deltaMode) {
4791
4817
  case WheelEvent.DOM_DELTA_LINE:
4792
- return e.deltaY * 16;
4818
+ return delta * 16;
4793
4819
  case WheelEvent.DOM_DELTA_PAGE:
4794
- return e.deltaY * 400;
4820
+ return delta * 400;
4795
4821
  default:
4796
- return e.deltaY;
4822
+ return delta;
4797
4823
  }
4798
4824
  }
4825
+ var TRACKPAD_MAX_DELTA = 20;
4826
+ var MOUSE_WHEEL_DAMPING = 0.4;
4827
+ function softenWheelDelta(d) {
4828
+ const a = Math.abs(d);
4829
+ if (a <= TRACKPAD_MAX_DELTA) return d;
4830
+ return Math.sign(d) * (TRACKPAD_MAX_DELTA + (a - TRACKPAD_MAX_DELTA) * MOUSE_WHEEL_DAMPING);
4831
+ }
4799
4832
  function attachViewportInput(options) {
4800
4833
  const {
4801
4834
  element,
@@ -4818,8 +4851,8 @@ function attachViewportInput(options) {
4818
4851
  const onWheel = (e) => {
4819
4852
  if (e.ctrlKey || e.metaKey) {
4820
4853
  e.preventDefault();
4821
- const dy = wheelDeltaYPixels(e);
4822
- const normDy = Math.abs(dy) < 20 ? dy * 12 : dy;
4854
+ const dy = wheelDeltaPixels(e.deltaY, e.deltaMode);
4855
+ const normDy = Math.abs(dy) < TRACKPAD_MAX_DELTA ? dy * 12 : dy;
4823
4856
  const factor = Math.exp(-normDy * wheelZoomSensitivity);
4824
4857
  const rect = element.getBoundingClientRect();
4825
4858
  camera.setZoom(camera.zoom * factor, {
@@ -4830,8 +4863,10 @@ function attachViewportInput(options) {
4830
4863
  return;
4831
4864
  }
4832
4865
  e.preventDefault();
4833
- camera.x -= e.deltaX * wheelPanSensitivity / camera.zoom;
4834
- camera.y -= e.deltaY * wheelPanSensitivity / camera.zoom;
4866
+ const panDx = softenWheelDelta(wheelDeltaPixels(e.deltaX, e.deltaMode));
4867
+ const panDy = softenWheelDelta(wheelDeltaPixels(e.deltaY, e.deltaMode));
4868
+ camera.x -= panDx * wheelPanSensitivity / camera.zoom;
4869
+ camera.y -= panDy * wheelPanSensitivity / camera.zoom;
4835
4870
  onUpdate();
4836
4871
  };
4837
4872
  const onPointerDown = (e) => {
@@ -7386,6 +7421,40 @@ var VectorViewport = react.forwardRef(
7386
7421
  onWorldPointerLeaveRef.current = onWorldPointerLeave;
7387
7422
  const onPlacementPreviewChangeRef = react.useRef(onPlacementPreviewChange);
7388
7423
  onPlacementPreviewChangeRef.current = onPlacementPreviewChange;
7424
+ const directRemoteStrokePreviewRef = react.useRef(false);
7425
+ const remoteStrokePreviewFrameRef = react.useRef(null);
7426
+ const pendingRemoteStrokePreviewRef = react.useRef(null);
7427
+ const flushRemoteStrokePreview = react.useCallback(() => {
7428
+ remoteStrokePreviewFrameRef.current = null;
7429
+ const pending = pendingRemoteStrokePreviewRef.current;
7430
+ if (!pending) return;
7431
+ onPlacementPreviewChangeRef.current?.({
7432
+ kind: "stroke",
7433
+ tool: pending.tool,
7434
+ points: pending.points
7435
+ });
7436
+ }, []);
7437
+ const emitRemoteStrokePreview = react.useCallback(
7438
+ (tool, points) => {
7439
+ if (tool === "laser") return;
7440
+ directRemoteStrokePreviewRef.current = true;
7441
+ pendingRemoteStrokePreviewRef.current = { tool, points };
7442
+ if (remoteStrokePreviewFrameRef.current != null) return;
7443
+ remoteStrokePreviewFrameRef.current = requestAnimationFrame(
7444
+ flushRemoteStrokePreview
7445
+ );
7446
+ },
7447
+ [flushRemoteStrokePreview]
7448
+ );
7449
+ const emitRemoteStrokePreviewClear = react.useCallback(() => {
7450
+ if (remoteStrokePreviewFrameRef.current != null) {
7451
+ cancelAnimationFrame(remoteStrokePreviewFrameRef.current);
7452
+ remoteStrokePreviewFrameRef.current = null;
7453
+ }
7454
+ pendingRemoteStrokePreviewRef.current = null;
7455
+ directRemoteStrokePreviewRef.current = false;
7456
+ onPlacementPreviewChangeRef.current?.(null);
7457
+ }, []);
7389
7458
  const overlayCameraTickRef = react.useRef(false);
7390
7459
  overlayCameraTickRef.current = interactive || remotePresence != null && remotePresence.length > 0 || presenceOverlay != null;
7391
7460
  const pruneEraserTrail = react.useCallback(
@@ -7504,6 +7573,7 @@ var VectorViewport = react.forwardRef(
7504
7573
  if (itemId) {
7505
7574
  renderSceneWithLivePenStroke(null);
7506
7575
  }
7576
+ emitRemoteStrokePreviewClear();
7507
7577
  setPlacementPreview(null);
7508
7578
  commitCompletedStroke({
7509
7579
  tool,
@@ -7515,6 +7585,7 @@ var VectorViewport = react.forwardRef(
7515
7585
  },
7516
7586
  [
7517
7587
  commitCompletedStroke,
7588
+ emitRemoteStrokePreviewClear,
7518
7589
  releaseInteractionPointer,
7519
7590
  renderSceneWithLivePenStroke
7520
7591
  ]
@@ -7626,8 +7697,18 @@ var VectorViewport = react.forwardRef(
7626
7697
  };
7627
7698
  }, [onWorldPointerMove]);
7628
7699
  react.useEffect(() => {
7700
+ if (directRemoteStrokePreviewRef.current && placementPreview === null) {
7701
+ return;
7702
+ }
7629
7703
  onPlacementPreviewChangeRef.current?.(placementPreview);
7630
7704
  }, [placementPreview]);
7705
+ react.useEffect(() => {
7706
+ return () => {
7707
+ if (remoteStrokePreviewFrameRef.current != null) {
7708
+ cancelAnimationFrame(remoteStrokePreviewFrameRef.current);
7709
+ }
7710
+ };
7711
+ }, []);
7631
7712
  react.useEffect(() => {
7632
7713
  const scene = sceneRef.current;
7633
7714
  if (scene) {
@@ -7658,15 +7739,22 @@ var VectorViewport = react.forwardRef(
7658
7739
  react.useEffect(() => {
7659
7740
  rememberImageBlobHrefs(items, rememberedImageBlobHrefsRef.current);
7660
7741
  }, [items]);
7661
- react.useEffect(
7662
- () => () => {
7663
- releaseRememberedBlobHrefs(
7664
- rememberedImageBlobHrefsRef.current,
7665
- (href) => URL.revokeObjectURL(href)
7666
- );
7667
- },
7668
- []
7669
- );
7742
+ const blobRevokeTimerRef = react.useRef(null);
7743
+ react.useEffect(() => {
7744
+ if (blobRevokeTimerRef.current !== null) {
7745
+ clearTimeout(blobRevokeTimerRef.current);
7746
+ blobRevokeTimerRef.current = null;
7747
+ }
7748
+ return () => {
7749
+ blobRevokeTimerRef.current = setTimeout(() => {
7750
+ releaseRememberedBlobHrefs(
7751
+ rememberedImageBlobHrefsRef.current,
7752
+ (href) => URL.revokeObjectURL(href)
7753
+ );
7754
+ blobRevokeTimerRef.current = null;
7755
+ }, 100);
7756
+ };
7757
+ }, []);
7670
7758
  const PASTE_OFFSET_WORLD = 24;
7671
7759
  const copyIdsToInternalClipboard = react.useCallback((ids) => {
7672
7760
  if (ids.length === 0) return;
@@ -8614,6 +8702,7 @@ var VectorViewport = react.forwardRef(
8614
8702
  setPlacementPreview(null);
8615
8703
  } else if (directPenStroke) {
8616
8704
  setPlacementPreview(null);
8705
+ emitRemoteStrokePreview(tool, [startPoint]);
8617
8706
  } else {
8618
8707
  setPlacementPreview({
8619
8708
  kind: "stroke",
@@ -8655,6 +8744,7 @@ var VectorViewport = react.forwardRef(
8655
8744
  [
8656
8745
  applePencilNav,
8657
8746
  captureInteractionPointer,
8747
+ emitRemoteStrokePreview,
8658
8748
  finalizeStrokeDragState,
8659
8749
  renderSceneWithLivePenStroke,
8660
8750
  screenToWorld
@@ -8713,6 +8803,7 @@ var VectorViewport = react.forwardRef(
8713
8803
  activeInteractionPointerIdRef.current = e.pointerId;
8714
8804
  activeInteractionPointerTargetRef.current = null;
8715
8805
  setPlacementPreview(null);
8806
+ emitRemoteStrokePreview(tool, [startPoint]);
8716
8807
  debugApplePencilPointer("native-pointerdown", {
8717
8808
  pointerType: e.pointerType,
8718
8809
  pointerId: e.pointerId,
@@ -8730,6 +8821,7 @@ var VectorViewport = react.forwardRef(
8730
8821
  };
8731
8822
  }, [
8732
8823
  applePencilNav,
8824
+ emitRemoteStrokePreview,
8733
8825
  finalizeStrokeDragState,
8734
8826
  interactive,
8735
8827
  renderSceneWithLivePenStroke,
@@ -8813,6 +8905,7 @@ var VectorViewport = react.forwardRef(
8813
8905
  activeInteractionPointerTargetRef.current = null;
8814
8906
  activeInteractionTouchIdRef.current = touch.identifier;
8815
8907
  setPlacementPreview(null);
8908
+ emitRemoteStrokePreview(tool, [startPoint]);
8816
8909
  debugApplePencilPointer("touchstart-stroke", {
8817
8910
  touchId: touch.identifier,
8818
8911
  touchType: touchKind(touch),
@@ -8844,6 +8937,7 @@ var VectorViewport = react.forwardRef(
8844
8937
  renderSceneWithLivePenStroke(item);
8845
8938
  }
8846
8939
  setPlacementPreview(null);
8940
+ emitRemoteStrokePreview(st.tool, interpolated);
8847
8941
  }
8848
8942
  debugApplePencilPointer("touchmove-stroke", {
8849
8943
  touchId: touch.identifier,
@@ -8906,6 +9000,7 @@ var VectorViewport = react.forwardRef(
8906
9000
  };
8907
9001
  }, [
8908
9002
  applePencilNav,
9003
+ emitRemoteStrokePreview,
8909
9004
  finalizeStrokeDragState,
8910
9005
  interactive,
8911
9006
  renderSceneWithLivePenStroke,
@@ -8960,6 +9055,7 @@ var VectorViewport = react.forwardRef(
8960
9055
  renderSceneWithLivePenStroke(item);
8961
9056
  }
8962
9057
  setPlacementPreview(null);
9058
+ emitRemoteStrokePreview(st.tool, interpolated);
8963
9059
  return;
8964
9060
  }
8965
9061
  if (st.tool === "laser") {
@@ -9101,6 +9197,9 @@ var VectorViewport = react.forwardRef(
9101
9197
  }
9102
9198
  const cam = cameraRef.current;
9103
9199
  if (!cam) {
9200
+ if (st.kind === "stroke") {
9201
+ emitRemoteStrokePreviewClear();
9202
+ }
9104
9203
  if (st.kind === "erase") {
9105
9204
  eraserPreviewIdsRef.current.clear();
9106
9205
  setEraserPreviewIds([]);
@@ -9332,6 +9431,8 @@ var VectorViewport = react.forwardRef(
9332
9431
  document.removeEventListener("pointercancel", onUp);
9333
9432
  };
9334
9433
  }, [
9434
+ emitRemoteStrokePreview,
9435
+ emitRemoteStrokePreviewClear,
9335
9436
  interactive,
9336
9437
  pruneEraserTrail,
9337
9438
  pruneLaserTrail,