canvu-react 0.3.6 → 0.3.8

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/README.md CHANGED
@@ -112,6 +112,198 @@ import { realtimeCollaborationPlugin } from "canvu/plugins/realtime";
112
112
 
113
113
  No `CanvasPluginHost` is required. The plugin runtime lives inside `VectorViewport`.
114
114
 
115
+ ## Persisting built-in file uploads
116
+
117
+ If you want to keep the native `VectorViewport` file picker and drag-and-drop
118
+ UX while also saving the original binary to your own backend or object storage,
119
+ pass `assetStore`.
120
+
121
+ ```tsx
122
+ import { useState } from "react";
123
+ import {
124
+ type VectorViewportAssetStore,
125
+ useVectorCanvasDocument,
126
+ VectorCanvas,
127
+ VectorToolbar,
128
+ VectorViewport,
129
+ } from "canvu/react";
130
+
131
+ export function Board() {
132
+ const [toolId, setToolId] = useState("hand");
133
+ const [toolLocked, setToolLocked] = useState(false);
134
+ const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });
135
+
136
+ const assetStore: VectorViewportAssetStore = {
137
+ async upload({ file, kind }) {
138
+ const form = new FormData();
139
+ form.append("file", file);
140
+ form.append("kind", kind);
141
+
142
+ const response = await fetch("/api/canvu/assets", {
143
+ method: "POST",
144
+ body: form,
145
+ });
146
+
147
+ const asset = await response.json();
148
+ return {
149
+ pluginData: {
150
+ assetId: asset.id,
151
+ assetKey: asset.key,
152
+ mimeType: file.type,
153
+ originalFileName: file.name,
154
+ },
155
+ };
156
+ },
157
+ async resolve({ assetIds }) {
158
+ const response = await fetch("/api/canvu/assets/resolve", {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json" },
161
+ body: JSON.stringify({ assetIds }),
162
+ });
163
+
164
+ return response.json();
165
+ },
166
+ };
167
+
168
+ if (!doc.isHydrated) return null;
169
+
170
+ return (
171
+ <VectorCanvas.Root style={{ height: "100dvh", width: "100%" }}>
172
+ <VectorCanvas.Body>
173
+ <VectorCanvas.Main>
174
+ <VectorCanvas.ViewportSurface>
175
+ <VectorViewport
176
+ ariaLabel="Board"
177
+ toolId={toolId}
178
+ toolLocked={toolLocked}
179
+ onToolChangeRequest={setToolId}
180
+ items={doc.items}
181
+ onItemsChange={doc.onItemsChange}
182
+ interactive
183
+ assetStore={assetStore}
184
+ toolbar={
185
+ <VectorCanvas.Toolbar>
186
+ <VectorToolbar
187
+ value={toolId}
188
+ onChange={setToolId}
189
+ toolLocked={toolLocked}
190
+ onToolLockedChange={setToolLocked}
191
+ aria-label="Canvas tools"
192
+ />
193
+ </VectorCanvas.Toolbar>
194
+ }
195
+ />
196
+ </VectorCanvas.ViewportSurface>
197
+ </VectorCanvas.Main>
198
+ </VectorCanvas.Body>
199
+ </VectorCanvas.Root>
200
+ );
201
+ }
202
+ ```
203
+
204
+ canvu still owns the conversion from `File` to `VectorSceneItem`. Your
205
+ `assetStore.upload(...)` only intercepts the original browser file and returns
206
+ persistable `pluginData`, which canvu merges into every created item from that
207
+ file. `resolve(...)` is optional and is intended for custom persistence
208
+ adapters that need to rehydrate signed URLs after loading a snapshot.
209
+
210
+ ## Reusing the same ingestion pipeline outside the viewport
211
+
212
+ When files originate outside the `VectorViewport` UI, such as a board creation
213
+ wizard, a custom import modal, or a migration routine, use
214
+ `ingestAssetFilesToSceneItems(...)`.
215
+
216
+ ```tsx
217
+ import { useRef, useState } from "react";
218
+ import {
219
+ ingestAssetFilesToSceneItems,
220
+ type VectorViewportAssetStore,
221
+ useVectorCanvasDocument,
222
+ VectorViewport,
223
+ } from "canvu/react";
224
+
225
+ export function BoardImport() {
226
+ const [toolId, setToolId] = useState("hand");
227
+ const inputRef = useRef<HTMLInputElement>(null);
228
+ const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });
229
+
230
+ const assetStore: VectorViewportAssetStore = {
231
+ async upload({ file, kind }) {
232
+ const form = new FormData();
233
+ form.append("file", file);
234
+ form.append("kind", kind);
235
+
236
+ const response = await fetch("/api/canvu/assets", {
237
+ method: "POST",
238
+ body: form,
239
+ });
240
+
241
+ const asset = await response.json();
242
+ return {
243
+ pluginData: {
244
+ assetId: asset.id,
245
+ assetKey: asset.key,
246
+ },
247
+ };
248
+ },
249
+ };
250
+
251
+ async function handleImport(files: FileList | null) {
252
+ if (!files) return;
253
+
254
+ const result = await ingestAssetFilesToSceneItems({
255
+ files: Array.from(files),
256
+ worldCenter: { x: 0, y: 0 },
257
+ assetStore,
258
+ decorateItem(item) {
259
+ return {
260
+ ...item,
261
+ locked: true,
262
+ };
263
+ },
264
+ });
265
+
266
+ doc.onItemsChange([...doc.items, ...result.items]);
267
+ }
268
+
269
+ if (!doc.isHydrated) return null;
270
+
271
+ return (
272
+ <>
273
+ <button type="button" onClick={() => inputRef.current?.click()}>
274
+ Import files
275
+ </button>
276
+
277
+ <input
278
+ ref={inputRef}
279
+ type="file"
280
+ accept="image/*,application/pdf"
281
+ multiple
282
+ hidden
283
+ onChange={(event) => {
284
+ void handleImport(event.target.files);
285
+ event.target.value = "";
286
+ }}
287
+ />
288
+
289
+ <VectorViewport
290
+ ariaLabel="Board"
291
+ items={doc.items}
292
+ onItemsChange={doc.onItemsChange}
293
+ toolId={toolId}
294
+ onToolChangeRequest={setToolId}
295
+ interactive
296
+ assetStore={assetStore}
297
+ />
298
+ </>
299
+ );
300
+ }
301
+ ```
302
+
303
+ This helper is the same ingestion layer used internally by the native file
304
+ tool, so external imports do not need to reimplement PDF rasterization, local
305
+ blob persistence, or `pluginData` attachment.
306
+
115
307
  ## Custom tools
