@tsdraw/react 0.5.0 → 0.6.0

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({
@@ -195,29 +195,52 @@ 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
- 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,6 +605,7 @@ 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
  });
557
611
  persistenceChannel?.postMessage({
@@ -592,6 +646,7 @@ function useTsdrawCanvasController(options = {}) {
592
646
  const applyRemoteDocumentSnapshot = (document) => {
593
647
  ignorePersistenceChanges = true;
594
648
  editor.loadDocumentSnapshot(document);
649
+ editor.clearRedoHistory();
595
650
  reconcileSelectionAfterDocumentLoad();
596
651
  render();
597
652
  ignorePersistenceChanges = false;
@@ -624,6 +679,7 @@ function useTsdrawCanvasController(options = {}) {
624
679
  const handlePointerDown = (e) => {
625
680
  if (!canvas.contains(e.target)) return;
626
681
  isPointerActiveRef.current = true;
682
+ editor.beginHistoryEntry();
627
683
  canvas.setPointerCapture(e.pointerId);
628
684
  lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
629
685
  updatePointerPreview(e.clientX, e.clientY);
@@ -748,6 +804,7 @@ function useTsdrawCanvasController(options = {}) {
748
804
  };
749
805
  render();
750
806
  refreshSelectionBounds(editor);
807
+ editor.endHistoryEntry();
751
808
  return;
752
809
  }
753
810
  if (drag.mode === "resize") {
@@ -756,6 +813,7 @@ function useTsdrawCanvasController(options = {}) {
756
813
  resizeRef.current = { handle: null, startBounds: null, startShapes: /* @__PURE__ */ new Map() };
757
814
  render();
758
815
  refreshSelectionBounds(editor);
816
+ editor.endHistoryEntry();
759
817
  return;
760
818
  }
761
819
  if (drag.mode === "move") {
@@ -763,6 +821,7 @@ function useTsdrawCanvasController(options = {}) {
763
821
  selectDragRef.current.mode = "none";
764
822
  render();
765
823
  refreshSelectionBounds(editor);
824
+ editor.endHistoryEntry();
766
825
  return;
767
826
  }
768
827
  if (drag.mode === "marquee") {
@@ -794,6 +853,7 @@ function useTsdrawCanvasController(options = {}) {
794
853
  pendingRemoteDocumentRef.current = null;
795
854
  applyRemoteDocumentSnapshot(pendingRemoteDocument);
796
855
  }
856
+ editor.endHistoryEntry();
797
857
  return;
798
858
  }
799
859
  }
@@ -805,8 +865,25 @@ function useTsdrawCanvasController(options = {}) {
805
865
  pendingRemoteDocumentRef.current = null;
806
866
  applyRemoteDocumentSnapshot(pendingRemoteDocument);
807
867
  }
868
+ editor.endHistoryEntry();
808
869
  };
809
870
  const handleKeyDown = (e) => {
871
+ const isMetaPressed = e.metaKey || e.ctrlKey;
872
+ const loweredKey = e.key.toLowerCase();
873
+ const isUndoOrRedoKey = loweredKey === "z" || loweredKey === "y";
874
+ if (isMetaPressed && isUndoOrRedoKey) {
875
+ const shouldRedo = loweredKey === "y" || loweredKey === "z" && e.shiftKey;
876
+ const changed = shouldRedo ? editor.redo() : editor.undo();
877
+ if (changed) {
878
+ e.preventDefault();
879
+ e.stopPropagation();
880
+ reconcileSelectionAfterDocumentLoad();
881
+ setSelectionRotationDeg(0);
882
+ render();
883
+ syncHistoryState();
884
+ return;
885
+ }
886
+ }
810
887
  editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
811
888
  editor.tools.keyDown({ key: e.key });
812
889
  render();
@@ -817,40 +894,56 @@ function useTsdrawCanvasController(options = {}) {
817
894
  render();
818
895
  };
819
896
  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);
897
+ if (!persistenceKey) {
898
+ setIsPersistenceReady(true);
899
+ return;
832
900
  }
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;
901
+ try {
902
+ persistenceDb = new TsdrawLocalIndexedDb(persistenceKey);
903
+ const loaded = await persistenceDb.load(sessionId);
904
+ const snapshot = {};
905
+ if (loaded.records.length > 0) {
906
+ snapshot.document = { records: loaded.records };
907
+ }
908
+ if (loaded.state) {
909
+ snapshot.state = loaded.state;
910
+ }
911
+ if (snapshot.document || snapshot.state) {
912
+ applyLoadedSnapshot(snapshot);
913
+ }
914
+ editor.loadHistorySnapshot(loaded.history);
915
+ syncHistoryState();
916
+ persistenceActive = true;
917
+ persistenceChannel = new BroadcastChannel(`tsdraw:persistence:${persistenceKey}`);
918
+ persistenceChannel.onmessage = async (event) => {
919
+ const data = event.data;
920
+ if (data?.type !== "tsdraw:persisted" || data.senderSessionId === sessionId) return;
921
+ if (!persistenceDb || disposed) return;
922
+ const nextLoaded = await persistenceDb.load(sessionId);
923
+ if (nextLoaded.records.length > 0) {
924
+ const nextDocument = { records: nextLoaded.records };
925
+ if (isPointerActiveRef.current) {
926
+ pendingRemoteDocumentRef.current = nextDocument;
927
+ return;
928
+ }
929
+ applyRemoteDocumentSnapshot(nextDocument);
845
930
  }
846
- applyRemoteDocumentSnapshot(nextDocument);
931
+ };
932
+ } finally {
933
+ if (!disposed) {
934
+ setIsPersistenceReady(true);
847
935
  }
848
- };
936
+ }
849
937
  };
850
938
  const cleanupEditorListener = editor.listen(() => {
851
939
  if (ignorePersistenceChanges) return;
852
940
  schedulePersist();
853
941
  });
942
+ const cleanupHistoryListener = editor.listenHistory(() => {
943
+ syncHistoryState();
944
+ if (ignorePersistenceChanges) return;
945
+ schedulePersist();
946
+ });
854
947
  resize();
855
948
  const ro = new ResizeObserver(resize);
856
949
  ro.observe(container);
@@ -873,6 +966,26 @@ function useTsdrawCanvasController(options = {}) {
873
966
  currentToolRef.current = tool;
874
967
  },
875
968
  getCurrentTool: () => editor.getCurrentToolId(),
969
+ undo: () => {
970
+ const changed = editor.undo();
971
+ if (!changed) return false;
972
+ reconcileSelectionAfterDocumentLoad();
973
+ setSelectionRotationDeg(0);
974
+ render();
975
+ syncHistoryState();
976
+ return true;
977
+ },
978
+ redo: () => {
979
+ const changed = editor.redo();
980
+ if (!changed) return false;
981
+ reconcileSelectionAfterDocumentLoad();
982
+ setSelectionRotationDeg(0);
983
+ render();
984
+ syncHistoryState();
985
+ return true;
986
+ },
987
+ canUndo: () => editor.canUndo(),
988
+ canRedo: () => editor.canRedo(),
876
989
  applyDrawStyle: (partial) => {
877
990
  editor.setCurrentDrawStyle(partial);
878
991
  if (partial.color) setDrawColor(partial.color);
@@ -885,6 +998,7 @@ function useTsdrawCanvasController(options = {}) {
885
998
  disposed = true;
886
999
  schedulePersistRef.current = null;
887
1000
  cleanupEditorListener();
1001
+ cleanupHistoryListener();
888
1002
  disposeMount?.();
889
1003
  ro.disconnect();
890
1004
  canvas.removeEventListener("pointerdown", handlePointerDown);
@@ -938,6 +1052,38 @@ function useTsdrawCanvasController(options = {}) {
938
1052
  },
939
1053
  [render]
940
1054
  );
1055
+ const undo = useCallback(() => {
1056
+ const editor = editorRef.current;
1057
+ if (!editor) return false;
1058
+ const changed = editor.undo();
1059
+ if (!changed) return false;
1060
+ const nextSelectedShapeIds = selectedShapeIdsRef.current.filter((shapeId) => editor.getShape(shapeId) != null);
1061
+ if (nextSelectedShapeIds.length !== selectedShapeIdsRef.current.length) {
1062
+ selectedShapeIdsRef.current = nextSelectedShapeIds;
1063
+ setSelectedShapeIds(nextSelectedShapeIds);
1064
+ }
1065
+ setSelectionRotationDeg(0);
1066
+ render();
1067
+ setCanUndo(editor.canUndo());
1068
+ setCanRedo(editor.canRedo());
1069
+ return true;
1070
+ }, [render]);
1071
+ const redo = useCallback(() => {
1072
+ const editor = editorRef.current;
1073
+ if (!editor) return false;
1074
+ const changed = editor.redo();
1075
+ if (!changed) return false;
1076
+ const nextSelectedShapeIds = selectedShapeIdsRef.current.filter((shapeId) => editor.getShape(shapeId) != null);
1077
+ if (nextSelectedShapeIds.length !== selectedShapeIdsRef.current.length) {
1078
+ selectedShapeIdsRef.current = nextSelectedShapeIds;
1079
+ setSelectedShapeIds(nextSelectedShapeIds);
1080
+ }
1081
+ setSelectionRotationDeg(0);
1082
+ render();
1083
+ setCanUndo(editor.canUndo());
1084
+ setCanRedo(editor.canRedo());
1085
+ return true;
1086
+ }, [render]);
941
1087
  const showToolOverlay = isPointerInsideCanvas && (currentTool === "pen" || currentTool === "eraser");
942
1088
  const canvasCursor = getCanvasCursor(currentTool, {
943
1089
  isMovingSelection,
@@ -976,14 +1122,19 @@ function useTsdrawCanvasController(options = {}) {
976
1122
  canvasCursor,
977
1123
  cursorContext,
978
1124
  toolOverlay,
1125
+ isPersistenceReady,
979
1126
  showStylePanel: stylePanelToolIdsRef.current.includes(currentTool),
1127
+ canUndo,
1128
+ canRedo,
1129
+ undo,
1130
+ redo,
980
1131
  setTool,
981
1132
  applyDrawStyle,
982
1133
  handleResizePointerDown,
983
1134
  handleRotatePointerDown
984
1135
  };
985
1136
  }
986
- var DEFAULT_TOOL_IDS = ["select", "pen", "eraser", "hand"];
1137
+ var DEFAULT_TOOLBAR_PARTS = [["undo", "redo"], ["select", "hand", "pen", "eraser"]];
987
1138
  var DEFAULT_TOOL_LABELS = {
988
1139
  select: "Select",
989
1140
  pen: "Pen",
@@ -1000,11 +1151,8 @@ function parseAnchor(anchor) {
1000
1151
  }
1001
1152
  return { vertical, horizontal };
1002
1153
  }
1003
- function isCustomTool(toolItem) {
1004
- return typeof toolItem !== "string";
1005
- }
1006
- function getToolId(toolItem) {
1007
- return typeof toolItem === "string" ? toolItem : toolItem.id;
1154
+ function isToolbarAction(item) {
1155
+ return item === "undo" || item === "redo";
1008
1156
  }
1009
1157
  function resolvePlacementStyle(placement, fallbackAnchor, fallbackOffsetX, fallbackOffsetY) {
1010
1158
  const anchor = placement?.anchor ?? fallbackAnchor;
@@ -1039,40 +1187,55 @@ function Tsdraw(props) {
1039
1187
  if (typeof window === "undefined") return "light";
1040
1188
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
1041
1189
  });
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),
1190
+ const customTools = props.customTools ?? [];
1191
+ const toolbarPartIds = props.uiOptions?.toolbar?.parts ?? DEFAULT_TOOLBAR_PARTS;
1192
+ const customToolMap = useMemo(
1193
+ () => new Map(customTools.map((customTool) => [customTool.id, customTool])),
1049
1194
  [customTools]
1050
1195
  );
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
- };
1196
+ const toolbarToolIds = useMemo(() => {
1197
+ const ids = /* @__PURE__ */ new Set();
1198
+ for (const toolbarPart of toolbarPartIds) {
1199
+ for (const item of toolbarPart) {
1200
+ if (isToolbarAction(item)) continue;
1201
+ if (item in DEFAULT_TOOL_LABELS || customToolMap.has(item)) {
1202
+ ids.add(item);
1203
+ }
1059
1204
  }
1060
- return {
1061
- id: tool.id,
1062
- label: tool.label,
1063
- icon: (isActive) => isActive && tool.iconSelected ? tool.iconSelected : tool.icon
1064
- };
1065
- }),
1066
- [toolItems]
1205
+ }
1206
+ return ids;
1207
+ }, [customToolMap, toolbarPartIds]);
1208
+ const toolDefinitions = useMemo(
1209
+ () => customTools.filter((customTool) => toolbarToolIds.has(customTool.id)).map((customTool) => customTool.definition),
1210
+ [customTools, toolbarToolIds]
1067
1211
  );
1068
1212
  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]
1213
+ () => {
1214
+ const nextToolIds = /* @__PURE__ */ new Set();
1215
+ if (toolbarToolIds.has("pen")) {
1216
+ nextToolIds.add("pen");
1217
+ }
1218
+ for (const customTool of customTools) {
1219
+ if ((customTool.showStylePanel ?? false) && toolbarToolIds.has(customTool.id)) {
1220
+ nextToolIds.add(customTool.id);
1221
+ }
1222
+ }
1223
+ return [...nextToolIds];
1224
+ },
1225
+ [customTools, toolbarToolIds]
1074
1226
  );
