@tsdraw/react 0.9.1 → 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.cjs CHANGED
@@ -98,6 +98,167 @@ function SelectionOverlay({
98
98
  )
99
99
  ] });
100
100
  }
101
+ function parseAnchor(anchor) {
102
+ const parts = anchor.split("-");
103
+ let vertical = "center";
104
+ let horizontal = "center";
105
+ for (const part of parts) {
106
+ if (part === "top" || part === "bottom") vertical = part;
107
+ else if (part === "left" || part === "right") horizontal = part;
108
+ }
109
+ return { vertical, horizontal };
110
+ }
111
+ function resolvePlacementStyle(placement, fallbackAnchor, fallbackEdgeOffset) {
112
+ const anchor = placement?.anchor ?? fallbackAnchor;
113
+ const edgeOffset = placement?.edgeOffset ?? fallbackEdgeOffset;
114
+ const { vertical, horizontal } = parseAnchor(anchor);
115
+ const result = {};
116
+ const transforms = [];
117
+ if (horizontal === "left") {
118
+ result.left = edgeOffset;
119
+ } else if (horizontal === "right") {
120
+ result.right = edgeOffset;
121
+ } else {
122
+ result.left = "50%";
123
+ transforms.push("translateX(-50%)");
124
+ }
125
+ if (vertical === "top") {
126
+ result.top = edgeOffset;
127
+ } else if (vertical === "bottom") {
128
+ result.bottom = edgeOffset;
129
+ } else {
130
+ result.top = "50%";
131
+ transforms.push("translateY(-50%)");
132
+ }
133
+ if (transforms.length > 0) result.transform = transforms.join(" ");
134
+ return placement?.style ? { ...result, ...placement.style } : result;
135
+ }
136
+ function resolveOrientation(anchor) {
137
+ const { vertical, horizontal } = parseAnchor(anchor);
138
+ if ((horizontal === "left" || horizontal === "right") && vertical === "center") return "vertical";
139
+ return "horizontal";
140
+ }
141
+ var SNAP_TARGETS = [
142
+ { anchor: "top-center", nx: 0.5, ny: 0 },
143
+ { anchor: "bottom-center", nx: 0.5, ny: 1 },
144
+ { anchor: "left-center", nx: 0, ny: 0.5 },
145
+ { anchor: "right-center", nx: 1, ny: 0.5 },
146
+ { anchor: "top-left", nx: 0, ny: 0 },
147
+ { anchor: "top-right", nx: 1, ny: 0 },
148
+ { anchor: "bottom-left", nx: 0, ny: 1 },
149
+ { anchor: "bottom-right", nx: 1, ny: 1 }
150
+ ];
151
+ function calculateSnap(payload, containerRect, disabledPositions) {
152
+ const nx = Math.max(0, Math.min(1, (payload.centerX - containerRect.left) / containerRect.width));
153
+ const ny = Math.max(0, Math.min(1, (payload.centerY - containerRect.top) / containerRect.height));
154
+ let closestAnchor = "bottom-center";
155
+ let closestDistSq = Infinity;
156
+ for (const target of SNAP_TARGETS) {
157
+ if (disabledPositions && disabledPositions.has(target.anchor)) continue;
158
+ const dx = nx - target.nx;
159
+ const dy = ny - target.ny;
160
+ const distSq = dx * dx + dy * dy;
161
+ if (distSq < closestDistSq) {
162
+ closestDistSq = distSq;
163
+ closestAnchor = target.anchor;
164
+ }
165
+ }
166
+ return { anchor: closestAnchor };
167
+ }
168
+ function BaseComponent({ children, className, style, draggable = false, onDragEnd, "aria-label": ariaLabel }) {
169
+ const componentRef = react.useRef(null);
170
+ const dragStateRef = react.useRef(null);
171
+ const suppressClickRef = react.useRef(false);
172
+ const pendingSnapRef = react.useRef(false);
173
+ const [isDragging, setIsDragging] = react.useState(false);
174
+ react.useLayoutEffect(() => {
175
+ if (!pendingSnapRef.current) return;
176
+ const componentNode = componentRef.current;
177
+ if (componentNode) componentNode.style.translate = "";
178
+ pendingSnapRef.current = false;
179
+ setIsDragging(false);
180
+ });
181
+ const finishDrag = (event, shouldSnap) => {
182
+ const componentNode = componentRef.current;
183
+ const dragState = dragStateRef.current;
184
+ if (!componentNode || !dragState || dragState.pointerId !== event.pointerId) return;
185
+ if (componentNode.hasPointerCapture(event.pointerId)) {
186
+ componentNode.releasePointerCapture(event.pointerId);
187
+ }
188
+ if (dragState.isDragging && shouldSnap && onDragEnd) {
189
+ const rect = componentNode.getBoundingClientRect();
190
+ onDragEnd({
191
+ left: rect.left,
192
+ top: rect.top,
193
+ width: rect.width,
194
+ height: rect.height,
195
+ centerX: rect.left + rect.width / 2,
196
+ centerY: rect.top + rect.height / 2
197
+ });
198
+ pendingSnapRef.current = true;
199
+ } else {
200
+ componentNode.style.translate = "";
201
+ setIsDragging(false);
202
+ }
203
+ suppressClickRef.current = dragState.didDrag;
204
+ dragStateRef.current = null;
205
+ };
206
+ const handlePointerDown = (event) => {
207
+ if (!draggable || event.button !== 0) return;
208
+ dragStateRef.current = {
209
+ pointerId: event.pointerId,
210
+ startX: event.clientX,
211
+ startY: event.clientY,
212
+ didDrag: false,
213
+ isDragging: false
214
+ };
215
+ };
216
+ const handlePointerMove = (event) => {
217
+ const componentNode = componentRef.current;
218
+ const dragState = dragStateRef.current;
219
+ if (!draggable || !componentNode || !dragState || dragState.pointerId !== event.pointerId) return;
220
+ const deltaX = event.clientX - dragState.startX;
221
+ const deltaY = event.clientY - dragState.startY;
222
+ if (!dragState.isDragging && deltaX * deltaX + deltaY * deltaY >= 25) {
223
+ dragState.isDragging = true;
224
+ dragState.didDrag = true;
225
+ componentNode.setPointerCapture(event.pointerId);
226
+ setIsDragging(true);
227
+ }
228
+ if (dragState.isDragging) {
229
+ componentNode.style.translate = `${deltaX}px ${deltaY}px`;
230
+ }
231
+ };
232
+ const handlePointerUp = (event) => {
233
+ finishDrag(event, true);
234
+ };
235
+ const handlePointerCancel = (event) => {
236
+ finishDrag(event, false);
237
+ };
238
+ const handleClickCapture = (event) => {
239
+ if (!draggable || !suppressClickRef.current) return;
240
+ suppressClickRef.current = false;
241
+ event.preventDefault();
242
+ event.stopPropagation();
243
+ };
244
+ const draggableClass = draggable ? " tsdraw-component--draggable" : "";
245
+ const draggingClass = isDragging ? " tsdraw-component--dragging" : "";
246
+ return /* @__PURE__ */ jsxRuntime.jsx(
247
+ "div",
248
+ {
249
+ ref: componentRef,
250
+ className: `tsdraw-component${draggableClass}${draggingClass}${className ? ` ${className}` : ""}`,
251
+ style,
252
+ "aria-label": ariaLabel,
253
+ onPointerDown: handlePointerDown,
254
+ onPointerMove: handlePointerMove,
255
+ onPointerUp: handlePointerUp,
256
+ onPointerCancel: handlePointerCancel,
257
+ onClickCapture: handleClickCapture,
258
+ children
259
+ }
260
+ );
261
+ }
101
262
  var STYLE_COLORS = Object.entries(core.DEFAULT_COLORS).filter(([key]) => key !== "white").map(([value]) => ({ value }));
