@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.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(
@@ -1613,6 +1775,10 @@ function useTsdrawCanvasController(options = {}) {
1613
1775
  if (ignorePersistenceChanges) return;
1614
1776
  schedulePersist();
1615
1777
  });
1778
+ const cleanupRenderRequest = editor.onRequestRender(() => {
1779
+ render();
1780
+ refreshSelectionBounds(editor);
1781
+ });
1616
1782
  resize();
1617
1783
  const ro = new ResizeObserver(resize);
1618
1784
  ro.observe(container);
@@ -1681,6 +1847,7 @@ function useTsdrawCanvasController(options = {}) {
1681
1847
  schedulePersistRef.current = null;
1682
1848
  cleanupEditorListener();
1683
1849
  cleanupHistoryListener();
1850
+ cleanupRenderRequest();
1684
1851
  disposeMount?.();
1685
1852
  ro.disconnect();
1686
1853
  canvas.removeEventListener("pointerdown", handlePointerDown);
@@ -1850,47 +2017,9 @@ var DEFAULT_TOOL_LABELS = {
1850
2017
  eraser: "Eraser",
1851
2018
  hand: "Hand"
1852
2019
  };
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
2020
  function isToolbarAction(item) {
1864
2021
  return item === "undo" || item === "redo";
1865
2022
  }
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
2023
  function Tsdraw(props) {
1895
2024
  const [systemTheme, setSystemTheme] = react.useState(() => {
1896
2025
  if (typeof window === "undefined") return "light";
@@ -1898,6 +2027,20 @@ function Tsdraw(props) {
1898
2027
  });
1899
2028
  const customTools = props.customTools ?? EMPTY_CUSTOM_TOOLS;
1900
2029
  const toolbarPartIds = props.uiOptions?.toolbar?.parts ?? DEFAULT_TOOLBAR_PARTS;
2030
+ const toolbarPlacement = props.uiOptions?.toolbar?.placement;
2031
+ const toolbarEdgeOffset = toolbarPlacement?.edgeOffset ?? 14;
2032
+ const isToolbarDraggable = props.uiOptions?.toolbar?.draggable === true;
2033
+ const shouldSaveDraggedToolbarPosition = props.uiOptions?.toolbar?.saveDraggedPosition === true;
2034
+ const disabledDragPositionsArray = props.uiOptions?.toolbar?.disabledDragPositions;
2035
+ const disabledDragPositionsSet = react.useMemo(
2036
+ () => disabledDragPositionsArray && disabledDragPositionsArray.length > 0 ? new Set(disabledDragPositionsArray) : void 0,
2037
+ [disabledDragPositionsArray]
2038
+ );
2039
+ const toolbarDraggedSessionKey = react.useMemo(
2040
+ () => `tsdraw-toolbar-pos-${props.persistenceKey ?? "default"}`,
2041
+ [props.persistenceKey]
2042
+ );
2043
+ const [draggedToolbarPosition, setDraggedToolbarPosition] = react.useState(null);
1901
2044
  const customToolMap = react.useMemo(
1902
2045
  () => new Map(customTools.map((customTool) => [customTool.id, customTool])),
1903
2046
  [customTools]
@@ -1982,9 +2125,35 @@ function Tsdraw(props) {
1982
2125
  onCameraChange: props.onCameraChange,
1983
2126
  onToolChange: props.onToolChange
1984
2127
  });
1985
- const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
1986
- const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
2128
+ const toolbarPlacementAnchor = draggedToolbarPosition?.anchor ?? toolbarPlacement?.anchor ?? "bottom-center";
2129
+ const effectiveToolbarPlacement = draggedToolbarPosition ? { anchor: draggedToolbarPosition.anchor, edgeOffset: toolbarEdgeOffset, style: toolbarPlacement?.style } : toolbarPlacement;
2130
+ const toolbarPlacementStyle = resolvePlacementStyle(effectiveToolbarPlacement, "bottom-center", 14);
2131
+ const toolbarOrientation = resolveOrientation(toolbarPlacementAnchor);
2132
+ const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8);
1987
2133
  const isToolbarHidden = props.uiOptions?.toolbar?.hide === true;
2134
+ react.useEffect(() => {
2135
+ if (!isToolbarDraggable || !shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2136
+ try {
2137
+ const rawPosition = window.sessionStorage.getItem(toolbarDraggedSessionKey);
2138
+ if (!rawPosition) return;
2139
+ const parsedPosition = JSON.parse(rawPosition);
2140
+ if (typeof parsedPosition.anchor !== "string") return;
2141
+ setDraggedToolbarPosition({ anchor: parsedPosition.anchor });
2142
+ } catch {
2143
+ }
2144
+ }, [isToolbarDraggable, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
2145
+ react.useEffect(() => {
2146
+ if (isToolbarDraggable) return;
2147
+ setDraggedToolbarPosition(null);
2148
+ }, [isToolbarDraggable]);
2149
+ const handleToolbarDragEnd = react.useCallback((payload) => {
2150
+ const containerNode = containerRef.current;
2151
+ if (!containerNode) return;
2152
+ const nextPosition = calculateSnap(payload, containerNode.getBoundingClientRect(), disabledDragPositionsSet);
2153
+ setDraggedToolbarPosition(nextPosition);
2154
+ if (!shouldSaveDraggedToolbarPosition || typeof window === "undefined") return;
2155
+ window.sessionStorage.setItem(toolbarDraggedSessionKey, JSON.stringify(nextPosition));
2156
+ }, [containerRef, disabledDragPositionsSet, shouldSaveDraggedToolbarPosition, toolbarDraggedSessionKey]);
1988
2157
  const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true || props.readOnly === true;
1989
2158
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
1990
2159
  const defaultToolOverlay = /* @__PURE__ */ jsxRuntime.jsx(
@@ -2137,7 +2306,7 @@ function Tsdraw(props) {
2137
2306
  position: "absolute",
2138
2307
  zIndex: 130,
2139
2308
  pointerEvents: "all",
2140
- ...resolvePlacementStyle(customElement.placement, "top-left", 8, 8)
2309
+ ...resolvePlacementStyle(customElement.placement, "top-left", 8)
2141
2310
  },
2142
2311
  children: customElement.render({ currentTool, setTool, applyDrawStyle })
2143
2312
  },
@@ -2148,9 +2317,12 @@ function Tsdraw(props) {
2148
2317
  {
2149
2318
  parts: toolbarParts,
2150
2319
  style: toolbarPlacementStyle,
2320
+ orientation: toolbarOrientation,
2151
2321
  currentTool: isPersistenceReady ? currentTool : null,
2152
2322
  onToolChange: setTool,
2153
- disabled: !isPersistenceReady
2323
+ disabled: !isPersistenceReady,
2324
+ draggable: isToolbarDraggable,
2325
+ onDragEnd: handleToolbarDragEnd
2154
2326
  }
2155
2327
  ) : null
2156
2328
  ]