116
308
 
117
309
  Use `createToolPlugin(...)` for isolated tools.
@@ -1,8 +1,9 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { C as CanvasPlugin } from './types-D1ftVsOQ.cjs';
2
+ import { C as CanvasPlugin } from './types-CW146bKP.cjs';
3
3
  import 'react';
4
4
  import './types-CB0TZZuk.cjs';
5
5
  import './camera-BwQjm5oh.cjs';
6
+ import './shape-builders-DxPoOecg.cjs';
6
7
 
7
8
  type ChatbotPluginPanelProps = {
8
9
  /**
package/dist/chatbot.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { C as CanvasPlugin } from './types--ALu1mF-.js';
2
+ import { C as CanvasPlugin } from './types-CpqlbbCP.js';
3
3
  import 'react';
4
4
  import './types-CB0TZZuk.js';
5
5
  import './camera-KwCYYPhm.js';
6
+ import './shape-builders-DTYvub8W.js';
6
7
 
7
8
  type ChatbotPluginPanelProps = {
8
9
  /**
package/dist/index.cjs CHANGED
@@ -371,7 +371,7 @@ function attachViewportInput(options) {
371
371
  if (e.ctrlKey || e.metaKey) {
372
372
  e.preventDefault();
373
373
  const dy = wheelDeltaYPixels(e);
374
- const normDy = dy < 20 ? dy * 12 : dy;
374
+ const normDy = Math.abs(dy) < 20 ? dy * 12 : dy;
375
375
  const factor = Math.exp(-normDy * wheelZoomSensitivity);
376
376
  const rect = element.getBoundingClientRect();
377
377
  camera.setZoom(camera.zoom * factor, {
@@ -616,89 +616,6 @@ function createCustomShapeItem(id, bounds, content) {
616
616
  };
617
617
  }
618
618
 
619
- // src/scene/freehand-path.ts
620
- function dedupeFreehandPoints(points, minDist) {
621
- if (points.length <= 2) {
622
- return points.map((p) => ({ ...p }));
623
- }
624
- const minSq = minDist * minDist;
625
- const first = points[0];
626
- if (!first) return [];
627
- const out = [{ ...first }];
628
- for (let i = 1; i < points.length - 1; i++) {
629
- const p = points[i];
630
- const last = out[out.length - 1];
631
- if (!p || !last) continue;
632
- const dx = p.x - last.x;
633
- const dy = p.y - last.y;
634
- if (dx * dx + dy * dy >= minSq) {
635
- out.push({ ...p });
636
- }
637
- }
638
- const end = points[points.length - 1];
639
- const lastKept = out[out.length - 1];
640
- if (!end || !lastKept) return out;
641
- if ((end.x - lastKept.x) ** 2 + (end.y - lastKept.y) ** 2 > 1e-12) {
642
- out.push({ ...end });
643
- }
644
- return out;
645
- }
646
- function smoothFreehandPointsToPathD(points) {
647
- const n = points.length;
648
- if (n === 0) return "";
649
- if (n === 1) {
650
- const p = points[0];
651
- if (!p) return "";
652
- return `M ${p.x} ${p.y}`;
653
- }
654
- if (n === 2) {
655
- const a = points[0];
656
- const b = points[1];
657
- if (!a || !b) return "";
658
- return `M ${a.x} ${a.y} L ${b.x} ${b.y}`;
659
- }
660
- const p0 = points[0];
661
- if (!p0) return "";
662
- let d = `M ${p0.x} ${p0.y}`;
663
- let i = 1;
664
- for (; i < n - 2; i++) {
665
- const pi = points[i];
666
- const pi1 = points[i + 1];
667
- if (!pi || !pi1) continue;
668
- const xc = (pi.x + pi1.x) / 2;
669
- const yc = (pi.y + pi1.y) / 2;
670
- d += ` Q ${pi.x} ${pi.y} ${xc} ${yc}`;
671
- }
672
- const pLast = points[i];
673
- const pEnd = points[i + 1];
674
- if (!pLast || !pEnd) return d;
675
- d += ` Q ${pLast.x} ${pLast.y} ${pEnd.x} ${pEnd.y}`;
676
- return d;
677
- }
678
- function outlineStrokeToClosedPathD(outline) {
679
- const len = outline.length;
680
- if (len === 0) return "";
681
- const first = outline[0];
682
- if (!first) return "";
683
- if (len < 3) {
684
- let d2 = `M ${first[0]} ${first[1]}`;
685
- for (let i = 1; i < len; i++) {
686
- const pt = outline[i];
687
- if (!pt) continue;
688
- d2 += ` L ${pt[0]} ${pt[1]}`;
689
- }
690
- return `${d2} Z`;
691
- }
692
- let d = `M ${first[0]} ${first[1]} Q`;
693
- for (let i = 0; i < len; i++) {
694
- const p0 = outline[i];
695
- const p1 = outline[(i + 1) % len];
696
- if (!p0 || !p1) continue;
697
- d += ` ${p0[0]} ${p0[1]} ${(p0[0] + p1[0]) / 2} ${(p0[1] + p1[1]) / 2}`;
698
- }
699
- return `${d} Z`;
700
- }
701
-
702
619
  // src/scene/text-svg.ts
703
620
  function escapeSvgTextContent(s) {
704
621
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -819,7 +736,7 @@ var DEFAULT_STROKE_STYLE = {
819
736
  strokeWidth: 2
820
737
  };
821
738
  var TOOL_FREEHAND_DEFAULTS = {
822
- draw: { strokeWidth: 3 },
739
+ draw: { strokeWidth: 10 },
823
740
  pencil: { strokeWidth: 3 },
824
741
  brush: { strokeWidth: 10 },
825
742
  marker: { stroke: "#fde047", strokeWidth: 16, strokeOpacity: 0.5 }
@@ -836,18 +753,18 @@ function perfectFreehandOptions(toolKind, style, strokeComplete, pressureAware =
836
753
  ...base,
837
754
  size: Math.max(2, sw * 1.05),
838
755
  thinning: 0.42,
839
- smoothing: 0.56,
840
- streamline: 0.18,
841
- simulatePressure: false
756
+ smoothing: 0.78,
757
+ streamline: 0.62,
758
+ simulatePressure: true
842
759
  };
843
760
  }
844
761
  return {
845
762
  ...base,
846
763
  size: Math.max(2, sw * 1.18),
847
764
  thinning: 0.12,
848
- smoothing: 0.72,
849
- streamline: 0.42,
850
- simulatePressure: false
765
+ smoothing: 0.85,
766
+ streamline: 0.78,
767
+ simulatePressure: true
851
768
  };
852
769
  }
853
770
  if (toolKind === "brush") {
@@ -865,7 +782,7 @@ function perfectFreehandOptions(toolKind, style, strokeComplete, pressureAware =
865
782
  thinning: 0.08,
866
783
  smoothing: 0.88,
867
784
  streamline: 0.84,
868
- simulatePressure: false
785
+ simulatePressure: true
869
786
  };
870
787
  }
871
788
  function resolveStrokeStyle(item) {
@@ -935,70 +852,41 @@ function computeFreehandSvgPayload(pathPointsLocal, style, toolKind, strokeCompl
935
852
  if (pathPointsLocal.length === 1) {
936
853
  const p = pathPointsLocal[0];
937
854
  if (!p) return null;
938
- const r = Math.max(0.5, style.strokeWidth / 2);
939
- return {
940
- kind: "circle",
941
- cx: p.x,
942
- cy: p.y,
943
- r,
944
- fill: style.stroke,
945
- fillOpacity: style.strokeOpacity
946
- };
947
- }
948
- const minDist = Math.min(0.25, Math.max(0.02, style.strokeWidth * 0.02));
949
- const pts = dedupeFreehandPoints(pathPointsLocal, minDist);
950
- if (pts.length === 0) return null;
951
- if (pts.length === 1) {
952
- const p = pts[0];
953
- if (!p) return null;
954
- const r = Math.max(0.5, style.strokeWidth / 2);
955
855
  return {
956
856
  kind: "circle",
957
857
  cx: p.x,
958
858
  cy: p.y,
959
- r,
859
+ r: Math.max(0.5, style.strokeWidth / 2),
960
860
  fill: style.stroke,
961
861
  fillOpacity: style.strokeOpacity
962
862
  };
963
863
  }
964
- const hasPressure = toolKind === "draw" && pts.some((p) => p.pressure != null && Number.isFinite(p.pressure));
965
- if (toolKind === "draw" && !hasPressure) {
966
- const d2 = smoothFreehandPointsToPathD(pts);
967
- return {
968
- kind: "strokePath",
969
- d: d2,
970
- stroke: style.stroke,
971
- strokeWidth: style.strokeWidth,
972
- strokeOpacity: style.strokeOpacity
973
- };
974
- }
975
- const input = hasPressure ? pts.map(
864
+ const hasPressure = pathPointsLocal.some(
865
+ (p) => p.pressure != null && Number.isFinite(p.pressure)
866
+ );
867
+ const input = hasPressure ? pathPointsLocal.map(
976
868
  (p) => [p.x, p.y, Math.min(1, Math.max(0, p.pressure ?? 0.5))]
977
- ) : pts.map((p) => [p.x, p.y]);
978
- const opts = perfectFreehandOptions(toolKind, style, strokeComplete, hasPressure);
979
- let outline = [];
980
- try {
981
- const raw = getStroke__default.default(input, opts);
982
- outline = raw.map(([x, y]) => [x, y]);
983
- } catch {
984
- outline = [];
985
- }
986
- if (outline.length >= 3) {
987
- const d2 = outlineStrokeToClosedPathD(outline);
988
- return {
989
- kind: "fillPath",
990
- d: d2,
991
- fill: style.stroke,
992
- fillOpacity: style.strokeOpacity
993
- };
869
+ ) : pathPointsLocal.map((p) => [p.x, p.y]);
870
+ const stroke = getStroke__default.default(
871
+ input,
872
+ perfectFreehandOptions(toolKind, style, strokeComplete, hasPressure)
873
+ );
874
+ if (stroke.length < 3) return null;
875
+ const first = stroke[0];
876
+ if (!first) return null;
877
+ let d = `M ${first[0]} ${first[1]} Q`;
878
+ for (let i = 0; i < stroke.length; i++) {
879
+ const a = stroke[i];
880
+ const b = stroke[(i + 1) % stroke.length];
881
+ if (!a || !b) continue;
882
+ d += ` ${a[0]} ${a[1]} ${(a[0] + b[0]) / 2} ${(a[1] + b[1]) / 2}`;
994
883
  }
995
- const d = smoothFreehandPointsToPathD(pts);
884
+ d += " Z";
996
885
  return {
997
- kind: "strokePath",
886
+ kind: "fillPath",
998
887
  d,
999
- stroke: style.stroke,
1000
- strokeWidth: style.strokeWidth,
1001
- strokeOpacity: style.strokeOpacity
888
+ fill: style.stroke,
889
+ fillOpacity: style.strokeOpacity
1002
890
  };
1003
891
  }
1004
892
  function freehandPayloadToSvgString(payload) {