102
263
  var STYLE_DASHES = ["draw", "solid", "dashed", "dotted"];
103
264
  var STYLE_FILLS = ["none", "blank", "semi", "solid"];
@@ -129,7 +290,7 @@ function StylePanel({
129
290
  onSizeSelect
130
291
  };
131
292
  const customPartMap = new Map((customParts ?? []).map((customPart) => [customPart.id, customPart]));
132
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "tsdraw-style-panel", style, "aria-label": "Draw style panel", children: parts.map((part) => {
293
+ return /* @__PURE__ */ jsxRuntime.jsx(BaseComponent, { className: "tsdraw-style-panel", style, "aria-label": "Draw style panel", children: parts.map((part) => {
133
294
  if (part === "colors") {
134
295
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "tsdraw-style-colors", children: STYLE_COLORS.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
135
296
  "button",
@@ -249,8 +410,9 @@ function getActionIcon(actionId) {
249
410
  if (actionId === "undo") return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconArrowBackUp, { size: 16, stroke: 1.8 });
250
411
  return /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconArrowForwardUp, { size: 16, stroke: 1.8 });
251
412
  }
252
- function Toolbar({ parts, currentTool, onToolChange, disabled, style }) {
253
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "tsdraw-toolbar", style, children: parts.map((part, partIndex) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "tsdraw-toolbar-part", children: [
413
+ function Toolbar({ parts, currentTool, onToolChange, disabled, style, orientation = "horizontal", draggable = false, onDragEnd }) {
414
+ const orientationClass = orientation === "vertical" ? " tsdraw-toolbar--vertical" : "";
415
+ return /* @__PURE__ */ jsxRuntime.jsx(BaseComponent, { className: `tsdraw-toolbar${orientationClass}`, style, draggable, onDragEnd, children: parts.map((part, partIndex) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "tsdraw-toolbar-part", children: [
254
416
  part.items.map((item) => {
255
417
  if (item.type === "action") {
256
418
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -1850,47 +2012,9 @@ var DEFAULT_TOOL_LABELS = {
1850
2012
  eraser: "Eraser",
1851
2013
  hand: "Hand"
1852
2014
  };
1853
- function parseAnchor(anchor) {
1854
- const parts = anchor.split("-");
1855
- let vertical = "center";
1856
- let horizontal = "center";
1857
- for (const part of parts) {
1858
- if (part === "top" || part === "bottom") vertical = part;
1859
- else if (part === "left" || part === "right") horizontal = part;
1860
- }
1861
- return { vertical, horizontal };
1862
- }
1863
2015
  function isToolbarAction(item) {
1864
2016
  return item === "undo" || item === "redo";
1865
2017
  }
1866
- function resolvePlacementStyle(placement, fallbackAnchor, fallbackOffsetX, fallbackOffsetY) {
1867
- const anchor = placement?.anchor ?? fallbackAnchor;
1868
- const offsetX = placement?.offsetX ?? fallbackOffsetX;
1869
- const offsetY = placement?.offsetY ?? fallbackOffsetY;
1870
- const { vertical, horizontal } = parseAnchor(anchor);
1871
- const result = {};
1872
- const transforms = [];
1873
- if (horizontal === "left") {
1874
- result.left = offsetX;
1875
- } else if (horizontal === "right") {
1876
- result.right = offsetX;
1877
- } else {
1878
- result.left = "50%";
1879
- transforms.push("translateX(-50%)");
1880
- if (offsetX) transforms.push(`translateX(${offsetX}px)`);
1881
- }
1882
- if (vertical === "top") {
1883
- result.top = offsetY;
1884
- } else if (vertical === "bottom") {
1885
- result.bottom = offsetY;
1886
- } else {
1887
- result.top = "50%";
1888
- transforms.push("translateY(-50%)");
1889
- if (offsetY) transforms.push(`translateY(${offsetY}px)`);
1890
- }
1891
- if (transforms.length > 0) result.transform = transforms.join(" ");
1892
- return placement?.style ? { ...result, ...placement.style } : result;
1893
- }
1894
2018
  function Tsdraw(props) {
1895
2019
  const [systemTheme, setSystemTheme] = react.useState(() => {
1896
2020
  if (typeof window === "undefined") return "light";
@@ -1898,6 +2022,20 @@ function Tsdraw(props) {
1898
2022
  });
1899
2023
  const customTools = props.customTools ?? EMPTY_CUSTOM_TOOLS;
1900
2024
  const toolbarPartIds = props.uiOptions?.toolbar?.parts ?? DEFAULT_TOOLBAR_PARTS;
2025
+ const toolbarPlacement = props.uiOptions?.toolbar?.placement;
2026
+ const toolbarEdgeOffset = toolbarPlacement?.edgeOffset ?? 14;
2027
+ const isToolbarDraggable = props.uiOptions?.toolbar?.draggable === true;
2028
+ const shouldSaveDraggedToolbarPosition = props.uiOptions?.toolbar?.saveDraggedPosition === true;
2029
+ const disabledDragPositionsArray = props.uiOptions?.toolbar?.disabledDragPositions;
2030
+ const disabledDragPositionsSet = react.useMemo(
2031
+ () => disabledDragPositionsArray && disabledDragPositionsArray.length > 0 ? new Set(disabledDragPositionsArray) : void 0,
2032
+ [disabledDragPositionsArray]
2033
+ );
2034
+ const toolbarDraggedSessionKey = react.useMemo(
2035
+ () => `tsdraw-toolbar-pos-${props.persistenceKey ?? "default"}`,
2036
+ [props.persistenceKey]
2037
+ );
2038
+ const [draggedToolbarPosition, setDraggedToolbarPosition] = react.useState(null);
1901
2039
  const customToolMap = react.useMemo(
1902
2040
  () => new Map(customTools.map((customTool) => [customTool.id, customTool])),
1903
2041
  [customTools]
@@ -1982,9 +2120,35 @@ function Tsdraw(props) {
1982
2120
  onCameraChange: props.onCameraChange,
1983
2121
  onToolChange: props.onToolChange
1984
2122
  });
1985
- const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
1986
- const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
2123
+ const toolbarPlacementAnchor = draggedToolbarPosition?.anchor ?? toolbarPlacement?.anchor ?? "bottom-center";
2124
+ const effectiveToolbarPlacement = draggedToolbarPosition ? { anchor: draggedToolbarPosition.anchor, edgeOffset: toolbarEdgeOffset, style: toolbarPlacement?.style } : toolbarPlacement;
2125
+ const toolbarPlacementStyle = resolvePlacementStyle(effectiveToolbarPlacement, "bottom-center", 14);
2126
+ const toolbarOrientation = resolveOrientation(toolbarPlacementAnchor);
2127
+ const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8);
1987
2128
  const isToolbarHidden = props.uiOptions?.toolbar?.hide === true;
2129
+ react.useEffect(() => {
2130
+ if (!isToolbarDraggable || !shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2131
+ try {
2132
+ const rawPosition = window.sessionStorage.getItem(toolbarDraggedSessionKey);
2133
+ if (!rawPosition) return;
2134
+ const parsedPosition = JSON.parse(rawPosition);
2135
+ if (typeof parsedPosition.anchor !== "string") return;
2136
+ setDraggedToolbarPosition({ anchor: parsedPosition.anchor });
2137
+ } catch {
2138
+ }
2139
+ }, [isToolbarDraggable, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
2140
+ react.useEffect(() => {
2141
+ if (isToolbarDraggable) return;
2142
+ setDraggedToolbarPosition(null);
2143
+ }, [isToolbarDraggable]);
2144
+ const handleToolbarDragEnd = react.useCallback((payload) => {
2145
+ const containerNode = containerRef.current;
2146
+ if (!containerNode) return;
2147
+ const nextPosition = calculateSnap(payload, containerNode.getBoundingClientRect(), disabledDragPositionsSet);
2148
+ setDraggedToolbarPosition(nextPosition);
2149
+ if (!shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2150
+ window.sessionStorage.setItem(toolbarDraggedSessionKey, JSON.stringify(nextPosition));
2151
+ }, [containerRef, disabledDragPositionsSet, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
1988
2152
  const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true || props.readOnly === true;
1989
2153
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
1990
2154
  const defaultToolOverlay = /* @__PURE__ */ jsxRuntime.jsx(
@@ -2137,7 +2301,7 @@ function Tsdraw(props) {
2137
2301
  position: "absolute",
2138
2302
  zIndex: 130,
2139
2303
  pointerEvents: "all",
2140
- ...resolvePlacementStyle(customElement.placement, "top-left", 8, 8)
2304
+ ...resolvePlacementStyle(customElement.placement, "top-left", 8)
2141
2305
  },
2142
2306
  children: customElement.render({ currentTool, setTool, applyDrawStyle })
2143
2307
  },
@@ -2148,9 +2312,12 @@ function Tsdraw(props) {
2148
2312
  {
2149
2313
  parts: toolbarParts,
2150
2314
  style: toolbarPlacementStyle,
2315
+ orientation: toolbarOrientation,
2151
2316
  currentTool: isPersistenceReady ? currentTool : null,
2152
2317
  onToolChange: setTool,
2153
- disabled: !isPersistenceReady
2318
+ disabled: !isPersistenceReady,
2319
+ draggable: isToolbarDraggable,
2320
+ onDragEnd: handleToolbarDragEnd
2154
2321
  }
2155
2322
  ) : null
2156
2323
  ]