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