@canvas-harness/react 0.1.2 → 0.1.4

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.d.cts CHANGED
@@ -131,6 +131,24 @@ type CanvasProps = {
131
131
  * <Canvas selectionColor="#10b981" />
132
132
  */
133
133
  selectionColor?: string;
134
+ /**
135
+ * Cap on the canvas backing-store DPR. Defaults to `1`.
136
+ *
137
+ * At native device-pixel ratio on hi-DPI displays (Mac Retina ≈ 2,
138
+ * Windows 4K @ 175% ≈ 1.75), the canvas backing buffer can hit
139
+ * 20-30 megapixels per frame — the per-frame GPU-upload cost alone
140
+ * eats a sizable slice of the frame budget. Capping DPR at 1 keeps
141
+ * perf consistent across hardware at the cost of slightly softer
142
+ * shape outlines on hi-DPI displays. Text remains crisp regardless
143
+ * (the text bitmap cache handles its own DPR).
144
+ *
145
+ * Bump to `2` (or `window.devicePixelRatio`) when crispness matters
146
+ * more than FPS — e.g. presentation slides, print-export views.
147
+ *
148
+ * @example
149
+ * <Canvas maxDpr={2} /> // pixel-crisp at the cost of FPS on hi-DPI
150
+ */
151
+ maxDpr?: number;
134
152
  /**
135
153
  * Render a custom node's React subtree. Called once per
136
154
  * library-mounted custom-node id; positioning is handled by the
package/dist/index.d.ts CHANGED
@@ -131,6 +131,24 @@ type CanvasProps = {
131
131
  * <Canvas selectionColor="#10b981" />
132
132
  */
133
133
  selectionColor?: string;
134
+ /**
135
+ * Cap on the canvas backing-store DPR. Defaults to `1`.
136
+ *
137
+ * At native device-pixel ratio on hi-DPI displays (Mac Retina ≈ 2,
138
+ * Windows 4K @ 175% ≈ 1.75), the canvas backing buffer can hit
139
+ * 20-30 megapixels per frame — the per-frame GPU-upload cost alone
140
+ * eats a sizable slice of the frame budget. Capping DPR at 1 keeps
141
+ * perf consistent across hardware at the cost of slightly softer
142
+ * shape outlines on hi-DPI displays. Text remains crisp regardless
143
+ * (the text bitmap cache handles its own DPR).
144
+ *
145
+ * Bump to `2` (or `window.devicePixelRatio`) when crispness matters
146
+ * more than FPS — e.g. presentation slides, print-export views.
147
+ *
148
+ * @example
149
+ * <Canvas maxDpr={2} /> // pixel-crisp at the cost of FPS on hi-DPI
150
+ */
151
+ maxDpr?: number;
134
152
  /**
135
153
  * Render a custom node's React subtree. Called once per
136
154
  * library-mounted custom-node id; positioning is handled by the
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRenderer, DEFAULT_MINIMAP_MAX_NODES, renderMinimapContent, sceneBounds, drawMinimapViewport, worldViewportFromCamera, screenToWorld, hitTestAny, copy, cut, paste, createPalmRejectionState, createDefaultTextareaEditor, minimapScreenToWorld, notePenActive, shouldRejectTouch, notePenInactive, asEdgeId, midpointToCubicControls, projectToNodeBoundary, marqueeNodes, shouldAutoFit, computeAutoFitHeight, hitTestPoint, worldToNodeLocal, edgeLabelBoundsWorld, asNodeId, zoomAtScreenPoint, clampZoom, panByScreen } from '@canvas-harness/core';
2
- import { createContext, useContext, useSyncExternalStore, useRef, useState, useEffect } from 'react';
2
+ import { createContext, useContext, useSyncExternalStore, useRef, useEffect, useState } from 'react';
3
3
  import { jsx, jsxs } from 'react/jsx-runtime';
4
4
 
5
5
  // src/Canvas.tsx
@@ -813,12 +813,27 @@ var usePanZoom = (ref, store, tool) => {
813
813
  scheduled = false;
814
814
  rafId = 0;
815
815
  if (pendingZoomFactor !== 1 && pendingZoomAnchor) {
816
+ const MAX_PER_FRAME = 2;
817
+ const MIN_PER_FRAME = 0.5;
818
+ let appliedFactor = pendingZoomFactor;
819
+ let drained = true;
820
+ if (appliedFactor > MAX_PER_FRAME) {
821
+ appliedFactor = MAX_PER_FRAME;
822
+ pendingZoomFactor = pendingZoomFactor / MAX_PER_FRAME;
823
+ drained = false;
824
+ } else if (appliedFactor < MIN_PER_FRAME) {
825
+ appliedFactor = MIN_PER_FRAME;
826
+ pendingZoomFactor = pendingZoomFactor / MIN_PER_FRAME;
827
+ drained = false;
828
+ } else {
829
+ pendingZoomFactor = 1;
830
+ }
816
831
  const camera = store.getCamera();
817
832
  store.setCamera(
818
- zoomAtScreenPoint(camera, clampZoom(camera.z * pendingZoomFactor), pendingZoomAnchor)
833
+ zoomAtScreenPoint(camera, clampZoom(camera.z * appliedFactor), pendingZoomAnchor)
819
834
  );
820
- pendingZoomFactor = 1;
821
- pendingZoomAnchor = null;
835
+ if (drained) pendingZoomAnchor = null;
836
+ else schedule();
822
837
  }
823
838
  if (pendingDx !== 0 || pendingDy !== 0) {
824
839
  const camera = store.getCamera();
@@ -855,7 +870,7 @@ var usePanZoom = (ref, store, tool) => {
855
870
  if (isEditing()) return;
856
871
  e.preventDefault();
857
872
  if (e.ctrlKey || e.metaKey) {
858
- const factor = Math.exp(-e.deltaY * 0.01);
873
+ const factor = Math.abs(e.deltaY) >= 100 ? e.deltaY > 0 ? 1 / 1.1 : 1.1 : Math.exp(-e.deltaY * 0.01);
859
874
  pendingZoomFactor *= factor;
860
875
  pendingZoomAnchor = screenFromClient(e.clientX, e.clientY);
861
876
  pulseMotion("zooming");
@@ -1030,6 +1045,7 @@ function CanvasSurface({
1030
1045
  arrowDefaults,
1031
1046
  background,
1032
1047
  selectionColor,
1048
+ maxDpr,
1033
1049
  renderCustomNodeView,
1034
1050
  children
1035
1051
  }) {
@@ -1047,8 +1063,15 @@ function CanvasSurface({
1047
1063
  const interactionMode = useInteractionMode();
1048
1064
  useArrowTool(wrapRef, store, tool === "arrow", arrowDefaults);
1049
1065
  const { mountedIds, setMountedIds } = useOverlayHost();
1050
- const [camera, setCamera] = useState(() => store.getCamera());
1051
- useEffect(() => store.subscribe("camera", (c) => setCamera({ ...c })), [store]);
1066
+ useEffect(() => {
1067
+ const el = overlayRef.current;
1068
+ if (!el) return;
1069
+ const apply = (c) => {
1070
+ el.style.transform = `translate(${-c.x * c.z}px, ${-c.y * c.z}px) scale(${c.z})`;
1071
+ };
1072
+ apply(store.getCamera());
1073
+ return store.subscribe("camera", apply);
1074
+ }, [store]);
1052
1075
  useEffect(() => {
1053
1076
  if (!staticRef.current || !interactiveRef.current || w === 0 || h === 0) return;
1054
1077
  if (rendererRef.current) {
@@ -1064,6 +1087,7 @@ function CanvasSurface({
1064
1087
  height: h,
1065
1088
  background,
1066
1089
  selectionColor,
1090
+ maxDpr,
1067
1091
  onOverlayChange: (ids) => setMountedIds(ids)
1068
1092
  });
1069
1093
  r.start();
@@ -1073,7 +1097,7 @@ function CanvasSurface({
1073
1097
  r.dispose();
1074
1098
  rendererRef.current = null;
1075
1099
  };
1076
- }, [store, theme, w, h, onRenderer, setMountedIds]);
1100
+ }, [store, theme, w, h, maxDpr, onRenderer, setMountedIds]);
1077
1101
  useEffect(() => {
1078
1102
  rendererRef.current?.setBackground(background);
1079
1103
  }, [background]);
@@ -1095,9 +1119,9 @@ function CanvasSurface({
1095
1119
  if (toolRef.current === "select") {
1096
1120
  const rect = el.getBoundingClientRect();
1097
1121
  const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
1098
- const camera2 = store.getCamera();
1099
- const world = screenToWorld(screen, camera2);
1100
- const hit = hitTestAny(store, world, camera2.z);
1122
+ const camera = store.getCamera();
1123
+ const world = screenToWorld(screen, camera);
1124
+ const hit = hitTestAny(store, world, camera.z);
1101
1125
  if (hit && hit.kind === "body" && "nodeId" in hit) {
1102
1126
  store.beginEdit(hit.nodeId);
1103
1127
  } else if (hit && hit.kind === "body" && "edgeId" in hit) {
@@ -1135,9 +1159,9 @@ function CanvasSurface({
1135
1159
  if (e.button !== 0) return;
1136
1160
  if (!isShapeTool(toolRef.current)) return;
1137
1161
  if (store.getInteractionState().mode === "editing") return;
1138
- const camera2 = store.getCamera();
1139
- const world = screenToWorld(screenFromEvent(e), camera2);
1140
- if (hitTestAny(store, world, camera2.z)) return;
1162
+ const camera = store.getCamera();
1163
+ const world = screenToWorld(screenFromEvent(e), camera);
1164
+ if (hitTestAny(store, world, camera.z)) return;
1141
1165
  startWorld = world;
1142
1166
  startScreen = screenFromEvent(e);
1143
1167
  activePointerId = e.pointerId;
@@ -1244,7 +1268,8 @@ function CanvasSurface({
1244
1268
  window.addEventListener("keydown", onKey);
1245
1269
  return () => window.removeEventListener("keydown", onKey);
1246
1270
  }, [store]);
1247
- const overlayTransform = `translate(${-camera.x * camera.z}px, ${-camera.y * camera.z}px) scale(${camera.z})`;
1271
+ const initialCamera = store.getCamera();
1272
+ const overlayTransform = `translate(${-initialCamera.x * initialCamera.z}px, ${-initialCamera.y * initialCamera.z}px) scale(${initialCamera.z})`;
1248
1273
  return /* @__PURE__ */ jsxs(
1249
1274
  "div",
1250
1275
  {