1075
- const initialTool = props.initialToolId ?? toolbarItems[0]?.id ?? "pen";
1227
+ const firstToolbarTool = useMemo(() => {
1228
+ for (const toolbarPart of toolbarPartIds) {
1229
+ for (const item of toolbarPart) {
1230
+ if (isToolbarAction(item)) continue;
1231
+ if (item in DEFAULT_TOOL_LABELS || customToolMap.has(item)) {
1232
+ return item;
1233
+ }
1234
+ }
1235
+ }
1236
+ return void 0;
1237
+ }, [customToolMap, toolbarPartIds]);
1238
+ const initialTool = props.initialToolId ?? firstToolbarTool ?? "pen";
1076
1239
  const requestedTheme = props.theme ?? "light";
1077
1240
  useEffect(() => {
1078
1241
  if (requestedTheme !== "system" || typeof window === "undefined") return;
@@ -1097,7 +1260,12 @@ function Tsdraw(props) {
1097
1260
  canvasCursor: defaultCanvasCursor,
1098
1261
  cursorContext,
1099
1262
  toolOverlay,
1263
+ isPersistenceReady,
1100
1264
  showStylePanel,
1265
+ canUndo,
1266
+ canRedo,
1267
+ undo,
1268
+ redo,
1101
1269
  setTool,
1102
1270
  applyDrawStyle,
1103
1271
  handleResizePointerDown,
@@ -1110,7 +1278,7 @@ function Tsdraw(props) {
1110
1278
  stylePanelToolIds,
1111
1279
  onMount: props.onMount
1112
1280
  });
1113
- const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 16);
1281
+ const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
1114
1282
  const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
1115
1283
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
1116
1284
  const defaultToolOverlay = /* @__PURE__ */ jsx(
@@ -1127,6 +1295,51 @@ function Tsdraw(props) {
1127
1295
  );
1128
1296
  const overlayNode = props.uiOptions?.overlays?.renderToolOverlay?.({ defaultOverlay: defaultToolOverlay, overlayState: toolOverlay, currentTool }) ?? defaultToolOverlay;
1129
1297
  const customElements = props.uiOptions?.customElements ?? [];
1298
+ const toolbarParts = useMemo(
1299
+ () => toolbarPartIds.map((toolbarPart, partIndex) => {
1300
+ const items = toolbarPart.map((item) => {
1301
+ if (item === "undo") {
1302
+ return {
1303
+ type: "action",
1304
+ id: "undo",
1305
+ label: "Undo",
1306
+ disabled: !canUndo,
1307
+ onSelect: undo
1308
+ };
1309
+ }
1310
+ if (item === "redo") {
1311
+ return {
1312
+ type: "action",
1313
+ id: "redo",
1314
+ label: "Redo",
1315
+ disabled: !canRedo,
1316
+ onSelect: redo
1317
+ };
1318
+ }
1319
+ if (item in DEFAULT_TOOL_LABELS) {
1320
+ return {
1321
+ type: "tool",
1322
+ id: item,
1323
+ label: DEFAULT_TOOL_LABELS[item],
1324
+ icon: (isActive) => getDefaultToolbarIcon(item, isActive)
1325
+ };
1326
+ }
1327
+ const customTool = customToolMap.get(item);
1328
+ if (!customTool) return null;
1329
+ return {
1330
+ type: "tool",
1331
+ id: customTool.id,
1332
+ label: customTool.label,
1333
+ icon: (isActive) => isActive && customTool.iconSelected ? customTool.iconSelected : customTool.icon
1334
+ };
1335
+ }).filter((nextItem) => nextItem != null);
1336
+ return {
1337
+ id: `toolbar-part-${partIndex.toString(36)}`,
1338
+ items
1339
+ };
1340
+ }).filter((part) => part.items.length > 0),
1341
+ [canRedo, canUndo, customToolMap, redo, toolbarPartIds, undo]
1342
+ );
1130
1343
  return /* @__PURE__ */ jsxs(
1131
1344
  "div",
1132
1345
  {
@@ -1170,7 +1383,7 @@ function Tsdraw(props) {
1170
1383
  /* @__PURE__ */ jsx(
1171
1384
  StylePanel,
1172
1385
  {
1173
- visible: showStylePanel,
1386
+ visible: isPersistenceReady && showStylePanel,
1174
1387
  style: stylePanelPlacementStyle,
1175
1388
  theme: resolvedTheme,
1176
1389
  drawColor,
@@ -1197,10 +1410,11 @@ function Tsdraw(props) {
1197
1410
  /* @__PURE__ */ jsx(
1198
1411
  Toolbar,
1199
1412
  {
1200
- items: toolbarItems,
1413
+ parts: toolbarParts,
1201
1414
  style: toolbarPlacementStyle,
1202
- currentTool,
1203
- onToolChange: setTool
1415
+ currentTool: isPersistenceReady ? currentTool : null,
1416
+ onToolChange: setTool,
1417
+ disabled: !isPersistenceReady
1204
1418
  }
1205
1419
  )
1206
1420
  ]