@tsdraw/react 0.5.1 → 0.6.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
@@ -1,7 +1,7 @@
1
1
  import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
3
  import { DEFAULT_COLORS, getSelectionBoundsPage, buildTransformSnapshots, Editor, STROKE_WIDTHS, ERASER_MARGIN, resolveThemeColor, getTopShapeAtPoint, buildStartPositions, applyRotation, applyResize, applyMove, normalizeSelectionBounds, getShapesInBounds, isSelectTool } from '@tsdraw/core';
4
- import { IconPointer, IconPencil, IconEraser, IconHandStop } from '@tabler/icons-react';
4
+ import { IconPointer, IconPencil, IconEraser, IconHandStop, IconArrowBackUp, IconArrowForwardUp } from '@tabler/icons-react';
5
5
 
6
6
  // src/components/TsdrawCanvas.tsx
7
7
  function SelectionOverlay({
@@ -201,23 +201,46 @@ function getDefaultToolbarIcon(toolId, isActive) {
201
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
- function Toolbar({ items, currentTool, onToolChange, style }) {
205
- return /* @__PURE__ */ jsx("div", { className: "tsdraw-toolbar", style, children: items.map((item) => {
206
- const isActive = currentTool === item.id;
207
- return /* @__PURE__ */ jsx(
208
- "button",
209
- {
210
- type: "button",
211
- className: "tsdraw-toolbar-btn",
212
- "data-active": isActive ? "true" : void 0,
213
- onClick: () => onToolChange(item.id),
214
- title: item.label,
215
- "aria-label": item.label,
216
- children: typeof item.icon === "function" ? item.icon(isActive) : item.icon
217
- },
218
- item.id
219
- );
220
- }) });
204
+ function getActionIcon(actionId) {
205
+ if (actionId === "undo") return /* @__PURE__ */ jsx(IconArrowBackUp, { size: 16, stroke: 1.8 });
206
+ return /* @__PURE__ */ jsx(IconArrowForwardUp, { size: 16, stroke: 1.8 });
207
+ }
208
+ function Toolbar({ parts, currentTool, onToolChange, disabled, style }) {
209
+ return /* @__PURE__ */ jsx("div", { className: "tsdraw-toolbar", style, children: parts.map((part, partIndex) => /* @__PURE__ */ jsxs("div", { className: "tsdraw-toolbar-part", children: [
210
+ part.items.map((item) => {
211
+ if (item.type === "action") {
212
+ return /* @__PURE__ */ jsx(
213
+ "button",
214
+ {
215
+ type: "button",
216
+ className: "tsdraw-toolbar-btn",
217
+ onClick: item.onSelect,
218
+ title: item.label,
219
+ "aria-label": item.label,
220
+ disabled: disabled || item.disabled,
221
+ children: getActionIcon(item.id)
222
+ },
223
+ item.id
224
+ );
225
+ }
226
+ const isActive = currentTool === item.id;
227
+ return /* @__PURE__ */ jsx(
228
+ "button",
229
+ {
230
+ type: "button",
231
+ className: "tsdraw-toolbar-btn",
232
+ "data-active": isActive ? "true" : void 0,
233
+ onClick: () => onToolChange(item.id),
234
+ title: item.label,
235
+ "aria-label": item.label,
236
+ disabled,
237
+ children: typeof item.icon === "function" ? item.icon(isActive) : item.icon
238
+ },
239
+ item.id
240
+ );
241
+ }),
242
+ partIndex < parts.length - 1 ? /* @__PURE__ */ jsx("div", { className: "tsdraw-toolbar-separator" }) : null
243
+ ] }, part.id)) });
221
244
  }
222
245
  function getCanvasCursor(currentTool, state) {
223
246
  if (currentTool === "hand") return "grab";
@@ -232,10 +255,11 @@ function getCanvasCursor(currentTool, state) {
232
255
 
233
256
  // src/persistence/localIndexedDb.ts
234
257
  var DATABASE_PREFIX = "tsdraw_v1_";
235
- var DATABASE_VERSION = 1;
258
+ var DATABASE_VERSION = 2;
236
259
  var STORE = {
237
260
  records: "records",
238
- state: "state"
261
+ state: "state",
262
+ history: "history"
239
263
  };
240
264
  function requestToPromise(request) {
241
265
  return new Promise((resolve, reject) => {
@@ -261,6 +285,9 @@ function openLocalDatabase(persistenceKey) {
261
285
  if (!database.objectStoreNames.contains(STORE.state)) {
262
286
  database.createObjectStore(STORE.state);
263
287
  }
288
+ if (!database.objectStoreNames.contains(STORE.history)) {
289
+ database.createObjectStore(STORE.history);
290
+ }
264
291
  };
265
292
  request.onsuccess = () => resolve(request.result);
266
293
  request.onerror = () => reject(request.error ?? new Error("Failed to open IndexedDB"));
@@ -277,11 +304,13 @@ var TsdrawLocalIndexedDb = class {
277
304
  }
278
305
  async load(sessionId) {
279
306
  const database = await this.databasePromise;
280
- const transaction = database.transaction([STORE.records, STORE.state], "readonly");
307
+ const transaction = database.transaction([STORE.records, STORE.state, STORE.history], "readonly");
281
308
  const recordStore = transaction.objectStore(STORE.records);
282
309
  const stateStore = transaction.objectStore(STORE.state);
310
+ const historyStore = transaction.objectStore(STORE.history);
283
311
  const records = await requestToPromise(recordStore.getAll());
284
312
  let state = (await requestToPromise(stateStore.get(sessionId)))?.snapshot ?? null;
313
+ let history = (await requestToPromise(historyStore.get(sessionId)))?.snapshot ?? null;
285
314
  if (!state) {
286
315
  const allStates = await requestToPromise(stateStore.getAll());
287
316
  if (allStates.length > 0) {
@@ -289,14 +318,22 @@ var TsdrawLocalIndexedDb = class {
289
318
  state = allStates[allStates.length - 1]?.snapshot ?? null;
290
319
  }
291
320
  }
321
+ if (!history) {
322
+ const allHistoryRows = await requestToPromise(historyStore.getAll());
323
+ if (allHistoryRows.length > 0) {
324
+ allHistoryRows.sort((left, right) => left.updatedAt - right.updatedAt);
325
+ history = allHistoryRows[allHistoryRows.length - 1]?.snapshot ?? null;
326
+ }
327
+ }
292
328
  await transactionDone(transaction);
293
- return { records, state };
329
+ return { records, state, history };
294
330
  }
295
331
  async storeSnapshot(args) {
296
332
  const database = await this.databasePromise;
297
- const transaction = database.transaction([STORE.records, STORE.state], "readwrite");
333
+ const transaction = database.transaction([STORE.records, STORE.state, STORE.history], "readwrite");
298
334
  const recordStore = transaction.objectStore(STORE.records);
299
335
  const stateStore = transaction.objectStore(STORE.state);
336
+ const historyStore = transaction.objectStore(STORE.history);
300
337
  recordStore.clear();
301
338
  for (const record of args.records) {
302
339
  recordStore.put(record, record.id);
@@ -307,6 +344,12 @@ var TsdrawLocalIndexedDb = class {
307
344
  updatedAt: Date.now()
308
345
  };
309
346
  stateStore.put(stateRow, args.sessionId);
347
+ const historyRow = {
348
+ id: args.sessionId,
349
+ snapshot: args.history,
350
+ updatedAt: Date.now()
351
+ };
352
+ historyStore.put(historyRow, args.sessionId);
310
353
  await transactionDone(transaction);
311
354
  }
312
355
  };
@@ -384,6 +427,9 @@ function useTsdrawCanvasController(options = {}) {
384
427
  const [isMovingSelection, setIsMovingSelection] = useState(false);
385
428
  const [isResizingSelection, setIsResizingSelection] = useState(false);
386
429
  const [isRotatingSelection, setIsRotatingSelection] = useState(false);
430
+ const [canUndo, setCanUndo] = useState(false);
431
+ const [canRedo, setCanRedo] = useState(false);
432
+ const [isPersistenceReady, setIsPersistenceReady] = useState(!options.persistenceKey);
387
433
  const [pointerScreenPoint, setPointerScreenPoint] = useState({ x: 0, y: 0 });
388
434
  const [isPointerInsideCanvas, setIsPointerInsideCanvas] = useState(false);
389
435
  useEffect(() => {
@@ -461,6 +507,7 @@ function useTsdrawCanvasController(options = {}) {
461
507
  startBounds: bounds,
462
508
  startShapes: buildTransformSnapshots(editor, selectedShapeIdsRef.current)
463
509
  };
510
+ editor.beginHistoryEntry();
464
511
  selectDragRef.current.mode = "resize";
465
512
  const p = getPagePointFromClient(editor, e.clientX, e.clientY);
466
513
  editor.input.pointerDown(p.x, p.y, 0.5, false);
@@ -490,6 +537,7 @@ function useTsdrawCanvasController(options = {}) {
490
537
  startSelectionRotationDeg: selectionRotationRef.current,
491
538
  startShapes: buildTransformSnapshots(editor, selectedShapeIdsRef.current)
492
539
  };
540
+ editor.beginHistoryEntry();
493
541
  selectDragRef.current.mode = "rotate";
494
542
  editor.input.pointerDown(p.x, p.y, 0.5, false);
495
543
  selectDragRef.current.startPage = p;
@@ -522,10 +570,15 @@ function useTsdrawCanvasController(options = {}) {
522
570
  let persistenceActive = false;
523
571
  const persistenceKey = options.persistenceKey;
524
572
  const sessionId = getOrCreateSessionId();
573
+ const syncHistoryState = () => {
574
+ setCanUndo(editor.canUndo());
575
+ setCanRedo(editor.canRedo());
576
+ };
525
577
  const activeTool = editor.getCurrentToolId();
526
578
  editorRef.current = editor;
527
579
  setCurrentToolState(activeTool);
528
580
  currentToolRef.current = activeTool;
581
+ syncHistoryState();
529
582
  const initialStyle = editor.getCurrentDrawStyle();
530
583
  setDrawColor(initialStyle.color);
531
584
  setDrawDash(initialStyle.dash);
@@ -552,8 +605,10 @@ function useTsdrawCanvasController(options = {}) {
552
605
  await persistenceDb.storeSnapshot({
553
606
  records: snapshot.document.records,
554
607
  state: snapshot.state,
608
+ history: editor.getHistorySnapshot(),
555
609
  sessionId
556
610
  });
611
+ if (disposed) return;
557
612
  persistenceChannel?.postMessage({
558
613
  type: "tsdraw:persisted",
559
614
  senderSessionId: sessionId
@@ -592,6 +647,7 @@ function useTsdrawCanvasController(options = {}) {
592
647
  const applyRemoteDocumentSnapshot = (document) => {
593
648
  ignorePersistenceChanges = true;
594
649
  editor.loadDocumentSnapshot(document);
650
+ editor.clearRedoHistory();
595
651
  reconcileSelectionAfterDocumentLoad();
596
652
  render();
597
653
  ignorePersistenceChanges = false;
@@ -624,6 +680,7 @@ function useTsdrawCanvasController(options = {}) {
624
680
  const handlePointerDown = (e) => {
625
681
  if (!canvas.contains(e.target)) return;
626
682
  isPointerActiveRef.current = true;
683
+ editor.beginHistoryEntry();
627
684
  canvas.setPointerCapture(e.pointerId);
628
685
  lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
629
686
  updatePointerPreview(e.clientX, e.clientY);
@@ -748,6 +805,7 @@ function useTsdrawCanvasController(options = {}) {
748
805
  };
749
806
  render();
750
807
  refreshSelectionBounds(editor);
808
+ editor.endHistoryEntry();
751
809
  return;
752
810
  }
753
811
  if (drag.mode === "resize") {
@@ -756,6 +814,7 @@ function useTsdrawCanvasController(options = {}) {
756
814
  resizeRef.current = { handle: null, startBounds: null, startShapes: /* @__PURE__ */ new Map() };
757
815
  render();
758
816
  refreshSelectionBounds(editor);
817
+ editor.endHistoryEntry();
759
818
  return;
760
819
  }
761
820
  if (drag.mode === "move") {
@@ -763,6 +822,7 @@ function useTsdrawCanvasController(options = {}) {
763
822
  selectDragRef.current.mode = "none";
764
823
  render();
765
824
  refreshSelectionBounds(editor);
825
+ editor.endHistoryEntry();
766
826
  return;
767
827
  }
768
828
  if (drag.mode === "marquee") {
@@ -794,6 +854,7 @@ function useTsdrawCanvasController(options = {}) {
794
854
  pendingRemoteDocumentRef.current = null;
795
855
  applyRemoteDocumentSnapshot(pendingRemoteDocument);
796
856
  }
857
+ editor.endHistoryEntry();
797
858
  return;
798
859
  }
799
860
  }
@@ -805,8 +866,25 @@ function useTsdrawCanvasController(options = {}) {
805
866
  pendingRemoteDocumentRef.current = null;
806
867
  applyRemoteDocumentSnapshot(pendingRemoteDocument);
807
868
  }
869
+ editor.endHistoryEntry();
808
870
  };
809
871
  const handleKeyDown = (e) => {
872
+ const isMetaPressed = e.metaKey || e.ctrlKey;
873
+ const loweredKey = e.key.toLowerCase();
874
+ const isUndoOrRedoKey = loweredKey === "z" || loweredKey === "y";
875
+ if (isMetaPressed && isUndoOrRedoKey) {
876
+ const shouldRedo = loweredKey === "y" || loweredKey === "z" && e.shiftKey;
877
+ const changed = shouldRedo ? editor.redo() : editor.undo();
878
+ if (changed) {
879
+ e.preventDefault();
880
+ e.stopPropagation();
881
+ reconcileSelectionAfterDocumentLoad();
882
+ setSelectionRotationDeg(0);
883
+ render();
884
+ syncHistoryState();
885
+ return;
886
+ }
887
+ }
810
888
  editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
811
889
  editor.tools.keyDown({ key: e.key });
812
890
  render();
@@ -817,40 +895,56 @@ function useTsdrawCanvasController(options = {}) {
817
895
  render();
818
896
  };
819
897
  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);
898
+ if (!persistenceKey) {
899
+ setIsPersistenceReady(true);
900
+ return;
832
901
  }
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;
902
+ try {
903
+ persistenceDb = new TsdrawLocalIndexedDb(persistenceKey);
904
+ const loaded = await persistenceDb.load(sessionId);
905
+ const snapshot = {};
906
+ if (loaded.records.length > 0) {
907
+ snapshot.document = { records: loaded.records };
908
+ }
909
+ if (loaded.state) {
910
+ snapshot.state = loaded.state;
911
+ }
912
+ if (snapshot.document || snapshot.state) {
913
+ applyLoadedSnapshot(snapshot);
914
+ }
915
+ editor.loadHistorySnapshot(loaded.history);
916
+ syncHistoryState();
917
+ persistenceActive = true;
918
+ persistenceChannel = new BroadcastChannel(`tsdraw:persistence:${persistenceKey}`);
919
+ persistenceChannel.onmessage = async (event) => {
920
+ const data = event.data;
921
+ if (data?.type !== "tsdraw:persisted" || data.senderSessionId === sessionId) return;
922
+ if (!persistenceDb || disposed) return;
923
+ const nextLoaded = await persistenceDb.load(sessionId);
924
+ if (nextLoaded.records.length > 0) {
925
+ const nextDocument = { records: nextLoaded.records };
926
+ if (isPointerActiveRef.current) {
927
+ pendingRemoteDocumentRef.current = nextDocument;
928
+ return;
929
+ }
930
+ applyRemoteDocumentSnapshot(nextDocument);
845
931
  }
846
- applyRemoteDocumentSnapshot(nextDocument);
932
+ };
933
+ } finally {
934
+ if (!disposed) {
935
+ setIsPersistenceReady(true);
847
936
  }
848
- };
937
+ }
849
938
  };
850
939
  const cleanupEditorListener = editor.listen(() => {
851
940
  if (ignorePersistenceChanges) return;
852
941
  schedulePersist();
853
942
  });
943
+ const cleanupHistoryListener = editor.listenHistory(() => {
944
+ syncHistoryState();
945
+ if (ignorePersistenceChanges) return;
946
+ schedulePersist();
947
+ });
854
948
  resize();
855
949
  const ro = new ResizeObserver(resize);
856
950
  ro.observe(container);
@@ -873,6 +967,26 @@ function useTsdrawCanvasController(options = {}) {
873
967
  currentToolRef.current = tool;
874
968
  },
875
969
  getCurrentTool: () => editor.getCurrentToolId(),
970
+ undo: () => {
971
+ const changed = editor.undo();
972
+ if (!changed) return false;
973
+ reconcileSelectionAfterDocumentLoad();
974
+ setSelectionRotationDeg(0);
975
+ render();
976
+ syncHistoryState();
977
+ return true;
978
+ },
979
+ redo: () => {
980
+ const changed = editor.redo();
981
+ if (!changed) return false;
982
+ reconcileSelectionAfterDocumentLoad();
983
+ setSelectionRotationDeg(0);
984
+ render();
985
+ syncHistoryState();
986
+ return true;
987
+ },
988
+ canUndo: () => editor.canUndo(),
989
+ canRedo: () => editor.canRedo(),
876
990
  applyDrawStyle: (partial) => {
877
991
  editor.setCurrentDrawStyle(partial);
878
992
  if (partial.color) setDrawColor(partial.color);
@@ -885,6 +999,7 @@ function useTsdrawCanvasController(options = {}) {
885
999
  disposed = true;
886
1000
  schedulePersistRef.current = null;
887
1001
  cleanupEditorListener();
1002
+ cleanupHistoryListener();
888
1003
  disposeMount?.();
889
1004
  ro.disconnect();
890
1005
  canvas.removeEventListener("pointerdown", handlePointerDown);
@@ -938,6 +1053,38 @@ function useTsdrawCanvasController(options = {}) {
938
1053
  },
939
1054
  [render]
940
1055
  );
1056
+ const undo = useCallback(() => {
1057
+ const editor = editorRef.current;
1058
+ if (!editor) return false;
1059
+ const changed = editor.undo();
1060
+ if (!changed) return false;
1061
+ const nextSelectedShapeIds = selectedShapeIdsRef.current.filter((shapeId) => editor.getShape(shapeId) != null);
1062
+ if (nextSelectedShapeIds.length !== selectedShapeIdsRef.current.length) {
1063
+ selectedShapeIdsRef.current = nextSelectedShapeIds;
1064
+ setSelectedShapeIds(nextSelectedShapeIds);
1065
+ }
1066
+ setSelectionRotationDeg(0);
1067
+ render();
1068
+ setCanUndo(editor.canUndo());
1069
+ setCanRedo(editor.canRedo());
1070
+ return true;
1071
+ }, [render]);
1072
+ const redo = useCallback(() => {
1073
+ const editor = editorRef.current;
1074
+ if (!editor) return false;
1075
+ const changed = editor.redo();
1076
+ if (!changed) return false;
1077
+ const nextSelectedShapeIds = selectedShapeIdsRef.current.filter((shapeId) => editor.getShape(shapeId) != null);
1078
+ if (nextSelectedShapeIds.length !== selectedShapeIdsRef.current.length) {
1079
+ selectedShapeIdsRef.current = nextSelectedShapeIds;
1080
+ setSelectedShapeIds(nextSelectedShapeIds);
1081
+ }
1082
+ setSelectionRotationDeg(0);
1083
+ render();
1084
+ setCanUndo(editor.canUndo());
1085
+ setCanRedo(editor.canRedo());
1086
+ return true;
1087
+ }, [render]);
941
1088
  const showToolOverlay = isPointerInsideCanvas && (currentTool === "pen" || currentTool === "eraser");
942
1089
  const canvasCursor = getCanvasCursor(currentTool, {
943
1090
  isMovingSelection,
@@ -976,14 +1123,19 @@ function useTsdrawCanvasController(options = {}) {
976
1123
  canvasCursor,
977
1124
  cursorContext,
978
1125
  toolOverlay,
1126
+ isPersistenceReady,
979
1127
  showStylePanel: stylePanelToolIdsRef.current.includes(currentTool),
1128
+ canUndo,
1129
+ canRedo,
1130
+ undo,
1131
+ redo,
980
1132
  setTool,
981
1133
  applyDrawStyle,
982
1134
  handleResizePointerDown,
983
1135
  handleRotatePointerDown
984
1136
  };
985
1137
  }
986
- var DEFAULT_TOOL_IDS = ["select", "pen", "eraser", "hand"];
1138
+ var DEFAULT_TOOLBAR_PARTS = [["undo", "redo"], ["select", "hand", "pen", "eraser"]];
987
1139
  var DEFAULT_TOOL_LABELS = {
988
1140
  select: "Select",
989
1141
  pen: "Pen",
@@ -1000,11 +1152,8 @@ function parseAnchor(anchor) {
1000
1152
  }
1001
1153
  return { vertical, horizontal };
1002
1154
  }
1003
- function isCustomTool(toolItem) {
1004
- return typeof toolItem !== "string";
1005
- }
1006
- function getToolId(toolItem) {
1007
- return typeof toolItem === "string" ? toolItem : toolItem.id;
1155
+ function isToolbarAction(item) {
1156
+ return item === "undo" || item === "redo";
1008
1157
  }
1009
1158
  function resolvePlacementStyle(placement, fallbackAnchor, fallbackOffsetX, fallbackOffsetY) {
1010
1159
  const anchor = placement?.anchor ?? fallbackAnchor;
@@ -1039,40 +1188,55 @@ function Tsdraw(props) {
1039
1188
  if (typeof window === "undefined") return "light";
1040
1189
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
1041
1190
  });
1042
- const toolItems = props.tools ?? DEFAULT_TOOL_IDS;
1043
- const customTools = useMemo(
1044
- () => toolItems.filter(isCustomTool),
1045
- [toolItems]
1046
- );
1047
- const toolDefinitions = useMemo(
1048
- () => customTools.map((tool) => tool.definition),
1191
+ const customTools = props.customTools ?? [];
1192
+ const toolbarPartIds = props.uiOptions?.toolbar?.parts ?? DEFAULT_TOOLBAR_PARTS;
1193
+ const customToolMap = useMemo(
1194
+ () => new Map(customTools.map((customTool) => [customTool.id, customTool])),
1049
1195
  [customTools]
1050
1196
  );
1051
- const toolbarItems = useMemo(
1052
- () => toolItems.map((tool) => {
1053
- if (typeof tool === "string") {
1054
- return {
1055
- id: tool,
1056
- label: DEFAULT_TOOL_LABELS[tool],
1057
- icon: (isActive) => getDefaultToolbarIcon(tool, isActive)
1058
- };
1197
+ const toolbarToolIds = useMemo(() => {
1198
+ const ids = /* @__PURE__ */ new Set();
1199
+ for (const toolbarPart of toolbarPartIds) {
1200
+ for (const item of toolbarPart) {
1201
+ if (isToolbarAction(item)) continue;
1202
+ if (item in DEFAULT_TOOL_LABELS || customToolMap.has(item)) {
1203
+ ids.add(item);
1204
+ }
1059
1205
  }
1060
- return {
1061
- id: tool.id,
1062
- label: tool.label,
1063
- icon: (isActive) => isActive && tool.iconSelected ? tool.iconSelected : tool.icon
1064
- };
1065
- }),
1066
- [toolItems]
1206
+ }
1207
+ return ids;
1208
+ }, [customToolMap, toolbarPartIds]);
1209
+ const toolDefinitions = useMemo(
1210
+ () => customTools.filter((customTool) => toolbarToolIds.has(customTool.id)).map((customTool) => customTool.definition),
1211
+ [customTools, toolbarToolIds]
1067
1212
  );
1068
1213
  const stylePanelToolIds = useMemo(
1069
- () => toolItems.filter((tool) => {
1070
- if (typeof tool === "string") return tool === "pen";
1071
- return tool.showStylePanel ?? false;
1072
- }).map(getToolId),
1073
- [toolItems]
1214
+ () => {
1215
+ const nextToolIds = /* @__PURE__ */ new Set();
1216
+ if (toolbarToolIds.has("pen")) {
1217
+ nextToolIds.add("pen");
1218
+ }
1219
+ for (const customTool of customTools) {
1220
+ if ((customTool.showStylePanel ?? false) && toolbarToolIds.has(customTool.id)) {
1221
+ nextToolIds.add(customTool.id);
1222
+ }
1223
+ }
1224
+ return [...nextToolIds];
1225
+ },
1226
+ [customTools, toolbarToolIds]
1074
1227
  );
1075
- const initialTool = props.initialToolId ?? toolbarItems[0]?.id ?? "pen";
1228
+ const firstToolbarTool = useMemo(() => {
1229
+ for (const toolbarPart of toolbarPartIds) {
1230
+ for (const item of toolbarPart) {
1231
+ if (isToolbarAction(item)) continue;
1232
+ if (item in DEFAULT_TOOL_LABELS || customToolMap.has(item)) {
1233
+ return item;
1234
+ }
1235
+ }
1236
+ }
1237
+ return void 0;
1238
+ }, [customToolMap, toolbarPartIds]);
1239
+ const initialTool = props.initialToolId ?? firstToolbarTool ?? "pen";
1076
1240
  const requestedTheme = props.theme ?? "light";
1077
1241
  useEffect(() => {
1078
1242
  if (requestedTheme !== "system" || typeof window === "undefined") return;
@@ -1097,7 +1261,12 @@ function Tsdraw(props) {
1097
1261
  canvasCursor: defaultCanvasCursor,
1098
1262
  cursorContext,
1099
1263
  toolOverlay,
1264
+ isPersistenceReady,
1100
1265
  showStylePanel,
1266
+ canUndo,
1267
+ canRedo,
1268
+ undo,
1269
+ redo,
1101
1270
  setTool,
1102
1271
  applyDrawStyle,
1103
1272
  handleResizePointerDown,
@@ -1127,6 +1296,51 @@ function Tsdraw(props) {
1127
1296
  );
1128
1297
  const overlayNode = props.uiOptions?.overlays?.renderToolOverlay?.({ defaultOverlay: defaultToolOverlay, overlayState: toolOverlay, currentTool }) ?? defaultToolOverlay;
1129
1298
  const customElements = props.uiOptions?.customElements ?? [];
1299
+ const toolbarParts = useMemo(
1300
+ () => toolbarPartIds.map((toolbarPart, partIndex) => {
1301
+ const items = toolbarPart.map((item) => {
1302
+ if (item === "undo") {
1303
+ return {
1304
+ type: "action",
1305
+ id: "undo",
1306
+ label: "Undo",
1307
+ disabled: !canUndo,
1308
+ onSelect: undo
1309
+ };
1310
+ }
1311
+ if (item === "redo") {
1312
+ return {
1313
+ type: "action",
1314
+ id: "redo",
1315
+ label: "Redo",
1316
+ disabled: !canRedo,
1317
+ onSelect: redo
1318
+ };
1319
+ }
1320
+ if (item in DEFAULT_TOOL_LABELS) {
1321
+ return {
1322
+ type: "tool",
1323
+ id: item,
1324
+ label: DEFAULT_TOOL_LABELS[item],
1325
+ icon: (isActive) => getDefaultToolbarIcon(item, isActive)
1326
+ };
1327
+ }
1328
+ const customTool = customToolMap.get(item);
1329
+ if (!customTool) return null;
1330
+ return {
1331
+ type: "tool",
1332
+ id: customTool.id,
1333
+ label: customTool.label,
1334
+ icon: (isActive) => isActive && customTool.iconSelected ? customTool.iconSelected : customTool.icon
1335
+ };
1336
+ }).filter((nextItem) => nextItem != null);
1337
+ return {
1338
+ id: `toolbar-part-${partIndex.toString(36)}`,
1339
+ items
1340
+ };
1341
+ }).filter((part) => part.items.length > 0),
1342
+ [canRedo, canUndo, customToolMap, redo, toolbarPartIds, undo]
1343
+ );
1130
1344
  return /* @__PURE__ */ jsxs(
1131
1345
  "div",
1132
1346
  {
@@ -1170,7 +1384,7 @@ function Tsdraw(props) {
1170
1384
  /* @__PURE__ */ jsx(
1171
1385
  StylePanel,
1172
1386
  {
1173
- visible: showStylePanel,
1387
+ visible: isPersistenceReady && showStylePanel,
1174
1388
  style: stylePanelPlacementStyle,
1175
1389
  theme: resolvedTheme,
1176
1390
  drawColor,
@@ -1197,10 +1411,11 @@ function Tsdraw(props) {
1197
1411
  /* @__PURE__ */ jsx(
1198
1412
  Toolbar,
1199
1413
  {
1200
- items: toolbarItems,
1414
+ parts: toolbarParts,
1201
1415
  style: toolbarPlacementStyle,
1202
- currentTool,
1203
- onToolChange: setTool
1416
+ currentTool: isPersistenceReady ? currentTool : null,
1417
+ onToolChange: setTool,
1418
+ disabled: !isPersistenceReady
1204
1419
  }
1205
1420
  )
1206
1421
  ]