@tsdraw/react 0.9.1 → 0.9.3

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.d.cts CHANGED
@@ -44,6 +44,15 @@ interface TsdrawStylePanelCustomPart {
44
44
  render: (context: TsdrawStylePanelRenderContext) => ReactNode;
45
45
  }
46
46
 
47
+ type VerticalPart = 'top' | 'bottom' | 'center';
48
+ type HorizontalPart = 'left' | 'right' | 'center';
49
+ type UiAnchor = `${VerticalPart}-${HorizontalPart}` | `${HorizontalPart}-${VerticalPart}`;
50
+ interface TsdrawUiPlacement {
51
+ anchor?: UiAnchor;
52
+ edgeOffset?: number;
53
+ style?: CSSProperties;
54
+ }
55
+
47
56
  interface TsdrawCursorContext {
48
57
  currentTool: ToolId;
49
58
  defaultCursor: string;
@@ -79,9 +88,6 @@ interface TsdrawMountApi {
79
88
  }>) => void;
80
89
  }
81
90
 
82
- type VerticalPart = 'top' | 'bottom' | 'center';
83
- type HorizontalPart = 'left' | 'right' | 'center';
84
- type UiAnchor = `${VerticalPart}-${HorizontalPart}` | `${HorizontalPart}-${VerticalPart}`;
85
91
  interface TsdrawCustomTool {
86
92
  id: ToolId;
87
93
  label: string;
@@ -95,17 +101,14 @@ interface TsdrawCustomTool {
95
101
  }
96
102
  type TsdrawToolbarBuiltInAction = 'undo' | 'redo';
97
103
  type ToolbarPartItem = ToolId | TsdrawToolbarBuiltInAction;
98
- interface TsdrawUiPlacement {
99
- anchor?: UiAnchor;
100
- offsetX?: number;
101
- offsetY?: number;
102
- style?: CSSProperties;
103
- }
104
104
  interface TsdrawUiOptions {
105
105
  toolbar?: {
106
106
  hide?: boolean;
107
107
  placement?: TsdrawUiPlacement;
108
108
  parts?: ToolbarPartItem[][];
109
+ draggable?: boolean;
110
+ saveDraggedPosition?: boolean;
111
+ disabledDragPositions?: UiAnchor[];
109
112
  };
110
113
  stylePanel?: {
111
114
  hide?: boolean;
package/dist/index.d.ts CHANGED
@@ -44,6 +44,15 @@ interface TsdrawStylePanelCustomPart {
44
44
  render: (context: TsdrawStylePanelRenderContext) => ReactNode;
45
45
  }
46
46
 
47
+ type VerticalPart = 'top' | 'bottom' | 'center';
48
+ type HorizontalPart = 'left' | 'right' | 'center';
49
+ type UiAnchor = `${VerticalPart}-${HorizontalPart}` | `${HorizontalPart}-${VerticalPart}`;
50
+ interface TsdrawUiPlacement {
51
+ anchor?: UiAnchor;
52
+ edgeOffset?: number;
53
+ style?: CSSProperties;
54
+ }
55
+
47
56
  interface TsdrawCursorContext {
48
57
  currentTool: ToolId;
49
58
  defaultCursor: string;
@@ -79,9 +88,6 @@ interface TsdrawMountApi {
79
88
  }>) => void;
80
89
  }
81
90
 
82
- type VerticalPart = 'top' | 'bottom' | 'center';
83
- type HorizontalPart = 'left' | 'right' | 'center';
84
- type UiAnchor = `${VerticalPart}-${HorizontalPart}` | `${HorizontalPart}-${VerticalPart}`;
85
91
  interface TsdrawCustomTool {
86
92
  id: ToolId;
87
93
  label: string;
@@ -95,17 +101,14 @@ interface TsdrawCustomTool {
95
101
  }
96
102
  type TsdrawToolbarBuiltInAction = 'undo' | 'redo';
97
103
  type ToolbarPartItem = ToolId | TsdrawToolbarBuiltInAction;
98
- interface TsdrawUiPlacement {
99
- anchor?: UiAnchor;
100
- offsetX?: number;
101
- offsetY?: number;
102
- style?: CSSProperties;
103
- }
104
104
  interface TsdrawUiOptions {
105
105
  toolbar?: {
106
106
  hide?: boolean;
107
107
  placement?: TsdrawUiPlacement;
108
108
  parts?: ToolbarPartItem[][];
109
+ draggable?: boolean;
110
+ saveDraggedPosition?: boolean;
111
+ disabledDragPositions?: UiAnchor[];
109
112
  };
110
113
  stylePanel?: {
111
114
  hide?: boolean;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
1
+ import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
3
  import { DEFAULT_COLORS, renderCanvasBackground, getSelectionBoundsPage, buildTransformSnapshots, Editor, STROKE_WIDTHS, ERASER_MARGIN, resolveThemeColor, pageToScreen, getTopShapeAtPoint, buildStartPositions, applyRotation, applyResize, applyMove, normalizeSelectionBounds, getShapesInBounds, HandDraggingState, startCameraSlide, isSelectTool, beginCameraPan, moveCameraPan } from '@tsdraw/core';
4
4
  import { IconPointer, IconPencil, IconSquare, IconCircle, IconEraser, IconHandStop, IconArrowBackUp, IconArrowForwardUp } from '@tabler/icons-react';
@@ -96,6 +96,167 @@ function SelectionOverlay({
96
96
  )
97
97
  ] });
98
98
  }
99
+ function parseAnchor(anchor) {
100
+ const parts = anchor.split("-");
101
+ let vertical = "center";
102
+ let horizontal = "center";
103
+ for (const part of parts) {
104
+ if (part === "top" || part === "bottom") vertical = part;
105
+ else if (part === "left" || part === "right") horizontal = part;
106
+ }
107
+ return { vertical, horizontal };
108
+ }
109
+ function resolvePlacementStyle(placement, fallbackAnchor, fallbackEdgeOffset) {
110
+ const anchor = placement?.anchor ?? fallbackAnchor;
111
+ const edgeOffset = placement?.edgeOffset ?? fallbackEdgeOffset;
112
+ const { vertical, horizontal } = parseAnchor(anchor);
113
+ const result = {};
114
+ const transforms = [];
115
+ if (horizontal === "left") {
116
+ result.left = edgeOffset;
117
+ } else if (horizontal === "right") {
118
+ result.right = edgeOffset;
119
+ } else {
120
+ result.left = "50%";
121
+ transforms.push("translateX(-50%)");
122
+ }
123
+ if (vertical === "top") {
124
+ result.top = edgeOffset;
125
+ } else if (vertical === "bottom") {
126
+ result.bottom = edgeOffset;
127
+ } else {
128
+ result.top = "50%";
129
+ transforms.push("translateY(-50%)");
130
+ }
131
+ if (transforms.length > 0) result.transform = transforms.join(" ");
132
+ return placement?.style ? { ...result, ...placement.style } : result;
133
+ }
134
+ function resolveOrientation(anchor) {
135
+ const { vertical, horizontal } = parseAnchor(anchor);
136
+ if ((horizontal === "left" || horizontal === "right") && vertical === "center") return "vertical";
137
+ return "horizontal";
138
+ }
139
+ var SNAP_TARGETS = [
140
+ { anchor: "top-center", nx: 0.5, ny: 0 },
141
+ { anchor: "bottom-center", nx: 0.5, ny: 1 },
142
+ { anchor: "left-center", nx: 0, ny: 0.5 },
143
+ { anchor: "right-center", nx: 1, ny: 0.5 },
144
+ { anchor: "top-left", nx: 0, ny: 0 },
145
+ { anchor: "top-right", nx: 1, ny: 0 },
146
+ { anchor: "bottom-left", nx: 0, ny: 1 },
147
+ { anchor: "bottom-right", nx: 1, ny: 1 }
148
+ ];
149
+ function calculateSnap(payload, containerRect, disabledPositions) {
150
+ const nx = Math.max(0, Math.min(1, (payload.centerX - containerRect.left) / containerRect.width));
151
+ const ny = Math.max(0, Math.min(1, (payload.centerY - containerRect.top) / containerRect.height));
152
+ let closestAnchor = "bottom-center";
153
+ let closestDistSq = Infinity;
154
+ for (const target of SNAP_TARGETS) {
155
+ if (disabledPositions && disabledPositions.has(target.anchor)) continue;
156
+ const dx = nx - target.nx;
157
+ const dy = ny - target.ny;
158
+ const distSq = dx * dx + dy * dy;
159
+ if (distSq < closestDistSq) {
160
+ closestDistSq = distSq;
161
+ closestAnchor = target.anchor;
162
+ }
163
+ }
164
+ return { anchor: closestAnchor };
165
+ }
166
+ function BaseComponent({ children, className, style, draggable = false, onDragEnd, "aria-label": ariaLabel }) {
167
+ const componentRef = useRef(null);
168
+ const dragStateRef = useRef(null);
169
+ const suppressClickRef = useRef(false);
170
+ const pendingSnapRef = useRef(false);
171
+ const [isDragging, setIsDragging] = useState(false);
172
+ useLayoutEffect(() => {
173
+ if (!pendingSnapRef.current) return;
174
+ const componentNode = componentRef.current;
175
+ if (componentNode) componentNode.style.translate = "";
176
+ pendingSnapRef.current = false;
177
+ setIsDragging(false);
178
+ });
179
+ const finishDrag = (event, shouldSnap) => {
180
+ const componentNode = componentRef.current;
181
+ const dragState = dragStateRef.current;
182
+ if (!componentNode || !dragState || dragState.pointerId !== event.pointerId) return;
183
+ if (componentNode.hasPointerCapture(event.pointerId)) {
184
+ componentNode.releasePointerCapture(event.pointerId);
185
+ }
186
+ if (dragState.isDragging && shouldSnap && onDragEnd) {
187
+ const rect = componentNode.getBoundingClientRect();
188
+ onDragEnd({
189
+ left: rect.left,
190
+ top: rect.top,
191
+ width: rect.width,
192
+ height: rect.height,
193
+ centerX: rect.left + rect.width / 2,
194
+ centerY: rect.top + rect.height / 2
195
+ });
196
+ pendingSnapRef.current = true;
197
+ } else {
198
+ componentNode.style.translate = "";
199
+ setIsDragging(false);
200
+ }
201
+ suppressClickRef.current = dragState.didDrag;
202
+ dragStateRef.current = null;
203
+ };
204
+ const handlePointerDown = (event) => {
205
+ if (!draggable || event.button !== 0) return;
206
+ dragStateRef.current = {
207
+ pointerId: event.pointerId,
208
+ startX: event.clientX,
209
+ startY: event.clientY,
210
+ didDrag: false,
211
+ isDragging: false
212
+ };
213
+ };
214
+ const handlePointerMove = (event) => {
215
+ const componentNode = componentRef.current;
216
+ const dragState = dragStateRef.current;
217
+ if (!draggable || !componentNode || !dragState || dragState.pointerId !== event.pointerId) return;
218
+ const deltaX = event.clientX - dragState.startX;
219
+ const deltaY = event.clientY - dragState.startY;
220
+ if (!dragState.isDragging && deltaX * deltaX + deltaY * deltaY >= 25) {
221
+ dragState.isDragging = true;
222
+ dragState.didDrag = true;
223
+ componentNode.setPointerCapture(event.pointerId);
224
+ setIsDragging(true);
225
+ }
226
+ if (dragState.isDragging) {
227
+ componentNode.style.translate = `${deltaX}px ${deltaY}px`;
228
+ }
229
+ };
230
+ const handlePointerUp = (event) => {
231
+ finishDrag(event, true);
232
+ };
233
+ const handlePointerCancel = (event) => {
234
+ finishDrag(event, false);
235
+ };
236
+ const handleClickCapture = (event) => {
237
+ if (!draggable || !suppressClickRef.current) return;
238
+ suppressClickRef.current = false;
239
+ event.preventDefault();
240
+ event.stopPropagation();
241
+ };
242
+ const draggableClass = draggable ? " tsdraw-component--draggable" : "";
243
+ const draggingClass = isDragging ? " tsdraw-component--dragging" : "";
244
+ return /* @__PURE__ */ jsx(
245
+ "div",
246
+ {
247
+ ref: componentRef,
248
+ className: `tsdraw-component${draggableClass}${draggingClass}${className ? ` ${className}` : ""}`,
249
+ style,
250
+ "aria-label": ariaLabel,
251
+ onPointerDown: handlePointerDown,
252
+ onPointerMove: handlePointerMove,
253
+ onPointerUp: handlePointerUp,
254
+ onPointerCancel: handlePointerCancel,
255
+ onClickCapture: handleClickCapture,
256
+ children
257
+ }
258
+ );
259
+ }
99
260
  var STYLE_COLORS = Object.entries(DEFAULT_COLORS).filter(([key]) => key !== "white").map(([value]) => ({ value }));
100
261
  var STYLE_DASHES = ["draw", "solid", "dashed", "dotted"];
101
262
  var STYLE_FILLS = ["none", "blank", "semi", "solid"];
@@ -127,7 +288,7 @@ function StylePanel({
127
288
  onSizeSelect
128
289
  };
129
290
  const customPartMap = new Map((customParts ?? []).map((customPart) => [customPart.id, customPart]));
130
- return /* @__PURE__ */ jsx("div", { className: "tsdraw-style-panel", style, "aria-label": "Draw style panel", children: parts.map((part) => {
291
+ return /* @__PURE__ */ jsx(BaseComponent, { className: "tsdraw-style-panel", style, "aria-label": "Draw style panel", children: parts.map((part) => {
131
292
  if (part === "colors") {
132
293
  return /* @__PURE__ */ jsx("div", { className: "tsdraw-style-colors", children: STYLE_COLORS.map((item) => /* @__PURE__ */ jsx(
133
294
  "button",
@@ -247,8 +408,9 @@ function getActionIcon(actionId) {
247
408
  if (actionId === "undo") return /* @__PURE__ */ jsx(IconArrowBackUp, { size: 16, stroke: 1.8 });
248
409
  return /* @__PURE__ */ jsx(IconArrowForwardUp, { size: 16, stroke: 1.8 });
249
410
  }
250
- function Toolbar({ parts, currentTool, onToolChange, disabled, style }) {
251
- return /* @__PURE__ */ jsx("div", { className: "tsdraw-toolbar", style, children: parts.map((part, partIndex) => /* @__PURE__ */ jsxs("div", { className: "tsdraw-toolbar-part", children: [
411
+ function Toolbar({ parts, currentTool, onToolChange, disabled, style, orientation = "horizontal", draggable = false, onDragEnd }) {
412
+ const orientationClass = orientation === "vertical" ? " tsdraw-toolbar--vertical" : "";
413
+ return /* @__PURE__ */ jsx(BaseComponent, { className: `tsdraw-toolbar${orientationClass}`, style, draggable, onDragEnd, children: parts.map((part, partIndex) => /* @__PURE__ */ jsxs("div", { className: "tsdraw-toolbar-part", children: [
252
414
  part.items.map((item) => {
253
415
  if (item.type === "action") {
254
416
  return /* @__PURE__ */ jsx(
@@ -1611,6 +1773,10 @@ function useTsdrawCanvasController(options = {}) {
1611
1773
  if (ignorePersistenceChanges) return;
1612
1774
  schedulePersist();
1613
1775
  });
1776
+ const cleanupRenderRequest = editor.onRequestRender(() => {
1777
+ render();
1778
+ refreshSelectionBounds(editor);
1779
+ });
1614
1780
  resize();
1615
1781
  const ro = new ResizeObserver(resize);
1616
1782
  ro.observe(container);
@@ -1679,6 +1845,7 @@ function useTsdrawCanvasController(options = {}) {
1679
1845
  schedulePersistRef.current = null;
1680
1846
  cleanupEditorListener();
1681
1847
  cleanupHistoryListener();
1848
+ cleanupRenderRequest();
1682
1849
  disposeMount?.();
1683
1850
  ro.disconnect();
1684
1851
  canvas.removeEventListener("pointerdown", handlePointerDown);
@@ -1848,47 +2015,9 @@ var DEFAULT_TOOL_LABELS = {
1848
2015
  eraser: "Eraser",
1849
2016
  hand: "Hand"
1850
2017
  };
1851
- function parseAnchor(anchor) {
1852
- const parts = anchor.split("-");
1853
- let vertical = "center";
1854
- let horizontal = "center";
1855
- for (const part of parts) {
1856
- if (part === "top" || part === "bottom") vertical = part;
1857
- else if (part === "left" || part === "right") horizontal = part;
1858
- }
1859
- return { vertical, horizontal };
1860
- }
1861
2018
  function isToolbarAction(item) {
1862
2019
  return item === "undo" || item === "redo";
1863
2020
  }
1864
- function resolvePlacementStyle(placement, fallbackAnchor, fallbackOffsetX, fallbackOffsetY) {
1865
- const anchor = placement?.anchor ?? fallbackAnchor;
1866
- const offsetX = placement?.offsetX ?? fallbackOffsetX;
1867
- const offsetY = placement?.offsetY ?? fallbackOffsetY;
1868
- const { vertical, horizontal } = parseAnchor(anchor);
1869
- const result = {};
1870
- const transforms = [];
1871
- if (horizontal === "left") {
1872
- result.left = offsetX;
1873
- } else if (horizontal === "right") {
1874
- result.right = offsetX;
1875
- } else {
1876
- result.left = "50%";
1877
- transforms.push("translateX(-50%)");
1878
- if (offsetX) transforms.push(`translateX(${offsetX}px)`);
1879
- }
1880
- if (vertical === "top") {
1881
- result.top = offsetY;
1882
- } else if (vertical === "bottom") {
1883
- result.bottom = offsetY;
1884
- } else {
1885
- result.top = "50%";
1886
- transforms.push("translateY(-50%)");
1887
- if (offsetY) transforms.push(`translateY(${offsetY}px)`);
1888
- }
1889
- if (transforms.length > 0) result.transform = transforms.join(" ");
1890
- return placement?.style ? { ...result, ...placement.style } : result;
1891
- }
1892
2021
  function Tsdraw(props) {
1893
2022
  const [systemTheme, setSystemTheme] = useState(() => {
1894
2023
  if (typeof window === "undefined") return "light";
@@ -1896,6 +2025,20 @@ function Tsdraw(props) {
1896
2025
  });
1897
2026
  const customTools = props.customTools ?? EMPTY_CUSTOM_TOOLS;
1898
2027
  const toolbarPartIds = props.uiOptions?.toolbar?.parts ?? DEFAULT_TOOLBAR_PARTS;
2028
+ const toolbarPlacement = props.uiOptions?.toolbar?.placement;
2029
+ const toolbarEdgeOffset = toolbarPlacement?.edgeOffset ?? 14;
2030
+ const isToolbarDraggable = props.uiOptions?.toolbar?.draggable === true;
2031
+ const shouldSaveDraggedToolbarPosition = props.uiOptions?.toolbar?.saveDraggedPosition === true;
2032
+ const disabledDragPositionsArray = props.uiOptions?.toolbar?.disabledDragPositions;
2033
+ const disabledDragPositionsSet = useMemo(
2034
+ () => disabledDragPositionsArray && disabledDragPositionsArray.length > 0 ? new Set(disabledDragPositionsArray) : void 0,
2035
+ [disabledDragPositionsArray]
2036
+ );
2037
+ const toolbarDraggedSessionKey = useMemo(
2038
+ () => `tsdraw-toolbar-pos-${props.persistenceKey ?? "default"}`,
2039
+ [props.persistenceKey]
2040
+ );
2041
+ const [draggedToolbarPosition, setDraggedToolbarPosition] = useState(null);
1899
2042
  const customToolMap = useMemo(
1900
2043
  () => new Map(customTools.map((customTool) => [customTool.id, customTool])),
1901
2044
  [customTools]
@@ -1980,9 +2123,35 @@ function Tsdraw(props) {
1980
2123
  onCameraChange: props.onCameraChange,
1981
2124
  onToolChange: props.onToolChange
1982
2125
  });
1983
- const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
1984
- const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
2126
+ const toolbarPlacementAnchor = draggedToolbarPosition?.anchor ?? toolbarPlacement?.anchor ?? "bottom-center";
2127
+ const effectiveToolbarPlacement = draggedToolbarPosition ? { anchor: draggedToolbarPosition.anchor, edgeOffset: toolbarEdgeOffset, style: toolbarPlacement?.style } : toolbarPlacement;
2128
+ const toolbarPlacementStyle = resolvePlacementStyle(effectiveToolbarPlacement, "bottom-center", 14);
2129
+ const toolbarOrientation = resolveOrientation(toolbarPlacementAnchor);
2130
+ const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8);
1985
2131
  const isToolbarHidden = props.uiOptions?.toolbar?.hide === true;
2132
+ useEffect(() => {
2133
+ if (!isToolbarDraggable || !shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2134
+ try {
2135
+ const rawPosition = window.sessionStorage.getItem(toolbarDraggedSessionKey);
2136
+ if (!rawPosition) return;
2137
+ const parsedPosition = JSON.parse(rawPosition);
2138
+ if (typeof parsedPosition.anchor !== "string") return;
2139
+ setDraggedToolbarPosition({ anchor: parsedPosition.anchor });
2140
+ } catch {
2141
+ }
2142
+ }, [isToolbarDraggable, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
2143
+ useEffect(() => {
2144
+ if (isToolbarDraggable) return;
2145
+ setDraggedToolbarPosition(null);
2146
+ }, [isToolbarDraggable]);
2147
+ const handleToolbarDragEnd = useCallback((payload) => {
2148
+ const containerNode = containerRef.current;
2149
+ if (!containerNode) return;
2150
+ const nextPosition = calculateSnap(payload, containerNode.getBoundingClientRect(), disabledDragPositionsSet);
2151
+ setDraggedToolbarPosition(nextPosition);
2152
+ if (!shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2153
+ window.sessionStorage.setItem(toolbarDraggedSessionKey, JSON.stringify(nextPosition));
2154
+ }, [containerRef, disabledDragPositionsSet, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
1986
2155
  const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true || props.readOnly === true;
1987
2156
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
1988
2157
  const defaultToolOverlay = /* @__PURE__ */ jsx(
@@ -2135,7 +2304,7 @@ function Tsdraw(props) {
2135
2304
  position: "absolute",
2136
2305
  zIndex: 130,
2137
2306
  pointerEvents: "all",
2138
- ...resolvePlacementStyle(customElement.placement, "top-left", 8, 8)
2307
+ ...resolvePlacementStyle(customElement.placement, "top-left", 8)
2139
2308
  },
2140
2309
  children: customElement.render({ currentTool, setTool, applyDrawStyle })
2141
2310
  },
@@ -2146,9 +2315,12 @@ function Tsdraw(props) {
2146
2315
  {
2147
2316
  parts: toolbarParts,
2148
2317
  style: toolbarPlacementStyle,
2318
+ orientation: toolbarOrientation,
2149
2319
  currentTool: isPersistenceReady ? currentTool : null,
2150
2320
  onToolChange: setTool,
2151
- disabled: !isPersistenceReady
2321
+ disabled: !isPersistenceReady,
2322
+ draggable: isToolbarDraggable,
2323
+ onDragEnd: handleToolbarDragEnd
2152
2324
  }
2153
2325
  ) : null
2154
2326
  ]