@tsdraw/react 0.9.4 → 0.9.5

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
@@ -60,6 +60,7 @@ interface TsdrawCursorContext {
60
60
  isMovingSelection: boolean;
61
61
  isResizingSelection: boolean;
62
62
  isRotatingSelection: boolean;
63
+ isDraggingVertex: boolean;
63
64
  }
64
65
  interface TsdrawToolOverlayState {
65
66
  visible: boolean;
package/dist/index.d.ts CHANGED
@@ -60,6 +60,7 @@ interface TsdrawCursorContext {
60
60
  isMovingSelection: boolean;
61
61
  isResizingSelection: boolean;
62
62
  isRotatingSelection: boolean;
63
+ isDraggingVertex: boolean;
63
64
  }
64
65
  interface TsdrawToolOverlayState {
65
66
  visible: boolean;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
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';
3
+ import { DEFAULT_COLORS, renderCanvasBackground, getSelectionBoundsPage, buildTransformSnapshots, Editor, getVertexHandlePagePositions, pageToScreen, STROKE_WIDTHS, ERASER_MARGIN, resolveThemeColor, findVertexHit, getTopShapeAtPoint, buildStartPositions, applyRotation, applyResize, applyMove, applyVertexDrag, 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';
5
5
 
6
6
  // src/components/TsdrawCanvas.tsx
@@ -8,6 +8,7 @@ function SelectionOverlay({
8
8
  selectionBrush,
9
9
  selectionBounds,
10
10
  selectionRotationDeg,
11
+ vertexHandleScreenPositions,
11
12
  currentTool,
12
13
  selectedCount,
13
14
  onRotatePointerDown,
@@ -35,7 +36,8 @@ function SelectionOverlay({
35
36
  top: selectionBounds.top,
36
37
  width: selectionBounds.width,
37
38
  height: selectionBounds.height,
38
- transform: `rotate(${selectionRotationDeg}deg)`
39
+ transform: `rotate(${selectionRotationDeg}deg)`,
40
+ transformOrigin: "center center"
39
41
  },
40
42
  children: [
41
43
  /* @__PURE__ */ jsx("div", { className: "tsdraw-selection-bounds" }),
@@ -93,7 +95,15 @@ function SelectionOverlay({
93
95
  ] })
94
96
  ]
95
97
  }
96
- )
98
+ ),
99
+ currentTool === "select" && vertexHandleScreenPositions.map((pos, index) => /* @__PURE__ */ jsx(
100
+ "div",
101
+ {
102
+ className: "tsdraw-vertex-handle",
103
+ style: { position: "absolute", left: pos.left, top: pos.top, transform: "translate(-50%, -50%)", pointerEvents: "none", zIndex: 95 }
104
+ },
105
+ `vertex-${index.toString()}`
106
+ ))
97
107
  ] });
98
108
  }
99
109
  function parseAnchor(anchor) {
@@ -452,6 +462,7 @@ function getCanvasCursor(currentTool, state) {
452
462
  if (state.isRotatingSelection) return "grabbing";
453
463
  if (state.isResizingSelection) return "nwse-resize";
454
464
  if (state.isMovingSelection) return "grabbing";
465
+ if (state.isDraggingVertex) return "grabbing";
455
466
  if (state.isHoveringSelectionBounds) return "move";
456
467
  return "default";
457
468
  }
@@ -894,6 +905,16 @@ function toScreenRect(editor, bounds) {
894
905
  height: Math.max(0, maxY - minY)
895
906
  };
896
907
  }
908
+ function selectionCenteredOnRotation(editor, startBoundsPage, centerPage) {
909
+ const startScreen = toScreenRect(editor, startBoundsPage);
910
+ const centerScreen = pageToScreen(editor.viewport, centerPage.x, centerPage.y);
911
+ return {
912
+ left: centerScreen.x - startScreen.width / 2,
913
+ top: centerScreen.y - startScreen.height / 2,
914
+ width: startScreen.width,
915
+ height: startScreen.height
916
+ };
917
+ }
897
918
  function resolveDrawColor(colorStyle, theme) {
898
919
  return resolveThemeColor(colorStyle, theme);
899
920
  }
@@ -954,7 +975,8 @@ function useTsdrawCanvasController(options = {}) {
954
975
  center: null,
955
976
  startAngle: 0,
956
977
  startSelectionRotationDeg: 0,
957
- startShapes: /* @__PURE__ */ new Map()
978
+ startShapes: /* @__PURE__ */ new Map(),
979
+ startBoundsPage: null
958
980
  });
