@tsdraw/react 0.9.0 → 0.9.2

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(
@@ -1848,47 +2010,9 @@ var DEFAULT_TOOL_LABELS = {
1848
2010
  eraser: "Eraser",
1849
2011
  hand: "Hand"
1850
2012
  };
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
2013
  function isToolbarAction(item) {
1862
2014
  return item === "undo" || item === "redo";
1863
2015
  }
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
2016
  function Tsdraw(props) {
1893
2017
  const [systemTheme, setSystemTheme] = useState(() => {
1894
2018
  if (typeof window === "undefined") return "light";
@@ -1896,6 +2020,20 @@ function Tsdraw(props) {
1896
2020
  });
1897
2021
  const customTools = props.customTools ?? EMPTY_CUSTOM_TOOLS;
1898
2022
  const toolbarPartIds = props.uiOptions?.toolbar?.parts ?? DEFAULT_TOOLBAR_PARTS;
2023
+ const toolbarPlacement = props.uiOptions?.toolbar?.placement;
2024
+ const toolbarEdgeOffset = toolbarPlacement?.edgeOffset ?? 14;
2025
+ const isToolbarDraggable = props.uiOptions?.toolbar?.draggable === true;
2026
+ const shouldSaveDraggedToolbarPosition = props.uiOptions?.toolbar?.saveDraggedPosition === true;
2027
+ const disabledDragPositionsArray = props.uiOptions?.toolbar?.disabledDragPositions;
2028
+ const disabledDragPositionsSet = useMemo(
2029
+ () => disabledDragPositionsArray && disabledDragPositionsArray.length > 0 ? new Set(disabledDragPositionsArray) : void 0,
2030
+ [disabledDragPositionsArray]
2031
+ );
2032
+ const toolbarDraggedSessionKey = useMemo(
2033
+ () => `tsdraw-toolbar-pos-${props.persistenceKey ?? "default"}`,
2034
+ [props.persistenceKey]
2035
+ );
2036
+ const [draggedToolbarPosition, setDraggedToolbarPosition] = useState(null);
1899
2037
  const customToolMap = useMemo(
1900
2038
  () => new Map(customTools.map((customTool) => [customTool.id, customTool])),
1901
2039
  [customTools]
@@ -1980,9 +2118,35 @@ function Tsdraw(props) {
1980
2118
  onCameraChange: props.onCameraChange,
1981
2119
  onToolChange: props.onToolChange
1982
2120
  });
1983
- const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
1984
- const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
2121
+ const toolbarPlacementAnchor = draggedToolbarPosition?.anchor ?? toolbarPlacement?.anchor ?? "bottom-center";
2122
+ const effectiveToolbarPlacement = draggedToolbarPosition ? { anchor: draggedToolbarPosition.anchor, edgeOffset: toolbarEdgeOffset, style: toolbarPlacement?.style } : toolbarPlacement;
2123
+ const toolbarPlacementStyle = resolvePlacementStyle(effectiveToolbarPlacement, "bottom-center", 14);
2124
+ const toolbarOrientation = resolveOrientation(toolbarPlacementAnchor);
2125
+ const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8);
1985
2126
  const isToolbarHidden = props.uiOptions?.toolbar?.hide === true;
2127
+ useEffect(() => {
2128
+ if (!isToolbarDraggable || !shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2129
+ try {
2130
+ const rawPosition = window.sessionStorage.getItem(toolbarDraggedSessionKey);
2131
+ if (!rawPosition) return;
2132
+ const parsedPosition = JSON.parse(rawPosition);
2133
+ if (typeof parsedPosition.anchor !== "string") return;
2134
+ setDraggedToolbarPosition({ anchor: parsedPosition.anchor });
2135
+ } catch {
2136
+ }
2137
+ }, [isToolbarDraggable, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
2138
+ useEffect(() => {
2139
+ if (isToolbarDraggable) return;
2140
+ setDraggedToolbarPosition(null);
2141
+ }, [isToolbarDraggable]);
2142
+ const handleToolbarDragEnd = useCallback((payload) => {
2143
+ const containerNode = containerRef.current;
2144
+ if (!containerNode) return;
2145
+ const nextPosition = calculateSnap(payload, containerNode.getBoundingClientRect(), disabledDragPositionsSet);
2146
+ setDraggedToolbarPosition(nextPosition);
2147
+ if (!shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2148
+ window.sessionStorage.setItem(toolbarDraggedSessionKey, JSON.stringify(nextPosition));
2149
+ }, [containerRef, disabledDragPositionsSet, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
1986
2150
  const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true || props.readOnly === true;
1987
2151
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
1988
2152
  const defaultToolOverlay = /* @__PURE__ */ jsx(
@@ -2135,7 +2299,7 @@ function Tsdraw(props) {
2135
2299
  position: "absolute",
2136
2300
  zIndex: 130,
2137
2301
  pointerEvents: "all",
2138
- ...resolvePlacementStyle(customElement.placement, "top-left", 8, 8)
2302
+ ...resolvePlacementStyle(customElement.placement, "top-left", 8)
2139
2303
  },
2140
2304
  children: customElement.render({ currentTool, setTool, applyDrawStyle })
2141
2305
  },
@@ -2146,9 +2310,12 @@ function Tsdraw(props) {
2146
2310
  {
2147
2311
  parts: toolbarParts,
2148
2312
  style: toolbarPlacementStyle,
2313
+ orientation: toolbarOrientation,
2149
2314
  currentTool: isPersistenceReady ? currentTool : null,
2150
2315
  onToolChange: setTool,
2151
- disabled: !isPersistenceReady
2316
+ disabled: !isPersistenceReady,
2317
+ draggable: isToolbarDraggable,
2318
+ onDragEnd: handleToolbarDragEnd
2152
2319
  }
2153
2320
  ) : null
2154
2321
  ]