@tsdraw/react 0.4.0 → 0.5.1

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.cjs CHANGED
@@ -197,10 +197,10 @@ function ToolOverlay({
197
197
  ) });
198
198
  }
199
199
  function getDefaultToolbarIcon(toolId, isActive) {
200
- if (toolId === "select") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconPointer, { size: 18, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
201
- if (toolId === "pen") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconPencil, { size: 18, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
202
- if (toolId === "eraser") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconEraser, { size: 18, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
203
- if (toolId === "hand") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconHandStop, { size: 18, stroke: isActive ? 1 : 1.8, fill: isActive ? "currentColor" : "none", style: isActive ? { stroke: "#000000" } : void 0 });
200
+ if (toolId === "select") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconPointer, { size: 16, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
201
+ if (toolId === "pen") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconPencil, { size: 16, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
202
+ if (toolId === "eraser") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconEraser, { size: 16, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
203
+ if (toolId === "hand") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconHandStop, { size: 16, stroke: isActive ? 1 : 1.8, fill: isActive ? "currentColor" : "none", style: isActive ? { stroke: "#000000" } : void 0 });
204
204
  return null;
205
205
  }
206
206
  function Toolbar({ items, currentTool, onToolChange, style }) {
@@ -232,6 +232,103 @@ function getCanvasCursor(currentTool, state) {
232
232
  return state.showToolOverlay ? "none" : "crosshair";
233
233
  }
234
234
 
235
+ // src/persistence/localIndexedDb.ts
236
+ var DATABASE_PREFIX = "tsdraw_v1_";
237
+ var DATABASE_VERSION = 1;
238
+ var STORE = {
239
+ records: "records",
240
+ state: "state"
241
+ };
242
+ function requestToPromise(request) {
243
+ return new Promise((resolve, reject) => {
244
+ request.onsuccess = () => resolve(request.result);
245
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
246
+ });
247
+ }
248
+ function transactionDone(transaction) {
249
+ return new Promise((resolve, reject) => {
250
+ transaction.oncomplete = () => resolve();
251
+ transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
252
+ transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
253
+ });
254
+ }
255
+ function openLocalDatabase(persistenceKey) {
256
+ return new Promise((resolve, reject) => {
257
+ const request = indexedDB.open(`${DATABASE_PREFIX}${persistenceKey}`, DATABASE_VERSION);
258
+ request.onupgradeneeded = () => {
259
+ const database = request.result;
260
+ if (!database.objectStoreNames.contains(STORE.records)) {
261
+ database.createObjectStore(STORE.records);
262
+ }
263
+ if (!database.objectStoreNames.contains(STORE.state)) {
264
+ database.createObjectStore(STORE.state);
265
+ }
266
+ };
267
+ request.onsuccess = () => resolve(request.result);
268
+ request.onerror = () => reject(request.error ?? new Error("Failed to open IndexedDB"));
269
+ });
270
+ }
271
+ var TsdrawLocalIndexedDb = class {
272
+ databasePromise;
273
+ constructor(persistenceKey) {
274
+ this.databasePromise = openLocalDatabase(persistenceKey);
275
+ }
276
+ async close() {
277
+ const database = await this.databasePromise;
278
+ database.close();
279
+ }
280
+ async load(sessionId) {
281
+ const database = await this.databasePromise;
282
+ const transaction = database.transaction([STORE.records, STORE.state], "readonly");
283
+ const recordStore = transaction.objectStore(STORE.records);
284
+ const stateStore = transaction.objectStore(STORE.state);
285
+ const records = await requestToPromise(recordStore.getAll());
286
+ let state = (await requestToPromise(stateStore.get(sessionId)))?.snapshot ?? null;
287
+ if (!state) {
288
+ const allStates = await requestToPromise(stateStore.getAll());
289
+ if (allStates.length > 0) {
290
+ allStates.sort((left, right) => left.updatedAt - right.updatedAt);
291
+ state = allStates[allStates.length - 1]?.snapshot ?? null;
292
+ }
293
+ }
294
+ await transactionDone(transaction);
295
+ return { records, state };
296
+ }
297
+ async storeSnapshot(args) {
298
+ const database = await this.databasePromise;
299
+ const transaction = database.transaction([STORE.records, STORE.state], "readwrite");
300
+ const recordStore = transaction.objectStore(STORE.records);
301
+ const stateStore = transaction.objectStore(STORE.state);
302
+ recordStore.clear();
303
+ for (const record of args.records) {
304
+ recordStore.put(record, record.id);
305
+ }
306
+ const stateRow = {
307
+ id: args.sessionId,
308
+ snapshot: args.state,
309
+ updatedAt: Date.now()
310
+ };
311
+ stateStore.put(stateRow, args.sessionId);
312
+ await transactionDone(transaction);
313
+ }
314
+ };
315
+
316
+ // src/persistence/sessionId.ts
317
+ var SESSION_STORAGE_KEY = "TSDRAW_TAB_SESSION_ID_v1";
318
+ function createSessionId() {
319
+ const timestamp = Date.now().toString(36);
320
+ const randomPart = Math.random().toString(36).slice(2, 10);
321
+ return `tsdraw-session-${timestamp}-${randomPart}`;
322
+ }
323
+ function getOrCreateSessionId() {
324
+ if (typeof window === "undefined") return createSessionId();
325
+ const existing = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
326
+ if (existing) return existing;
327
+ const newId = createSessionId();
328
+ window.sessionStorage.setItem(SESSION_STORAGE_KEY, newId);
329
+ return newId;
330
+ }
331
+
235
332
  // src/canvas/useTsdrawCanvasController.ts
236
333
  function toScreenRect(editor, bounds) {
237
334
  const { x, y, zoom } = editor.viewport;
@@ -255,6 +352,9 @@ function useTsdrawCanvasController(options = {}) {
255
352
  const lastPointerClientRef = react.useRef(null);
256
353
  const currentToolRef = react.useRef(options.initialTool ?? "pen");
257
354
  const selectedShapeIdsRef = react.useRef([]);
355
+ const schedulePersistRef = react.useRef(null);
356
+ const isPointerActiveRef = react.useRef(false);
357
+ const pendingRemoteDocumentRef = react.useRef(null);
258
358
  const selectionRotationRef = react.useRef(0);
259
359
  const resizeRef = react.useRef({
260
360
  handle: null,
@@ -300,6 +400,9 @@ function useTsdrawCanvasController(options = {}) {
300
400
  react.useEffect(() => {
301
401
  selectionRotationRef.current = selectionRotationDeg;
302
402
  }, [selectionRotationDeg]);
403
+ react.useEffect(() => {
404
+ schedulePersistRef.current?.();
405
+ }, [selectedShapeIds, currentTool, drawColor, drawDash, drawSize]);
303
406
  const render = react.useCallback(() => {
304
407
  const canvas = canvasRef.current;
305
408
  const editor = editorRef.current;
@@ -411,6 +514,16 @@ function useTsdrawCanvasController(options = {}) {
411
514
  if (!editor.tools.hasTool(initialTool)) {
412
515
  editor.setCurrentTool("pen");
413
516
  }
517
+ let disposed = false;
518
+ let ignorePersistenceChanges = false;
519
+ let disposeMount;
520
+ let persistenceDb = null;
521
+ let persistenceChannel = null;
522
+ let isPersisting = false;
523
+ let needsAnotherPersist = false;
524
+ let persistenceActive = false;
525
+ const persistenceKey = options.persistenceKey;
526
+ const sessionId = getOrCreateSessionId();
414
527
  const activeTool = editor.getCurrentToolId();
415
528
  editorRef.current = editor;
416
529
  setCurrentToolState(activeTool);
@@ -427,12 +540,81 @@ function useTsdrawCanvasController(options = {}) {
427
540
  canvas.height = Math.round(rect.height * dpr);
428
541
  canvas.style.width = `${rect.width}px`;
429
542
  canvas.style.height = `${rect.height}px`;
430
- editor.viewport.x = 0;
431
- editor.viewport.y = 0;
432
- editor.viewport.zoom = 1;
543
+ if (!persistenceActive) {
544
+ editor.setViewport({ x: 0, y: 0, zoom: 1 });
545
+ }
433
546
  render();
434
547
  refreshSelectionBounds(editor);
435
548
  };
549
+ const persistSnapshot = async () => {
550
+ if (!persistenceDb || !persistenceKey || ignorePersistenceChanges || disposed) return;
551
+ const snapshot = editor.getPersistenceSnapshot({
552
+ selectedShapeIds: selectedShapeIdsRef.current
553
+ });
554
+ await persistenceDb.storeSnapshot({
555
+ records: snapshot.document.records,
556
+ state: snapshot.state,
557
+ sessionId
558
+ });
559
+ persistenceChannel?.postMessage({
560
+ type: "tsdraw:persisted",
561
+ senderSessionId: sessionId
562
+ });
563
+ };
564
+ const schedulePersist = () => {
565
+ if (!persistenceDb || !persistenceKey || disposed) return;
566
+ const runPersist = async () => {
567
+ if (isPersisting) {
568
+ needsAnotherPersist = true;
569
+ return;
570
+ }
571
+ isPersisting = true;
572
+ try {
573
+ do {
574
+ needsAnotherPersist = false;
575
+ await persistSnapshot();
576
+ } while (needsAnotherPersist && !disposed);
577
+ } catch (error) {
578
+ console.error("tsdraw persistence failed", error);
579
+ } finally {
580
+ isPersisting = false;
581
+ }
582
+ };
583
+ void runPersist();
584
+ };
585
+ schedulePersistRef.current = schedulePersist;
586
+ const reconcileSelectionAfterDocumentLoad = () => {
587
+ const nextSelectedShapeIds = selectedShapeIdsRef.current.filter((shapeId) => editor.getShape(shapeId) != null);
588
+ if (nextSelectedShapeIds.length !== selectedShapeIdsRef.current.length) {
589
+ selectedShapeIdsRef.current = nextSelectedShapeIds;
590
+ setSelectedShapeIds(nextSelectedShapeIds);
591
+ }
592
+ refreshSelectionBounds(editor, nextSelectedShapeIds);
593
+ };
594
+ const applyRemoteDocumentSnapshot = (document) => {
595
+ ignorePersistenceChanges = true;
596
+ editor.loadDocumentSnapshot(document);
597
+ reconcileSelectionAfterDocumentLoad();
598
+ render();
599
+ ignorePersistenceChanges = false;
600
+ };
601
+ const applyLoadedSnapshot = (snapshot) => {
602
+ ignorePersistenceChanges = true;
603
+ const nextSelectionIds = editor.loadPersistenceSnapshot(snapshot);
604
+ setSelectedShapeIds(nextSelectionIds);
605
+ selectedShapeIdsRef.current = nextSelectionIds;
606
+ const nextTool = editor.getCurrentToolId();
607
+ currentToolRef.current = nextTool;
608
+ setCurrentToolState(nextTool);
609
+ const nextDrawStyle = editor.getCurrentDrawStyle();
610
+ setDrawColor(nextDrawStyle.color);
611
+ setDrawDash(nextDrawStyle.dash);
612
+ setDrawSize(nextDrawStyle.size);
613
+ setSelectionRotationDeg(0);
614
+ render();
615
+ refreshSelectionBounds(editor, nextSelectionIds);
616
+ ignorePersistenceChanges = false;
617
+ };
436
618
  const getPagePoint = (e) => {
437
619
  const rect = canvas.getBoundingClientRect();
438
620
  return editor.screenToPage(e.clientX - rect.left, e.clientY - rect.top);
@@ -443,6 +625,7 @@ function useTsdrawCanvasController(options = {}) {
443
625
  };
444
626
  const handlePointerDown = (e) => {
445
627
  if (!canvas.contains(e.target)) return;
628
+ isPointerActiveRef.current = true;
446
629
  canvas.setPointerCapture(e.pointerId);
447
630
  lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
448
631
  updatePointerPreview(e.clientX, e.clientY);
@@ -547,6 +730,7 @@ function useTsdrawCanvasController(options = {}) {
547
730
  refreshSelectionBounds(editor);
548
731
  };
549
732
  const handlePointerUp = (e) => {
733
+ isPointerActiveRef.current = false;
550
734
  lastPointerClientRef.current = null;
551
735
  updatePointerPreview(e.clientX, e.clientY);
552
736
  const { x, y } = getPagePoint(e);
@@ -607,12 +791,22 @@ function useTsdrawCanvasController(options = {}) {
607
791
  selectDragRef.current.mode = "none";
608
792
  render();
609
793
  refreshSelectionBounds(editor, ids);
794
+ if (pendingRemoteDocumentRef.current) {
795
+ const pendingRemoteDocument = pendingRemoteDocumentRef.current;
796
+ pendingRemoteDocumentRef.current = null;
797
+ applyRemoteDocumentSnapshot(pendingRemoteDocument);
798
+ }
610
799
  return;
611
800
  }
612
801
  }
613
802
  editor.tools.pointerUp();
614
803
  render();
615
804
  refreshSelectionBounds(editor);
805
+ if (pendingRemoteDocumentRef.current) {
806
+ const pendingRemoteDocument = pendingRemoteDocumentRef.current;
807
+ pendingRemoteDocumentRef.current = null;
808
+ applyRemoteDocumentSnapshot(pendingRemoteDocument);
809
+ }
616
810
  };
617
811
  const handleKeyDown = (e) => {
618
812
  editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
@@ -624,6 +818,41 @@ function useTsdrawCanvasController(options = {}) {
624
818
  editor.tools.keyUp({ key: e.key });
625
819
  render();
626
820
  };
821
+ const initializePersistence = async () => {
822
+ if (!persistenceKey) return;
823
+ persistenceDb = new TsdrawLocalIndexedDb(persistenceKey);
824
+ const loaded = await persistenceDb.load(sessionId);
825
+ const snapshot = {};
826
+ if (loaded.records.length > 0) {
827
+ snapshot.document = { records: loaded.records };
828
+ }
829
+ if (loaded.state) {
830
+ snapshot.state = loaded.state;
831
+ }
832
+ if (snapshot.document || snapshot.state) {
833
+ applyLoadedSnapshot(snapshot);
834
+ }
835
+ persistenceActive = true;
836
+ persistenceChannel = new BroadcastChannel(`tsdraw:persistence:${persistenceKey}`);
837
+ persistenceChannel.onmessage = async (event) => {
838
+ const data = event.data;
839
+ if (data?.type !== "tsdraw:persisted" || data.senderSessionId === sessionId) return;
840
+ if (!persistenceDb || disposed) return;
841
+ const nextLoaded = await persistenceDb.load(sessionId);
842
+ if (nextLoaded.records.length > 0) {
843
+ const nextDocument = { records: nextLoaded.records };
844
+ if (isPointerActiveRef.current) {
845
+ pendingRemoteDocumentRef.current = nextDocument;
846
+ return;
847
+ }
848
+ applyRemoteDocumentSnapshot(nextDocument);
849
+ }
850
+ };
851
+ };
852
+ const cleanupEditorListener = editor.listen(() => {
853
+ if (ignorePersistenceChanges) return;
854
+ schedulePersist();
855
+ });
627
856
  resize();
628
857
  const ro = new ResizeObserver(resize);
629
858
  ro.observe(container);
@@ -632,7 +861,10 @@ function useTsdrawCanvasController(options = {}) {
632
861
  window.addEventListener("pointerup", handlePointerUp);
633
862
  window.addEventListener("keydown", handleKeyDown);
634
863
  window.addEventListener("keyup", handleKeyUp);
635
- const disposeMount = options.onMount?.({
864
+ void initializePersistence().catch((error) => {
865
+ console.error("failed to initialize tsdraw persistence", error);
866
+ });
867
+ disposeMount = options.onMount?.({
636
868
  editor,
637
869
  container,
638
870
  canvas,
@@ -652,6 +884,9 @@ function useTsdrawCanvasController(options = {}) {
652
884
  }
653
885
  });
654
886
  return () => {
887
+ disposed = true;
888
+ schedulePersistRef.current = null;
889
+ cleanupEditorListener();
655
890
  disposeMount?.();
656
891
  ro.disconnect();
657
892
  canvas.removeEventListener("pointerdown", handlePointerDown);
@@ -659,12 +894,17 @@ function useTsdrawCanvasController(options = {}) {
659
894
  window.removeEventListener("pointerup", handlePointerUp);
660
895
  window.removeEventListener("keydown", handleKeyDown);
661
896
  window.removeEventListener("keyup", handleKeyUp);
897
+ isPointerActiveRef.current = false;
898
+ pendingRemoteDocumentRef.current = null;
899
+ persistenceChannel?.close();
900
+ void persistenceDb?.close();
662
901
  editorRef.current = null;
663
902
  };
664
903
  }, [
665
904
  getPagePointFromClient,
666
905
  options.initialTool,
667
906
  options.onMount,
907
+ options.persistenceKey,
668
908
  options.toolDefinitions,
669
909
  refreshSelectionBounds,
670
910
  render,
@@ -868,10 +1108,11 @@ function Tsdraw(props) {
868
1108
  toolDefinitions,
869
1109
  initialTool,
870
1110
  theme: resolvedTheme,
1111
+ persistenceKey: props.persistenceKey,
871
1112
  stylePanelToolIds,
872
1113
  onMount: props.onMount
873
1114
  });
874
- const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 16);
1115
+ const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
875
1116
  const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
876
1117
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
877
1118
  const defaultToolOverlay = /* @__PURE__ */ jsxRuntime.jsx(