959
981
  const selectDragRef = useRef({
960
982
  mode: "none",
@@ -976,6 +998,8 @@ function useTsdrawCanvasController(options = {}) {
976
998
  const [isMovingSelection, setIsMovingSelection] = useState(false);
977
999
  const [isResizingSelection, setIsResizingSelection] = useState(false);
978
1000
  const [isRotatingSelection, setIsRotatingSelection] = useState(false);
1001
+ const [isDraggingVertex, setIsDraggingVertex] = useState(false);
1002
+ const [vertexHandleScreenPositions, setVertexHandleScreenPositions] = useState([]);
979
1003
  const [canUndo, setCanUndo] = useState(false);
980
1004
  const [canRedo, setCanRedo] = useState(false);
981
1005
  const [isPersistenceReady, setIsPersistenceReady] = useState(!options.persistenceKey);
@@ -1069,9 +1093,12 @@ function useTsdrawCanvasController(options = {}) {
1069
1093
  setIsMovingSelection(false);
1070
1094
  setIsResizingSelection(false);
1071
1095
  setIsRotatingSelection(false);
1096
+ setIsDraggingVertex(false);
1072
1097
  selectDragRef.current.mode = "none";
1098
+ selectDragRef.current.vertexRefs = void 0;
1099
+ selectDragRef.current.vertexSnapshots = void 0;
1073
1100
  resizeRef.current = { handle: null, startBounds: null, startShapes: /* @__PURE__ */ new Map(), cursorHandleOffset: { x: 0, y: 0 } };
1074
- rotateRef.current = { center: null, startAngle: 0, startSelectionRotationDeg: 0, startShapes: /* @__PURE__ */ new Map() };
1101
+ rotateRef.current = { center: null, startAngle: 0, startSelectionRotationDeg: 0, startShapes: /* @__PURE__ */ new Map(), startBoundsPage: null };
1075
1102
  }, []);
1076
1103
  const handleResizePointerDown = useCallback(
1077
1104
  (e, handle) => {
@@ -1125,8 +1152,10 @@ function useTsdrawCanvasController(options = {}) {
1125
1152
  center,
1126
1153
  startAngle: Math.atan2(p.y - center.y, p.x - center.x),
1127
1154
  startSelectionRotationDeg: selectionRotationRef.current,
1128
- startShapes: buildTransformSnapshots(editor, selectedShapeIdsRef.current)
1155
+ startShapes: buildTransformSnapshots(editor, selectedShapeIdsRef.current),
1156
+ startBoundsPage: bounds
1129
1157
  };
1158
+ setSelectionBounds(selectionCenteredOnRotation(editor, bounds, center));
1130
1159
  isPointerActiveRef.current = true;
1131
1160
  activePointerIdsRef.current.add(e.pointerId);
1132
1161
  canvas.setPointerCapture(e.pointerId);
@@ -1392,6 +1421,30 @@ function useTsdrawCanvasController(options = {}) {
1392
1421
  const pressure = (first.pressure ?? 0.5) * pressureSensitivity;
1393
1422
  const isPen = first.pointerType === "pen" || hasRealPressure(first.pressure);
1394
1423
  if (currentToolRef.current === "select") {
1424
+ if (!readOnlyRef.current) {
1425
+ const zoom = editor.viewport.zoom;
1426
+ const marginPage = 12 / zoom;
1427
+ const clusterTolerance = 20 / zoom;
1428
+ const vertexHit = findVertexHit(editor, { x, y }, marginPage, clusterTolerance);
1429
+ if (vertexHit) {
1430
+ const involvedIds = [...new Set(vertexHit.refs.map((r) => r.shapeId))];
1431
+ selectDragRef.current = {
1432
+ mode: "vertex",
1433
+ startPage: { x, y },
1434
+ currentPage: { x, y },
1435
+ startPositions: /* @__PURE__ */ new Map(),
1436
+ additive: false,
1437
+ initialSelection: [...selectedShapeIdsRef.current],
1438
+ vertexRefs: vertexHit.refs,
1439
+ vertexSnapshots: vertexHit.snapshots
1440
+ };
1441
+ setIsDraggingVertex(true);
1442
+ setSelectedShapeIds(involvedIds);
1443
+ selectedShapeIdsRef.current = involvedIds;
1444
+ refreshSelectionBounds(editor, involvedIds);
1445
+ return;
1446
+ }
1447
+ }
1395
1448
  const hit = getTopShapeAtPoint(editor, { x, y });
1396
1449
  const isHitSelected = !!(hit && selectedShapeIdsRef.current.includes(hit.id));
1397
1450
  const isInsideSelectionBounds = (() => {
@@ -1407,7 +1460,8 @@ function useTsdrawCanvasController(options = {}) {
1407
1460
  currentPage: { x, y },
1408
1461
  startPositions: buildStartPositions(editor, selectedShapeIdsRef.current),
1409
1462
  additive: false,
1410
- initialSelection: [...selectedShapeIdsRef.current]
1463
+ initialSelection: [...selectedShapeIdsRef.current],
1464
+ moveFromEmptyInsideBounds: isInsideSelectionBounds && !hit
1411
1465
  };
1412
1466
  setIsMovingSelection(true);
1413
1467
  return;
@@ -1463,13 +1517,14 @@ function useTsdrawCanvasController(options = {}) {
1463
1517
  const mode = selectDragRef.current.mode;
1464
1518
  const { x: px, y: py } = editor.input.getCurrentPagePoint();
1465
1519
  if (mode === "rotate") {
1466
- const { center, startAngle, startSelectionRotationDeg, startShapes } = rotateRef.current;
1467
- if (!center) return;
1520
+ const { center, startAngle, startSelectionRotationDeg, startShapes, startBoundsPage } = rotateRef.current;
1521
+ if (!center || !startBoundsPage) return;
1468
1522
  const angle = Math.atan2(py - center.y, px - center.x);
1469
1523
  const delta = angle - startAngle;
1470
1524
  setSelectionRotationDeg(startSelectionRotationDeg + delta * 180 / Math.PI);
1471
1525
  applyRotation(editor, startShapes, center, delta);
1472
1526
  render();
1527
+ setSelectionBounds(selectionCenteredOnRotation(editor, startBoundsPage, center));
1473
1528
  return;
1474
1529
  }
1475
1530
  if (mode === "resize") {
@@ -1487,6 +1542,20 @@ function useTsdrawCanvasController(options = {}) {
1487
1542
  refreshSelectionBounds(editor);
1488
1543
  return;
1489
1544
  }
1545
+ if (mode === "vertex") {
1546
+ const drag = selectDragRef.current;
1547
+ const refs = drag.vertexRefs;
1548
+ const snapshots = drag.vertexSnapshots;
1549
+ if (refs && snapshots) {
1550
+ applyVertexDrag(editor, snapshots, refs, {
1551
+ x: px - drag.startPage.x,
1552
+ y: py - drag.startPage.y
1553
+ });
1554
+ render();
1555
+ refreshSelectionBounds(editor);
1556
+ }
1557
+ return;
1558
+ }
1490
1559
  if (mode === "marquee") {
1491
1560
  selectDragRef.current.currentPage = { x: px, y: py };
1492
1561
  const pageRect = normalizeSelectionBounds(selectDragRef.current.startPage, selectDragRef.current.currentPage);
@@ -1524,7 +1593,7 @@ function useTsdrawCanvasController(options = {}) {
1524
1593
  setIsRotatingSelection(false);
1525
1594
  selectDragRef.current.mode = "none";
1526
1595
  setSelectionRotationDeg(0);
1527
- rotateRef.current = { center: null, startAngle: 0, startSelectionRotationDeg: 0, startShapes: /* @__PURE__ */ new Map() };
1596
+ rotateRef.current = { center: null, startAngle: 0, startSelectionRotationDeg: 0, startShapes: /* @__PURE__ */ new Map(), startBoundsPage: null };
1528
1597
  render();
1529
1598
  refreshSelectionBounds(editor);
1530
1599
  editor.endHistoryEntry();
@@ -1542,6 +1611,24 @@ function useTsdrawCanvasController(options = {}) {
1542
1611
  if (drag.mode === "move") {
1543
1612
  setIsMovingSelection(false);
1544
1613
  selectDragRef.current.mode = "none";
1614
+ const distSq = (x - drag.startPage.x) ** 2 + (y - drag.startPage.y) ** 2;
1615
+ if (drag.moveFromEmptyInsideBounds && distSq < 4) {
1616
+ setSelectedShapeIds([]);
1617
+ selectedShapeIdsRef.current = [];
1618
+ setSelectionBounds(null);
1619
+ } else {
1620
+ refreshSelectionBounds(editor);
1621
+ }
1622
+ selectDragRef.current.moveFromEmptyInsideBounds = void 0;
1623
+ render();
1624
+ editor.endHistoryEntry();
1625
+ return;
1626
+ }
1627
+ if (drag.mode === "vertex") {
1628
+ setIsDraggingVertex(false);
1629
+ selectDragRef.current.mode = "none";
1630
+ selectDragRef.current.vertexRefs = void 0;
1631
+ selectDragRef.current.vertexSnapshots = void 0;
1545
1632
  render();
1546
1633
  refreshSelectionBounds(editor);
1547
1634
  editor.endHistoryEntry();
@@ -1620,9 +1707,21 @@ function useTsdrawCanvasController(options = {}) {
1620
1707
  editor.input.pointerUp();
1621
1708
  if (currentToolRef.current === "select") {
1622
1709
  const drag = selectDragRef.current;
1623
- if (drag.mode === "rotate") setIsRotatingSelection(false);
1710
+ if (drag.mode === "rotate") {
1711
+ setIsRotatingSelection(false);
1712
+ setSelectionRotationDeg(0);
1713
+ rotateRef.current = { center: null, startAngle: 0, startSelectionRotationDeg: 0, startShapes: /* @__PURE__ */ new Map(), startBoundsPage: null };
1714
+ }
1624
1715
  if (drag.mode === "resize") setIsResizingSelection(false);
1625
- if (drag.mode === "move") setIsMovingSelection(false);
1716
+ if (drag.mode === "move") {
1717
+ setIsMovingSelection(false);
1718
+ selectDragRef.current.moveFromEmptyInsideBounds = void 0;
1719
+ }
1720
+ if (drag.mode === "vertex") {
1721
+ setIsDraggingVertex(false);
1722
+ selectDragRef.current.vertexRefs = void 0;
1723
+ selectDragRef.current.vertexSnapshots = void 0;
1724
+ }
1626
1725
  if (drag.mode === "marquee") setSelectionBrush(null);
1627
1726
  if (drag.mode !== "none") {
1628
1727
  selectDragRef.current.mode = "none";
@@ -1959,10 +2058,25 @@ function useTsdrawCanvasController(options = {}) {
1959
2058
  }, [render]);
1960
2059
  const isHoveringSelectionBounds = isPointerInsideCanvas && currentTool === "select" && selectedShapeIds.length > 0 && selectionBounds != null && pointerScreenPoint.x >= selectionBounds.left && pointerScreenPoint.x <= selectionBounds.left + selectionBounds.width && pointerScreenPoint.y >= selectionBounds.top && pointerScreenPoint.y <= selectionBounds.top + selectionBounds.height;
1961
2060
  const showToolOverlay = isPointerInsideCanvas && (currentTool === "pen" || currentTool === "eraser");
2061
+ useEffect(() => {
2062
+ const editor = editorRef.current;
2063
+ if (!editor || currentTool !== "select" || selectedShapeIds.length === 0) {
2064
+ setVertexHandleScreenPositions([]);
2065
+ return;
2066
+ }
2067
+ const pagePts = getVertexHandlePagePositions(editor, selectedShapeIds);
2068
+ setVertexHandleScreenPositions(
2069
+ pagePts.map((pg) => {
2070
+ const s = pageToScreen(editor.viewport, pg.x, pg.y);
2071
+ return { left: s.x, top: s.y };
2072
+ })
2073
+ );
2074
+ }, [currentTool, selectedShapeIds, selectionBounds, selectionRotationDeg, isPersistenceReady]);
1962
2075
  const canvasCursor = getCanvasCursor(currentTool, {
1963
2076
  isMovingSelection,
1964
2077
  isResizingSelection,
1965
2078
  isRotatingSelection,
2079
+ isDraggingVertex,
1966
2080
  isHoveringSelectionBounds,
1967
2081
  showToolOverlay
1968
2082
  });
@@ -1972,7 +2086,8 @@ function useTsdrawCanvasController(options = {}) {
1972
2086
  showToolOverlay,
1973
2087
  isMovingSelection,
1974
2088
  isResizingSelection,
1975
- isRotatingSelection
2089
+ isRotatingSelection,
2090
+ isDraggingVertex
1976
2091
  };
1977
2092
  const toolOverlay = {
1978
2093
  visible: showToolOverlay,
@@ -1995,6 +2110,7 @@ function useTsdrawCanvasController(options = {}) {
1995
2110
  selectionBrush,
1996
2111
  selectionBounds,
1997
2112
  selectionRotationDeg,
2113
+ vertexHandleScreenPositions,
1998
2114
  canvasCursor,
1999
2115
  cursorContext,
2000
2116
  toolOverlay,
@@ -2105,6 +2221,7 @@ function Tsdraw(props) {
2105
2221
  selectionBrush,
2106
2222
  selectionBounds,
2107
2223
  selectionRotationDeg,
2224
+ vertexHandleScreenPositions,
2108
2225
  canvasCursor: defaultCanvasCursor,
2109
2226
  cursorContext,
2110
2227
  toolOverlay,
@@ -2286,6 +2403,7 @@ function Tsdraw(props) {
2286
2403
  selectionBrush,
2287
2404
  selectionBounds,
2288
2405
  selectionRotationDeg,
2406
+ vertexHandleScreenPositions,
2289
2407
  currentTool,
2290
2408
  selectedCount: selectedShapeIds.length,
2291
2409
  onRotatePointerDown: handleRotatePointerDown,