@fieldnotes/core 0.34.0 → 0.36.0

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
@@ -605,6 +605,36 @@ function distSqToSegment(p, a, b) {
605
605
  const dy = p.y - (a.y + t * aby);
606
606
  return dx * dx + dy * dy;
607
607
  }
608
+ function rotatePoint(p, center2, angle) {
609
+ if (angle === 0) return p;
610
+ const cos = Math.cos(angle);
611
+ const sin = Math.sin(angle);
612
+ const dx = p.x - center2.x;
613
+ const dy = p.y - center2.y;
614
+ return { x: center2.x + dx * cos - dy * sin, y: center2.y + dx * sin + dy * cos };
615
+ }
616
+ function rotatedAABB(bounds, angle) {
617
+ if (angle === 0) return bounds;
618
+ const c = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
619
+ const corners = [
620
+ { x: bounds.x, y: bounds.y },
621
+ { x: bounds.x + bounds.w, y: bounds.y },
622
+ { x: bounds.x + bounds.w, y: bounds.y + bounds.h },
623
+ { x: bounds.x, y: bounds.y + bounds.h }
624
+ ].map((p) => rotatePoint(p, c, angle));
625
+ const xs = corners.map((p) => p.x);
626
+ const ys = corners.map((p) => p.y);
627
+ const minX = Math.min(...xs);
628
+ const minY = Math.min(...ys);
629
+ return { x: minX, y: minY, w: Math.max(...xs) - minX, h: Math.max(...ys) - minY };
630
+ }
631
+ function normalizeAngle(angle) {
632
+ const twoPi = Math.PI * 2;
633
+ let a = angle % twoPi;
634
+ if (a <= -Math.PI) a += twoPi;
635
+ else if (a > Math.PI) a -= twoPi;
636
+ return a;
637
+ }
608
638
 
609
639
  // src/elements/arrow-geometry.ts
610
640
  function getArrowControlPoint(from, to, bend) {
@@ -961,6 +991,14 @@ var KeyboardActions = class {
961
991
  }
962
992
  this.pasteCount = 0;
963
993
  }
994
+ cut() {
995
+ if (this.deps.isToolActive()) return;
996
+ this.copy();
997
+ this.deleteSelected();
998
+ }
999
+ hasClipboard() {
1000
+ return this.clipboard.length > 0;
1001
+ }
964
1002
  paste() {
965
1003
  if (this.deps.isToolActive()) return;
966
1004
  this.flushPendingNudge();
@@ -1029,6 +1067,10 @@ var KeyboardActions = class {
1029
1067
  if (this.deps.isToolActive()) return;
1030
1068
  this.deps.ungroup?.();
1031
1069
  }
1070
+ toggleLock() {
1071
+ if (this.deps.isToolActive()) return;
1072
+ this.deps.toggleLock?.();
1073
+ }
1032
1074
  zOrder(operation) {
1033
1075
  if (this.deps.isToolActive()) return;
1034
1076
  this.flushPendingNudge();
@@ -1130,6 +1172,8 @@ var DEFAULT_BINDINGS = [
1130
1172
  ["zoom-reset", ["mod+0"]],
1131
1173
  ["group", ["mod+g"]],
1132
1174
  ["ungroup", ["mod+shift+g"]],
1175
+ ["cut", ["mod+x"]],
1176
+ ["toggle-lock", ["mod+shift+l"]],
1133
1177
  ["nudge-left", ["arrowleft"]],
1134
1178
  ["nudge-right", ["arrowright"]],
1135
1179
  ["nudge-up", ["arrowup"]],
@@ -1268,6 +1312,7 @@ var ShortcutMap = class {
1268
1312
  var ZOOM_SENSITIVITY = 1e-3;
1269
1313
  var ZOOM_STEP = 1.2;
1270
1314
  var MIDDLE_BUTTON = 1;
1315
+ var LONG_PRESS_MS = 500;
1271
1316
  var NUDGE_DELTAS = {
1272
1317
  "nudge-left": [-1, 0],
1273
1318
  "nudge-right": [1, 0],
@@ -1291,8 +1336,10 @@ var InputHandler = class {
1291
1336
  fitToContent: options.fitToContent,
1292
1337
  group: options.group,
1293
1338
  ungroup: options.ungroup,
1339
+ toggleLock: options.toggleLock,
1294
1340
  getLastPointerWorld: () => this.lastPointerWorld()
1295
1341
  });
1342
+ this.openContextMenu = options.openContextMenu;
1296
1343
  this.shortcutMap = new ShortcutMap(options.shortcuts?.bindings);
1297
1344
  this.scope = options.shortcuts?.scope ?? "focus";
1298
1345
  this.element.style.touchAction = "none";
@@ -1316,10 +1363,13 @@ var InputHandler = class {
1316
1363
  lastPointerEvent = null;
1317
1364
  inputFilter = new InputFilter();
1318
1365
  deferredDown = null;
1366
+ longPressTimer = null;
1367
+ longPressStart = null;
1319
1368
  abortController = new AbortController();
1320
1369
  actions;
1321
1370
  shortcutMap;
1322
1371
  scope;
1372
+ openContextMenu;
1323
1373
  setToolManager(toolManager, toolContext) {
1324
1374
  this.toolManager = toolManager;
1325
1375
  this.toolContext = toolContext;
@@ -1334,6 +1384,7 @@ var InputHandler = class {
1334
1384
  this.actions.dispose();
1335
1385
  this.abortController.abort();
1336
1386
  this.inputFilter.reset();
1387
+ this.cancelLongPress();
1337
1388
  this.deferredDown = null;
1338
1389
  this.lastPointerEvent = null;
1339
1390
  if (this.scope === "focus") {
@@ -1349,6 +1400,7 @@ var InputHandler = class {
1349
1400
  this.element.addEventListener("pointerup", this.onPointerUp, opts);
1350
1401
  this.element.addEventListener("pointerleave", this.onPointerLeave, opts);
1351
1402
  this.element.addEventListener("pointercancel", this.onPointerUp, opts);
1403
+ this.element.addEventListener("contextmenu", this.onContextMenu, opts);
1352
1404
  window.addEventListener("keydown", this.onKeyDown, opts);
1353
1405
  window.addEventListener("keyup", this.onKeyUp, opts);
1354
1406
  }
@@ -1377,11 +1429,13 @@ var InputHandler = class {
1377
1429
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
1378
1430
  this.element.setPointerCapture?.(e.pointerId);
1379
1431
  if (this.activePointers.size === 2) {
1432
+ this.cancelLongPress();
1380
1433
  this.startPinch();
1381
1434
  this.cancelToolIfActive(e);
1382
1435
  return;
1383
1436
  }
1384
1437
  if (e.button === MIDDLE_BUTTON || e.button === 0 && this.spaceHeld) {
1438
+ this.cancelLongPress();
1385
1439
  this.isPanning = true;
1386
1440
  this.lastPointer = { x: e.clientX, y: e.clientY };
1387
1441
  return;
@@ -1391,6 +1445,7 @@ var InputHandler = class {
1391
1445
  if (result.action === "suppress") return;
1392
1446
  if (result.action === "defer") {
1393
1447
  this.deferredDown = e;
1448
+ this.startLongPress(e);
1394
1449
  return;
1395
1450
  }
1396
1451
  this.dispatchToolDown(e);
@@ -1419,6 +1474,7 @@ var InputHandler = class {
1419
1474
  } else if (this.deferredDown) {
1420
1475
  const result = this.inputFilter.filterMove(e);
1421
1476
  if (result.action === "dispatch") {
1477
+ this.cancelLongPress();
1422
1478
  this.dispatchToolDown(this.deferredDown);
1423
1479
  this.deferredDown = null;
1424
1480
  this.dispatchToolMove(e);
@@ -1428,6 +1484,7 @@ var InputHandler = class {
1428
1484
  }
1429
1485
  };
1430
1486
  onPointerUp = (e) => {
1487
+ this.cancelLongPress();
1431
1488
  try {
1432
1489
  this.element.releasePointerCapture(e.pointerId);
1433
1490
  } catch {
@@ -1480,74 +1537,82 @@ var InputHandler = class {
1480
1537
  runAction(action, e) {
1481
1538
  switch (action) {
1482
1539
  case "delete":
1483
- e.preventDefault();
1540
+ e?.preventDefault();
1484
1541
  this.actions.deleteSelected();
1485
1542
  return;
1486
1543
  case "deselect":
1487
1544
  this.actions.deselect();
1488
1545
  return;
1489
1546
  case "undo":
1490
- e.preventDefault();
1547
+ e?.preventDefault();
1491
1548
  this.actions.undo();
1492
1549
  return;
1493
1550
  case "redo":
1494
- e.preventDefault();
1551
+ e?.preventDefault();
1495
1552
  this.actions.redo();
1496
1553
  return;
1497
1554
  case "select-all":
1498
- e.preventDefault();
1555
+ e?.preventDefault();
1499
1556
  this.actions.selectAll();
1500
1557
  return;
1501
1558
  case "copy":
1502
- e.preventDefault();
1559
+ e?.preventDefault();
1503
1560
  this.actions.copy();
1504
1561
  return;
1505
1562
  case "paste":
1506
- e.preventDefault();
1563
+ e?.preventDefault();
1507
1564
  this.actions.paste();
1508
1565
  return;
1509
1566
  case "duplicate":
1510
- e.preventDefault();
1567
+ e?.preventDefault();
1511
1568
  this.actions.duplicate();
1512
1569
  return;
1513
1570
  case "z-forward":
1514
- e.preventDefault();
1571
+ e?.preventDefault();
1515
1572
  this.actions.zOrder("forward");
1516
1573
  return;
1517
1574
  case "z-backward":
1518
- e.preventDefault();
1575
+ e?.preventDefault();
1519
1576
  this.actions.zOrder("backward");
1520
1577
  return;
1521
1578
  case "z-front":
1522
- e.preventDefault();
1579
+ e?.preventDefault();
1523
1580
  this.actions.zOrder("front");
1524
1581
  return;
1525
1582
  case "z-back":
1526
- e.preventDefault();
1583
+ e?.preventDefault();
1527
1584
  this.actions.zOrder("back");
1528
1585
  return;
1529
1586
  case "zoom-fit":
1530
- e.preventDefault();
1587
+ e?.preventDefault();
1531
1588
  this.actions.zoomToFit();
1532
1589
  return;
1533
1590
  case "group":
1534
- e.preventDefault();
1591
+ e?.preventDefault();
1535
1592
  this.actions.group();
1536
1593
  return;
1537
1594
  case "ungroup":
1538
- e.preventDefault();
1595
+ e?.preventDefault();
1539
1596
  this.actions.ungroup();
1540
1597
  return;
1598
+ case "cut":
1599
+ e?.preventDefault();
1600
+ this.actions.cut();
1601
+ return;
1602
+ case "toggle-lock":
1603
+ e?.preventDefault();
1604
+ this.actions.toggleLock();
1605
+ return;
1541
1606
  case "zoom-in":
1542
- e.preventDefault();
1607
+ e?.preventDefault();
1543
1608
  this.zoomByFactor(ZOOM_STEP);
1544
1609
  return;
1545
1610
  case "zoom-out":
1546
- e.preventDefault();
1611
+ e?.preventDefault();
1547
1612
  this.zoomByFactor(1 / ZOOM_STEP);
1548
1613
  return;
1549
1614
  case "zoom-reset":
1550
- e.preventDefault();
1615
+ e?.preventDefault();
1551
1616
  this.zoomToLevel(1);
1552
1617
  return;
1553
1618
  case "nudge-left":
@@ -1555,22 +1620,26 @@ var InputHandler = class {
1555
1620
  case "nudge-up":
1556
1621
  case "nudge-down": {
1557
1622
  const delta = NUDGE_DELTAS[action];
1558
- if (delta && this.actions.nudge(delta[0], delta[1], e.shiftKey)) {
1559
- e.preventDefault();
1623
+ if (delta && this.actions.nudge(delta[0], delta[1], e?.shiftKey ?? false)) {
1624
+ e?.preventDefault();
1560
1625
  }
1561
1626
  return;
1562
1627
  }
1563
1628
  default:
1564
1629
  if (action.startsWith("tool:")) {
1565
1630
  if (this.isToolActive) return;
1566
- e.preventDefault();
1631
+ e?.preventDefault();
1567
1632
  this.toolContext?.switchTool?.(action.slice("tool:".length));
1568
1633
  return;
1569
1634
  }
1570
1635
  console.warn(`[fieldnotes] unknown shortcut action "${action}"`);
1571
1636
  }
1572
1637
  }
1638
+ hasClipboard() {
1639
+ return this.actions.hasClipboard();
1640
+ }
1573
1641
  startPinch() {
1642
+ this.cancelLongPress();
1574
1643
  this.inputFilter.reset();
1575
1644
  this.deferredDown = null;
1576
1645
  this.isPanning = true;
@@ -1582,18 +1651,18 @@ var InputHandler = class {
1582
1651
  handlePinchMove() {
1583
1652
  const [a, b] = this.getPinchPoints();
1584
1653
  const dist = this.distance(a, b);
1585
- const center = this.midpoint(a, b);
1654
+ const center2 = this.midpoint(a, b);
1586
1655
  if (this.lastPinchDistance > 0) {
1587
1656
  const scale = dist / this.lastPinchDistance;
1588
1657
  const newZoom = this.camera.zoom * scale;
1589
- this.camera.zoomAt(newZoom, center);
1658
+ this.camera.zoomAt(newZoom, center2);
1590
1659
  }
1591
- const dx = center.x - this.lastPointer.x;
1592
- const dy = center.y - this.lastPointer.y;
1660
+ const dx = center2.x - this.lastPointer.x;
1661
+ const dy = center2.y - this.lastPointer.y;
1593
1662
  this.camera.pan(dx, dy);
1594
1663
  this.lastPinchDistance = dist;
1595
- this.lastPinchCenter = center;
1596
- this.lastPointer = { ...center };
1664
+ this.lastPinchCenter = center2;
1665
+ this.lastPointer = { ...center2 };
1597
1666
  }
1598
1667
  getPinchPoints() {
1599
1668
  const pts = [...this.activePointers.values()];
@@ -1613,6 +1682,13 @@ var InputHandler = class {
1613
1682
  const rect = this.element.getBoundingClientRect();
1614
1683
  return this.camera.screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top });
1615
1684
  }
1685
+ onContextMenu = (e) => {
1686
+ e.preventDefault();
1687
+ if (this.toolManager?.activeTool?.name !== "select") return;
1688
+ const rect = this.element.getBoundingClientRect();
1689
+ const world = this.camera.screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top });
1690
+ this.openContextMenu?.({ x: e.clientX, y: e.clientY }, world);
1691
+ };
1616
1692
  onPointerLeave = (e) => {
1617
1693
  this.lastPointerEvent = null;
1618
1694
  this.onPointerUp(e);
@@ -1660,12 +1736,36 @@ var InputHandler = class {
1660
1736
  this.element.focus({ preventScroll: true });
1661
1737
  }
1662
1738
  cancelToolIfActive(e) {
1739
+ this.cancelLongPress();
1663
1740
  if (this.isToolActive) {
1664
1741
  this.dispatchToolUp(e);
1665
1742
  this.isToolActive = false;
1666
1743
  }
1667
1744
  this.deferredDown = null;
1668
1745
  }
1746
+ startLongPress(e) {
1747
+ if (e.pointerType !== "touch") return;
1748
+ if (this.toolManager?.activeTool?.name !== "select") return;
1749
+ this.longPressStart = { x: e.clientX, y: e.clientY };
1750
+ this.longPressTimer = setTimeout(() => this.fireLongPress(), LONG_PRESS_MS);
1751
+ }
1752
+ cancelLongPress() {
1753
+ if (this.longPressTimer !== null) {
1754
+ clearTimeout(this.longPressTimer);
1755
+ this.longPressTimer = null;
1756
+ }
1757
+ this.longPressStart = null;
1758
+ }
1759
+ fireLongPress() {
1760
+ this.longPressTimer = null;
1761
+ if (!this.deferredDown || this.activePointers.size !== 1 || this.isPanning) return;
1762
+ const start = this.longPressStart;
1763
+ if (!start) return;
1764
+ const rect = this.element.getBoundingClientRect();
1765
+ const world = this.camera.screenToWorld({ x: start.x - rect.left, y: start.y - rect.top });
1766
+ this.deferredDown = null;
1767
+ this.openContextMenu?.({ x: start.x, y: start.y }, world);
1768
+ }
1669
1769
  };
1670
1770
 
1671
1771
  // src/canvas/background.ts
@@ -2135,11 +2235,19 @@ var ElementStore = class {
2135
2235
  (el) => el.type === type
2136
2236
  );
2137
2237
  }
2238
+ // Spatial index stores the rotation-expanded AABB so rotated elements remain
2239
+ // broad-phase hit-test/marquee candidates; precise tests run against local bounds.
2240
+ indexBounds(element) {
2241
+ const bounds = getElementBounds(element);
2242
+ if (!bounds) return null;
2243
+ const angle = element.rotation ?? 0;
2244
+ return angle === 0 ? bounds : rotatedAABB(bounds, angle);
2245
+ }
2138
2246
  add(element) {
2139
2247
  this.sortedCache = null;
2140
2248
  this._versions.set(element.id, 0);
2141
2249
  this.elements.set(element.id, element);
2142
- const bounds = getElementBounds(element);
2250
+ const bounds = this.indexBounds(element);
2143
2251
  if (bounds) this.spatialIndex.insert(element.id, bounds);
2144
2252
  this.bus.emit("add", element);
2145
2253
  }
@@ -2161,7 +2269,7 @@ var ElementStore = class {
2161
2269
  updated.text = sanitizeNoteHtml(updated.text);
2162
2270
  }
2163
2271
  this.elements.set(id, updated);
2164
- const newBounds = getElementBounds(updated);
2272
+ const newBounds = this.indexBounds(updated);
2165
2273
  if (newBounds) {
2166
2274
  this.spatialIndex.update(id, newBounds);
2167
2275
  }
@@ -2194,7 +2302,7 @@ var ElementStore = class {
2194
2302
  for (const el of elements) {
2195
2303
  this.elements.set(el.id, el);
2196
2304
  this._versions.set(el.id, 0);
2197
- const bounds = getElementBounds(el);
2305
+ const bounds = this.indexBounds(el);
2198
2306
  if (bounds) this.spatialIndex.insert(el.id, bounds);
2199
2307
  if (el.type === "stroke") {
2200
2308
  computeStrokeSegments(el);
@@ -2399,9 +2507,9 @@ function updateBoundArrow(arrow, store) {
2399
2507
  if (arrow.fromBinding) {
2400
2508
  const el = store.getById(arrow.fromBinding.elementId);
2401
2509
  if (el) {
2402
- const center = getElementCenter(el);
2403
- updates.from = center;
2404
- updates.position = center;
2510
+ const center2 = getElementCenter(el);
2511
+ updates.from = center2;
2512
+ updates.position = center2;
2405
2513
  }
2406
2514
  }
2407
2515
  if (arrow.toBinding) {
@@ -2413,6 +2521,21 @@ function updateBoundArrow(arrow, store) {
2413
2521
  return Object.keys(updates).length > 0 ? updates : null;
2414
2522
  }
2415
2523
 
2524
+ // src/elements/rotate-canvas.ts
2525
+ function withRotation(ctx, el, center2, draw) {
2526
+ const angle = el.rotation ?? 0;
2527
+ if (angle === 0) {
2528
+ draw();
2529
+ return;
2530
+ }
2531
+ ctx.save();
2532
+ ctx.translate(center2.x, center2.y);
2533
+ ctx.rotate(angle);
2534
+ ctx.translate(-center2.x, -center2.y);
2535
+ draw();
2536
+ ctx.restore();
2537
+ }
2538
+
2416
2539
  // src/elements/grid-renderer.ts
2417
2540
  function getSquareGridLines(bounds, cellSize) {
2418
2541
  if (cellSize <= 0) return { verticals: [], horizontals: [] };
@@ -2676,18 +2799,18 @@ function getHexDistance(a, b, cellSize, orientation) {
2676
2799
  const ds = -dq - dr;
2677
2800
  return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
2678
2801
  }
2679
- function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
2802
+ function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
2680
2803
  const n = Math.round(radiusCells);
2681
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2804
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2682
2805
  const cube = offsetToCube(off.col, off.row, orientation);
2683
2806
  if (n <= 0) {
2684
2807
  return [offsetToPixel(off.col, off.row, cellSize, orientation)];
2685
2808
  }
2686
2809
  return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
2687
2810
  }
2688
- function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2811
+ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
2689
2812
  const n = Math.round(radiusCells);
2690
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2813
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2691
2814
  const cube = offsetToCube(off.col, off.row, orientation);
2692
2815
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2693
2816
  if (n <= 0) return [centerPixel];
@@ -2721,9 +2844,9 @@ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2721
2844
  }
2722
2845
  return cells;
2723
2846
  }
2724
- function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2847
+ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
2725
2848
  const n = Math.round(radiusCells);
2726
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2849
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2727
2850
  const cube = offsetToCube(off.col, off.row, orientation);
2728
2851
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2729
2852
  if (n <= 0) return [centerPixel];
@@ -2759,9 +2882,9 @@ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2759
2882
  }
2760
2883
  return cells;
2761
2884
  }
2762
- function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
2885
+ function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
2763
2886
  const n = Math.round(radiusCells);
2764
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2887
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2765
2888
  const cube = offsetToCube(off.col, off.row, orientation);
2766
2889
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2767
2890
  if (n <= 0) return [centerPixel];
@@ -2839,18 +2962,27 @@ var ElementRenderer = class {
2839
2962
  }
2840
2963
  renderCanvasElement(ctx, element) {
2841
2964
  switch (element.type) {
2842
- case "stroke":
2843
- this.renderStroke(ctx, element);
2965
+ case "stroke": {
2966
+ const b = getElementBounds(element);
2967
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2968
+ withRotation(ctx, element, c, () => this.renderStroke(ctx, element));
2844
2969
  break;
2970
+ }
2845
2971
  case "arrow":
2846
2972
  this.renderArrow(ctx, element);
2847
2973
  break;
2848
- case "shape":
2849
- this.renderShape(ctx, element);
2974
+ case "shape": {
2975
+ const b = getElementBounds(element);
2976
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2977
+ withRotation(ctx, element, c, () => this.renderShape(ctx, element));
2850
2978
  break;
2851
- case "image":
2852
- this.renderImage(ctx, element);
2979
+ }
2980
+ case "image": {
2981
+ const b = getElementBounds(element);
2982
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2983
+ withRotation(ctx, element, c, () => this.renderImage(ctx, element));
2853
2984
  break;
2985
+ }
2854
2986
  case "grid":
2855
2987
  this.renderGrid(ctx, element);
2856
2988
  break;
@@ -3147,20 +3279,20 @@ var ElementRenderer = class {
3147
3279
  renderHexTemplate(ctx, template, cellSize, orientation) {
3148
3280
  const snapUnit = Math.sqrt(3) * cellSize;
3149
3281
  const radiusCells = template.radius / snapUnit;
3150
- const center = template.position;
3282
+ const center2 = template.position;
3151
3283
  let cells;
3152
3284
  switch (template.templateShape) {
3153
3285
  case "circle":
3154
- cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
3286
+ cells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
3155
3287
  break;
3156
3288
  case "cone":
3157
- cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
3289
+ cells = getHexCellsInCone(center2, template.angle, radiusCells, cellSize, orientation);
3158
3290
  break;
3159
3291
  case "line":
3160
- cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
3292
+ cells = getHexCellsInLine(center2, template.angle, radiusCells, cellSize, orientation);
3161
3293
  break;
3162
3294
  case "square":
3163
- cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
3295
+ cells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
3164
3296
  break;
3165
3297
  }
3166
3298
  ctx.save();
@@ -3181,7 +3313,7 @@ var ElementRenderer = class {
3181
3313
  {
3182
3314
  ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
3183
3315
  ctx.beginPath();
3184
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
3316
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
3185
3317
  ctx.fillStyle = template.strokeColor;
3186
3318
  ctx.fill();
3187
3319
  ctx.strokeStyle = template.strokeColor;
@@ -3190,7 +3322,7 @@ var ElementRenderer = class {
3190
3322
  }
3191
3323
  if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
3192
3324
  const r = template.radius;
3193
- this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
3325
+ this.renderRadiusMarker(ctx, center2.x, center2.y, r, template.radiusFeet);
3194
3326
  }
3195
3327
  ctx.restore();
3196
3328
  }
@@ -3820,6 +3952,87 @@ var NoteEditor = class {
3820
3952
  }
3821
3953
  };
3822
3954
 
3955
+ // src/canvas/context-menu.ts
3956
+ var ContextMenu = class {
3957
+ constructor(options) {
3958
+ this.options = options;
3959
+ }
3960
+ el = null;
3961
+ outsideListener = null;
3962
+ keyListener = null;
3963
+ isOpen() {
3964
+ return this.el !== null;
3965
+ }
3966
+ open(items, screenPos) {
3967
+ this.close();
3968
+ const el = document.createElement("div");
3969
+ el.className = "fieldnotes-context-menu";
3970
+ Object.assign(el.style, {
3971
+ position: "fixed",
3972
+ left: `${screenPos.x}px`,
3973
+ top: `${screenPos.y}px`,
3974
+ zIndex: "10000",
3975
+ display: "flex",
3976
+ flexDirection: "column"
3977
+ });
3978
+ for (const item of items) {
3979
+ const btn = document.createElement("button");
3980
+ btn.type = "button";
3981
+ btn.className = "fieldnotes-context-menu-item" + (item.disabled ? " fieldnotes-context-menu-item--disabled" : "");
3982
+ btn.textContent = item.label;
3983
+ if (item.disabled) {
3984
+ btn.disabled = true;
3985
+ } else {
3986
+ btn.addEventListener("click", () => {
3987
+ this.options.onCommand(item.action);
3988
+ this.close();
3989
+ });
3990
+ }
3991
+ el.appendChild(btn);
3992
+ }
3993
+ document.body.appendChild(el);
3994
+ this.el = el;
3995
+ this.clampToViewport(el, screenPos);
3996
+ this.keyListener = (e) => {
3997
+ if (e.key === "Escape") this.close();
3998
+ };
3999
+ document.addEventListener("keydown", this.keyListener);
4000
+ this.outsideListener = (e) => {
4001
+ if (this.el && !this.el.contains(e.target)) this.close();
4002
+ };
4003
+ setTimeout(() => {
4004
+ if (this.outsideListener) document.addEventListener("pointerdown", this.outsideListener);
4005
+ }, 0);
4006
+ }
4007
+ close() {
4008
+ if (this.keyListener) {
4009
+ document.removeEventListener("keydown", this.keyListener);
4010
+ this.keyListener = null;
4011
+ }
4012
+ if (this.outsideListener) {
4013
+ document.removeEventListener("pointerdown", this.outsideListener);
4014
+ this.outsideListener = null;
4015
+ }
4016
+ if (this.el) {
4017
+ this.el.remove();
4018
+ this.el = null;
4019
+ this.options.onClose();
4020
+ }
4021
+ }
4022
+ dispose() {
4023
+ this.close();
4024
+ }
4025
+ clampToViewport(el, screenPos) {
4026
+ const rect = el.getBoundingClientRect();
4027
+ if (rect.width > 0 && screenPos.x + rect.width > window.innerWidth) {
4028
+ el.style.left = `${Math.max(0, screenPos.x - rect.width)}px`;
4029
+ }
4030
+ if (rect.height > 0 && screenPos.y + rect.height > window.innerHeight) {
4031
+ el.style.top = `${Math.max(0, screenPos.y - rect.height)}px`;
4032
+ }
4033
+ }
4034
+ };
4035
+
3823
4036
  // src/elements/translate.ts
3824
4037
  function translateElementPatch(el, dx, dy) {
3825
4038
  const position = { x: el.position.x + dx, y: el.position.y + dy };
@@ -4309,6 +4522,7 @@ function renderStyledRuns(ctx, runs, startX, startY, maxWidth) {
4309
4522
  }
4310
4523
 
4311
4524
  // src/canvas/export-image.ts
4525
+ var center = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
4312
4526
  function getStrokeBounds(el) {
4313
4527
  if (el.type !== "stroke") return null;
4314
4528
  if (el.points.length === 0) return null;
@@ -4334,8 +4548,10 @@ function getStrokeBounds(el) {
4334
4548
  }
4335
4549
  function getElementRect(el) {
4336
4550
  switch (el.type) {
4337
- case "stroke":
4338
- return getStrokeBounds(el);
4551
+ case "stroke": {
4552
+ const r = getStrokeBounds(el);
4553
+ return r ? rotatedAABB(r, el.rotation ?? 0) : r;
4554
+ }
4339
4555
  case "arrow": {
4340
4556
  const b = getArrowBounds(el.from, el.to, el.bend);
4341
4557
  const pad = el.width / 2 + 14;
@@ -4354,7 +4570,10 @@ function getElementRect(el) {
4354
4570
  case "text":
4355
4571
  case "shape":
4356
4572
  if ("size" in el) {
4357
- return { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
4573
+ return rotatedAABB(
4574
+ { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h },
4575
+ el.rotation ?? 0
4576
+ );
4358
4577
  }
4359
4578
  return null;
4360
4579
  default:
@@ -4492,11 +4711,13 @@ async function exportImage(store, options = {}, layerManager) {
4492
4711
  continue;
4493
4712
  }
4494
4713
  if (el.type === "note") {
4495
- renderNoteOnCanvas(ctx, el);
4714
+ const b = getElementBounds(el);
4715
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderNoteOnCanvas(ctx, el));
4496
4716
  continue;
4497
4717
  }
4498
4718
  if (el.type === "text") {
4499
- renderTextOnCanvas(ctx, el);
4719
+ const b = getElementBounds(el);
4720
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderTextOnCanvas(ctx, el));
4500
4721
  continue;
4501
4722
  }
4502
4723
  if (el.type === "html") {
@@ -4505,7 +4726,13 @@ async function exportImage(store, options = {}, layerManager) {
4505
4726
  if (el.type === "image") {
4506
4727
  const img = imageCache.get(el.id);
4507
4728
  if (img) {
4508
- ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h);
4729
+ const b = getElementBounds(el);
4730
+ withRotation(
4731
+ ctx,
4732
+ el,
4733
+ b ? center(b) : el.position,
4734
+ () => ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h)
4735
+ );
4509
4736
  }
4510
4737
  continue;
4511
4738
  }
@@ -4841,7 +5068,9 @@ var DomNodeManager = class {
4841
5068
  top: `${element.position.y}px`,
4842
5069
  width: size ? `${size.w}px` : "auto",
4843
5070
  height: size ? `${size.h}px` : "auto",
4844
- zIndex: String(zIndex)
5071
+ zIndex: String(zIndex),
5072
+ transform: element.rotation ? `rotate(${element.rotation}rad)` : "",
5073
+ transformOrigin: "50% 50%"
4845
5074
  });
4846
5075
  this.renderDomContent(node, element);
4847
5076
  }
@@ -5620,8 +5849,20 @@ var Viewport = class {
5620
5849
  fitToContent: () => this.fitToContent(),
5621
5850
  group: () => this.groupSelection(),
5622
5851
  ungroup: () => this.ungroupSelection(),
5852
+ toggleLock: () => this.toggleLockSelection(),
5853
+ openContextMenu: (screenPos, world) => {
5854
+ this.getSelectTool()?.selectAtPoint(world, this.toolContext);
5855
+ this.openContextMenu(screenPos);
5856
+ },
5623
5857
  shortcuts: options.shortcuts
5624
5858
  });
5859
+ if (options.contextMenu !== false) {
5860
+ this.contextMenu = new ContextMenu({
5861
+ onCommand: (action) => this.runAction(action),
5862
+ onClose: noop
5863
+ });
5864
+ }
5865
+ this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
5625
5866
  this.domNodeManager = new DomNodeManager({
5626
5867
  domLayer: this.domLayer,
5627
5868
  onEditRequest: (id) => this.startEditingElement(id),
@@ -5653,6 +5894,7 @@ var Viewport = class {
5653
5894
  this.unsubCamera = this.camera.onChange(() => {
5654
5895
  this.applyCameraTransform();
5655
5896
  this.noteEditor.updateToolbarPosition();
5897
+ this.contextMenu?.close();
5656
5898
  this.requestRender();
5657
5899
  });
5658
5900
  this.unsubStore = [
@@ -5705,6 +5947,7 @@ var Viewport = class {
5705
5947
  canvasEl;
5706
5948
  wrapper;
5707
5949
  unsubCamera;
5950
+ unsubToolChange;
5708
5951
  unsubStore;
5709
5952
  inputHandler;
5710
5953
  background;
@@ -5727,6 +5970,7 @@ var Viewport = class {
5727
5970
  doubleTapDetector = new DoubleTapDetector();
5728
5971
  tapDownX = 0;
5729
5972
  tapDownY = 0;
5973
+ contextMenu = null;
5730
5974
  get ctx() {
5731
5975
  return this.canvasEl.getContext("2d");
5732
5976
  }
@@ -5917,6 +6161,34 @@ var Viewport = class {
5917
6161
  getSelectedIds() {
5918
6162
  return this.getSelectTool()?.selectedIds ?? EMPTY_IDS;
5919
6163
  }
6164
+ runAction(action) {
6165
+ this.inputHandler.runAction(action);
6166
+ }
6167
+ canPaste() {
6168
+ return this.inputHandler.hasClipboard();
6169
+ }
6170
+ openContextMenu(screenPos) {
6171
+ if (!this.contextMenu) return;
6172
+ const ids = this.getSelectedIds();
6173
+ const items = [];
6174
+ if (ids.length > 0) {
6175
+ items.push({ label: "Cut", action: "cut" });
6176
+ items.push({ label: "Copy", action: "copy" });
6177
+ if (this.canPaste()) items.push({ label: "Paste", action: "paste" });
6178
+ items.push({ label: "Duplicate", action: "duplicate" });
6179
+ items.push({ label: "Delete", action: "delete" });
6180
+ items.push({ label: "Bring to Front", action: "z-front" });
6181
+ items.push({ label: "Bring Forward", action: "z-forward" });
6182
+ items.push({ label: "Send Backward", action: "z-backward" });
6183
+ items.push({ label: "Send to Back", action: "z-back" });
6184
+ const allLocked = ids.every((id) => this.store.getById(id)?.locked);
6185
+ items.push({ label: allLocked ? "Unlock" : "Lock", action: "toggle-lock" });
6186
+ } else if (this.canPaste()) {
6187
+ items.push({ label: "Paste", action: "paste" });
6188
+ }
6189
+ if (items.length === 0) return;
6190
+ this.contextMenu.open(items, screenPos);
6191
+ }
5920
6192
  onSelectionChange(listener) {
5921
6193
  const tool = this.getSelectTool();
5922
6194
  return tool ? tool.onSelectionChange(listener) : noop;
@@ -5977,6 +6249,20 @@ var Viewport = class {
5977
6249
  }
5978
6250
  this.historyRecorder.commit();
5979
6251
  }
6252
+ toggleLockSelection() {
6253
+ const ids = this.getSelectedIds();
6254
+ if (ids.length === 0) return;
6255
+ const anyUnlocked = ids.some((id) => {
6256
+ const el = this.store.getById(id);
6257
+ return el ? !el.locked : false;
6258
+ });
6259
+ this.historyRecorder.begin();
6260
+ for (const id of ids) {
6261
+ const el = this.store.getById(id);
6262
+ if (el && el.locked !== anyUnlocked) this.store.update(id, { locked: anyUnlocked });
6263
+ }
6264
+ this.historyRecorder.commit();
6265
+ }
5980
6266
  alignSelection(edge) {
5981
6267
  const bounded = this.boundedSelection();
5982
6268
  if (bounded.length < 2) return;
@@ -6018,13 +6304,13 @@ var Viewport = class {
6018
6304
  distributeSelection(axis) {
6019
6305
  const bounded = this.boundedSelection();
6020
6306
  if (bounded.length < 3) return;
6021
- const center = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
6022
- const sorted = [...bounded].sort((p, q) => center(p.bounds) - center(q.bounds));
6307
+ const center2 = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
6308
+ const sorted = [...bounded].sort((p, q) => center2(p.bounds) - center2(q.bounds));
6023
6309
  const first = sorted[0];
6024
6310
  const last = sorted[sorted.length - 1];
6025
6311
  if (!first || !last) return;
6026
- const c0 = center(first.bounds);
6027
- const cN = center(last.bounds);
6312
+ const c0 = center2(first.bounds);
6313
+ const cN = center2(last.bounds);
6028
6314
  const n = sorted.length;
6029
6315
  this.historyRecorder.begin();
6030
6316
  const moved = [];
@@ -6032,7 +6318,7 @@ var Viewport = class {
6032
6318
  const item = sorted[i];
6033
6319
  if (!item || !this.isMovable(item.el)) continue;
6034
6320
  const target = c0 + i * (cN - c0) / (n - 1);
6035
- const delta = target - center(item.bounds);
6321
+ const delta = target - center2(item.bounds);
6036
6322
  if (delta === 0) continue;
6037
6323
  const [dx, dy] = axis === "horizontal" ? [delta, 0] : [0, delta];
6038
6324
  this.store.update(item.id, translateElementPatch(item.el, dx, dy));
@@ -6075,12 +6361,14 @@ var Viewport = class {
6075
6361
  this.noteEditor.destroy(this.store);
6076
6362
  this.arrowLabelEditor.cancel();
6077
6363
  this.historyRecorder.destroy();
6364
+ this.contextMenu?.dispose();
6078
6365
  this.wrapper.removeEventListener("pointerdown", this.onTapDown);
6079
6366
  this.wrapper.removeEventListener("pointerup", this.onDoubleTap);
6080
6367
  this.wrapper.removeEventListener("dragover", this.onDragOver);
6081
6368
  this.wrapper.removeEventListener("drop", this.onDrop);
6082
6369
  this.inputHandler.destroy();
6083
6370
  this.unsubCamera();
6371
+ this.unsubToolChange();
6084
6372
  this.unsubStore.forEach((fn) => fn());
6085
6373
  this.resizeObserver?.disconnect();
6086
6374
  this.resizeObserver = null;
@@ -6755,10 +7043,10 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6755
7043
  const excludeId = el.toBinding?.elementId;
6756
7044
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6757
7045
  if (target) {
6758
- const center = getElementCenter(target);
7046
+ const center2 = getElementCenter(target);
6759
7047
  ctx.store.update(elementId, {
6760
- from: center,
6761
- position: center,
7048
+ from: center2,
7049
+ position: center2,
6762
7050
  fromBinding: { elementId: target.id }
6763
7051
  });
6764
7052
  } else {
@@ -6774,9 +7062,9 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6774
7062
  const excludeId = el.fromBinding?.elementId;
6775
7063
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6776
7064
  if (target) {
6777
- const center = getElementCenter(target);
7065
+ const center2 = getElementCenter(target);
6778
7066
  ctx.store.update(elementId, {
6779
- to: center,
7067
+ to: center2,
6780
7068
  toBinding: { elementId: target.id }
6781
7069
  });
6782
7070
  } else {
@@ -6865,6 +7153,9 @@ var SNAP_PX = 6;
6865
7153
  var HANDLE_HIT_PADDING2 = 4;
6866
7154
  var SELECTION_PAD = 4;
6867
7155
  var MIN_ELEMENT_SIZE = 20;
7156
+ var ROTATE_HANDLE_OFFSET = 24;
7157
+ var ROTATE_SNAP = Math.PI / 12;
7158
+ var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
6868
7159
  var HANDLE_CURSORS = {
6869
7160
  nw: "nwse-resize",
6870
7161
  se: "nwse-resize",
@@ -6905,6 +7196,15 @@ var SelectTool = class {
6905
7196
  this.setSelectedIds(ids);
6906
7197
  this.ctx?.requestRender();
6907
7198
  }
7199
+ selectAtPoint(world, ctx) {
7200
+ const hit = this.hitTest(world, ctx);
7201
+ if (!hit) {
7202
+ this.setSelectedIds([]);
7203
+ return;
7204
+ }
7205
+ if (this._selectedIds.includes(hit.id)) return;
7206
+ this.setSelectedIds(expandToGroups([hit.id], ctx.store.getAll()));
7207
+ }
6908
7208
  get isMarqueeActive() {
6909
7209
  return this.mode.type === "marquee";
6910
7210
  }
@@ -6953,6 +7253,22 @@ var SelectTool = class {
6953
7253
  ctx.requestRender();
6954
7254
  return;
6955
7255
  }
7256
+ const rotateHit = this.hitTestRotateHandle(world, ctx);
7257
+ if (rotateHit) {
7258
+ const el = ctx.store.getById(rotateHit.elementId);
7259
+ const layout = el ? this.getOverlayLayout(el, ctx.camera.zoom) : null;
7260
+ if (el && layout) {
7261
+ this.mode = {
7262
+ type: "rotating",
7263
+ elementId: rotateHit.elementId,
7264
+ center: layout.center,
7265
+ startPointerAngle: Math.atan2(world.y - layout.center.y, world.x - layout.center.x),
7266
+ startRotation: el.rotation ?? 0
7267
+ };
7268
+ ctx.requestRender();
7269
+ return;
7270
+ }
7271
+ }
6956
7272
  const resizeHit = this.hitTestResizeHandle(world, ctx);
6957
7273
  if (resizeHit) {
6958
7274
  const el = ctx.store.getById(resizeHit.elementId);
@@ -7018,6 +7334,15 @@ var SelectTool = class {
7018
7334
  this.handleTemplateResize(world, ctx);
7019
7335
  return;
7020
7336
  }
7337
+ if (this.mode.type === "rotating") {
7338
+ const { elementId, center: center2, startPointerAngle, startRotation } = this.mode;
7339
+ const a = Math.atan2(world.y - center2.y, world.x - center2.x);
7340
+ let next = startRotation + (a - startPointerAngle);
7341
+ if (state.shiftKey) next = Math.round(next / ROTATE_SNAP) * ROTATE_SNAP;
7342
+ ctx.store.update(elementId, { rotation: normalizeAngle(next) });
7343
+ ctx.requestRender();
7344
+ return;
7345
+ }
7021
7346
  if (this.mode.type === "resizing") {
7022
7347
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
7023
7348
  this.handleResize(world, ctx, state.shiftKey);
@@ -7220,6 +7545,10 @@ var SelectTool = class {
7220
7545
  ctx.setCursor?.("nwse-resize");
7221
7546
  return null;
7222
7547
  }
7548
+ if (this.hitTestRotateHandle(world, ctx)) {
7549
+ ctx.setCursor?.("grab");
7550
+ return null;
7551
+ }
7223
7552
  const resizeHit = this.hitTestResizeHandle(world, ctx);
7224
7553
  if (resizeHit) {
7225
7554
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -7238,6 +7567,11 @@ var SelectTool = class {
7238
7567
  if (this.mode.type !== "resizing") return;
7239
7568
  const el = ctx.store.getById(this.mode.elementId);
7240
7569
  if (!el || !("size" in el) || el.locked) return;
7570
+ const angle = el.rotation ?? 0;
7571
+ if (angle !== 0) {
7572
+ this.handleRotatedResize(world, el, angle, ctx, shiftKey);
7573
+ return;
7574
+ }
7241
7575
  const { handle } = this.mode;
7242
7576
  const dx = world.x - this.lastWorld.x;
7243
7577
  const dy = world.y - this.lastWorld.y;
@@ -7295,6 +7629,78 @@ var SelectTool = class {
7295
7629
  this.updateArrowsBoundTo([this.mode.elementId], ctx);
7296
7630
  ctx.requestRender();
7297
7631
  }
7632
+ anchorOffset(handle, w, h) {
7633
+ switch (handle) {
7634
+ case "se":
7635
+ return { x: -w / 2, y: -h / 2 };
7636
+ case "sw":
7637
+ return { x: w / 2, y: -h / 2 };
7638
+ case "ne":
7639
+ return { x: -w / 2, y: h / 2 };
7640
+ case "nw":
7641
+ return { x: w / 2, y: h / 2 };
7642
+ default:
7643
+ return { x: 0, y: 0 };
7644
+ }
7645
+ }
7646
+ handleRotatedResize(world, el, angle, ctx, shiftKey) {
7647
+ if (this.mode.type !== "resizing") return;
7648
+ const { handle } = this.mode;
7649
+ const wdx = world.x - this.lastWorld.x;
7650
+ const wdy = world.y - this.lastWorld.y;
7651
+ this.lastWorld = world;
7652
+ const cosN = Math.cos(-angle);
7653
+ const sinN = Math.sin(-angle);
7654
+ const ldx = wdx * cosN - wdy * sinN;
7655
+ const ldy = wdx * sinN + wdy * cosN;
7656
+ let w = el.size.w;
7657
+ let h = el.size.h;
7658
+ switch (handle) {
7659
+ case "se":
7660
+ w += ldx;
7661
+ h += ldy;
7662
+ break;
7663
+ case "sw":
7664
+ w -= ldx;
7665
+ h += ldy;
7666
+ break;
7667
+ case "ne":
7668
+ w += ldx;
7669
+ h -= ldy;
7670
+ break;
7671
+ case "nw":
7672
+ w -= ldx;
7673
+ h -= ldy;
7674
+ break;
7675
+ }
7676
+ if (shiftKey && this.resizeAspectRatio > 0) {
7677
+ const absDw = Math.abs(w - el.size.w);
7678
+ const absDh = Math.abs(h - el.size.h);
7679
+ if (absDw >= absDh) h = w / this.resizeAspectRatio;
7680
+ else w = h * this.resizeAspectRatio;
7681
+ }
7682
+ w = Math.max(w, MIN_ELEMENT_SIZE);
7683
+ h = Math.max(h, MIN_ELEMENT_SIZE);
7684
+ const oldCenter = { x: el.position.x + el.size.w / 2, y: el.position.y + el.size.h / 2 };
7685
+ const oldAnchorLocal = this.anchorOffset(handle, el.size.w, el.size.h);
7686
+ const anchorWorld = rotatePoint(
7687
+ { x: oldCenter.x + oldAnchorLocal.x, y: oldCenter.y + oldAnchorLocal.y },
7688
+ oldCenter,
7689
+ angle
7690
+ );
7691
+ const newAnchorLocal = this.anchorOffset(handle, w, h);
7692
+ const cos = Math.cos(angle);
7693
+ const sin = Math.sin(angle);
7694
+ const rotatedAnchor = {
7695
+ x: newAnchorLocal.x * cos - newAnchorLocal.y * sin,
7696
+ y: newAnchorLocal.x * sin + newAnchorLocal.y * cos
7697
+ };
7698
+ const newCenter = { x: anchorWorld.x - rotatedAnchor.x, y: anchorWorld.y - rotatedAnchor.y };
7699
+ const position = { x: newCenter.x - w / 2, y: newCenter.y - h / 2 };
7700
+ ctx.store.update(this.mode.elementId, { position, size: { w, h } });
7701
+ this.updateArrowsBoundTo([this.mode.elementId], ctx);
7702
+ ctx.requestRender();
7703
+ }
7298
7704
  hitTestResizeHandle(world, ctx) {
7299
7705
  if (this._selectedIds.length === 0) return null;
7300
7706
  const zoom = ctx.camera.zoom;
@@ -7302,11 +7708,11 @@ var SelectTool = class {
7302
7708
  for (const id of this._selectedIds) {
7303
7709
  const el = ctx.store.getById(id);
7304
7710
  if (!el || !("size" in el)) continue;
7711
+ if (el.locked) continue;
7305
7712
  if (el.type === "shape" && el.shape === "line") continue;
7306
- const bounds = getElementBounds(el);
7307
- if (!bounds) continue;
7308
- const corners = this.getHandlePositions(bounds);
7309
- for (const [handle, pos] of corners) {
7713
+ const layout = this.getOverlayLayout(el, zoom);
7714
+ if (!layout) continue;
7715
+ for (const [handle, pos] of layout.corners) {
7310
7716
  if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
7311
7717
  return { elementId: id, handle };
7312
7718
  }
@@ -7314,6 +7720,19 @@ var SelectTool = class {
7314
7720
  }
7315
7721
  return null;
7316
7722
  }
7723
+ hitTestRotateHandle(world, ctx) {
7724
+ if (this._selectedIds.length !== 1) return null;
7725
+ const id = this._selectedIds[0];
7726
+ if (!id) return null;
7727
+ const el = ctx.store.getById(id);
7728
+ if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
7729
+ const layout = this.getOverlayLayout(el, ctx.camera.zoom);
7730
+ if (!layout) return null;
7731
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
7732
+ const dx = world.x - layout.rotateHandle.x;
7733
+ const dy = world.y - layout.rotateHandle.y;
7734
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
7735
+ }
7317
7736
  hitTestLineHandles(world, ctx) {
7318
7737
  if (this._selectedIds.length === 0) return null;
7319
7738
  const zoom = ctx.camera.zoom;
@@ -7336,6 +7755,30 @@ var SelectTool = class {
7336
7755
  ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
7337
7756
  ];
7338
7757
  }
7758
+ getOverlayLayout(el, zoom) {
7759
+ const bounds = getElementBounds(el);
7760
+ if (!bounds) return null;
7761
+ const angle = el.rotation ?? 0;
7762
+ const pad = SELECTION_PAD / zoom;
7763
+ const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
7764
+ const raw = [
7765
+ ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
7766
+ ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
7767
+ ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
7768
+ ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
7769
+ ];
7770
+ const corners = raw.map(
7771
+ ([h, p]) => [h, rotatePoint(p, center2, angle)]
7772
+ );
7773
+ const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
7774
+ const rotateHandle = rotatePoint(topMid, center2, angle);
7775
+ return { center: center2, corners, rotateHandle, angle };
7776
+ }
7777
+ topMidpoint(layout) {
7778
+ const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
7779
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
7780
+ return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
7781
+ }
7339
7782
  renderMarquee(canvasCtx) {
7340
7783
  if (this.mode.type !== "marquee") return;
7341
7784
  const rect = this.getMarqueeRect();
@@ -7380,49 +7823,110 @@ var SelectTool = class {
7380
7823
  }
7381
7824
  const bounds = getElementBounds(el);
7382
7825
  if (!bounds) continue;
7826
+ const layout = this.getOverlayLayout(el, zoom);
7827
+ if (!layout) continue;
7383
7828
  const pad = SELECTION_PAD / zoom;
7384
- canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
7385
- if ("size" in el) {
7386
- canvasCtx.setLineDash([]);
7387
- canvasCtx.fillStyle = "#ffffff";
7388
- const corners = this.getHandlePositions(bounds);
7389
- for (const [, pos] of corners) {
7829
+ if (layout.angle === 0) {
7830
+ canvasCtx.strokeRect(
7831
+ bounds.x - pad,
7832
+ bounds.y - pad,
7833
+ bounds.w + pad * 2,
7834
+ bounds.h + pad * 2
7835
+ );
7836
+ } else {
7837
+ const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((p) => !!p);
7838
+ const [p0, ...others] = ordered;
7839
+ if (p0) {
7840
+ canvasCtx.beginPath();
7841
+ canvasCtx.moveTo(p0.x, p0.y);
7842
+ for (const p of others) canvasCtx.lineTo(p.x, p.y);
7843
+ canvasCtx.closePath();
7844
+ canvasCtx.stroke();
7845
+ }
7846
+ }
7847
+ if (!el.locked) {
7848
+ if ("size" in el) {
7849
+ canvasCtx.setLineDash([]);
7850
+ canvasCtx.fillStyle = "#ffffff";
7851
+ const corners = layout.angle === 0 ? this.getHandlePositions(bounds) : layout.corners;
7852
+ for (const [, pos] of corners) {
7853
+ canvasCtx.fillRect(
7854
+ pos.x - handleWorldSize / 2,
7855
+ pos.y - handleWorldSize / 2,
7856
+ handleWorldSize,
7857
+ handleWorldSize
7858
+ );
7859
+ canvasCtx.strokeRect(
7860
+ pos.x - handleWorldSize / 2,
7861
+ pos.y - handleWorldSize / 2,
7862
+ handleWorldSize,
7863
+ handleWorldSize
7864
+ );
7865
+ }
7866
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7867
+ } else if (el.type === "template") {
7868
+ canvasCtx.setLineDash([]);
7869
+ canvasCtx.fillStyle = "#ffffff";
7870
+ const hx = bounds.x + bounds.w;
7871
+ const hy = bounds.y + bounds.h;
7390
7872
  canvasCtx.fillRect(
7391
- pos.x - handleWorldSize / 2,
7392
- pos.y - handleWorldSize / 2,
7873
+ hx - handleWorldSize / 2,
7874
+ hy - handleWorldSize / 2,
7393
7875
  handleWorldSize,
7394
7876
  handleWorldSize
7395
7877
  );
7396
7878
  canvasCtx.strokeRect(
7397
- pos.x - handleWorldSize / 2,
7398
- pos.y - handleWorldSize / 2,
7879
+ hx - handleWorldSize / 2,
7880
+ hy - handleWorldSize / 2,
7399
7881
  handleWorldSize,
7400
7882
  handleWorldSize
7401
7883
  );
7884
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7885
+ }
7886
+ if (this._selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
7887
+ const stemStart = this.topMidpoint(layout);
7888
+ const stemEnd = layout.rotateHandle;
7889
+ canvasCtx.beginPath();
7890
+ canvasCtx.moveTo(stemStart.x, stemStart.y);
7891
+ canvasCtx.lineTo(stemEnd.x, stemEnd.y);
7892
+ canvasCtx.stroke();
7893
+ canvasCtx.setLineDash([]);
7894
+ canvasCtx.fillStyle = "#ffffff";
7895
+ canvasCtx.beginPath();
7896
+ canvasCtx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
7897
+ canvasCtx.fill();
7898
+ canvasCtx.stroke();
7899
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7402
7900
  }
7403
- canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7404
- } else if (el.type === "template") {
7405
- canvasCtx.setLineDash([]);
7406
- canvasCtx.fillStyle = "#ffffff";
7407
- const hx = bounds.x + bounds.w;
7408
- const hy = bounds.y + bounds.h;
7409
- canvasCtx.fillRect(
7410
- hx - handleWorldSize / 2,
7411
- hy - handleWorldSize / 2,
7412
- handleWorldSize,
7413
- handleWorldSize
7414
- );
7415
- canvasCtx.strokeRect(
7416
- hx - handleWorldSize / 2,
7417
- hy - handleWorldSize / 2,
7418
- handleWorldSize,
7419
- handleWorldSize
7420
- );
7421
- canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7901
+ }
7902
+ if (el.locked) {
7903
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1];
7904
+ if (ne) this.drawLockBadge(canvasCtx, ne, zoom);
7422
7905
  }
7423
7906
  }
7424
7907
  canvasCtx.restore();
7425
7908
  }
7909
+ drawLockBadge(ctx, at, zoom) {
7910
+ const r = 9 / zoom;
7911
+ ctx.save();
7912
+ ctx.setLineDash([]);
7913
+ ctx.beginPath();
7914
+ ctx.arc(at.x, at.y, r, 0, Math.PI * 2);
7915
+ ctx.fillStyle = "#ffffff";
7916
+ ctx.fill();
7917
+ ctx.strokeStyle = "#2196F3";
7918
+ ctx.lineWidth = 1.5 / zoom;
7919
+ ctx.stroke();
7920
+ const bw = 8 / zoom;
7921
+ const bh = 6 / zoom;
7922
+ ctx.fillStyle = "#2196F3";
7923
+ ctx.fillRect(at.x - bw / 2, at.y - bh / 2 + 1 / zoom, bw, bh);
7924
+ ctx.beginPath();
7925
+ ctx.arc(at.x, at.y - bh / 2 + 1 / zoom, 2.5 / zoom, Math.PI, 0);
7926
+ ctx.lineWidth = 1.4 / zoom;
7927
+ ctx.stroke();
7928
+ ctx.restore();
7929
+ }
7426
7930
  renderBindingHighlights(canvasCtx, arrow, zoom) {
7427
7931
  if (!this.ctx) return;
7428
7932
  if (!arrow.fromBinding && !arrow.toBinding) return;
@@ -7499,7 +8003,7 @@ var SelectTool = class {
7499
8003
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
7500
8004
  if (el.type === "grid") continue;
7501
8005
  const bounds = getElementBounds(el);
7502
- if (bounds && this.rectsOverlap(marquee, bounds)) {
8006
+ if (bounds && this.rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
7503
8007
  ids.push(el.id);
7504
8008
  }
7505
8009
  }
@@ -7521,6 +8025,13 @@ var SelectTool = class {
7521
8025
  }
7522
8026
  isInsideBounds(point, el) {
7523
8027
  if (el.type === "grid") return false;
8028
+ const angle = el.rotation ?? 0;
8029
+ if (angle !== 0) {
8030
+ const b = getElementBounds(el);
8031
+ if (b) {
8032
+ point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
8033
+ }
8034
+ }
7524
8035
  if (el.type === "shape" && el.shape === "line") {
7525
8036
  const [a, b] = lineEndpoints(el);
7526
8037
  const threshold = Math.max(el.strokeWidth / 2, 6);
@@ -8287,20 +8798,20 @@ var TemplateTool = class {
8287
8798
  const snapUnit = Math.sqrt(3) * cellSize;
8288
8799
  const radiusCells = radius / snapUnit;
8289
8800
  const angle = this.computeAngle();
8290
- const center = this.origin;
8801
+ const center2 = this.origin;
8291
8802
  let hexCells;
8292
8803
  switch (this.templateShape) {
8293
8804
  case "circle":
8294
- hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
8805
+ hexCells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
8295
8806
  break;
8296
8807
  case "cone":
8297
- hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
8808
+ hexCells = getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation);
8298
8809
  break;
8299
8810
  case "line":
8300
- hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
8811
+ hexCells = getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation);
8301
8812
  break;
8302
8813
  case "square":
8303
- hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
8814
+ hexCells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
8304
8815
  break;
8305
8816
  }
8306
8817
  ctx.save();
@@ -8321,7 +8832,7 @@ var TemplateTool = class {
8321
8832
  if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
8322
8833
  ctx.globalAlpha = 0.5;
8323
8834
  ctx.beginPath();
8324
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
8835
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
8325
8836
  ctx.fillStyle = this.strokeColor;
8326
8837
  ctx.fill();
8327
8838
  ctx.strokeStyle = this.strokeColor;
@@ -8337,8 +8848,8 @@ var TemplateTool = class {
8337
8848
  ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
8338
8849
  ctx.textAlign = "center";
8339
8850
  ctx.textBaseline = "bottom";
8340
- const textX = center.x;
8341
- const textY = center.y - 4;
8851
+ const textX = center2.x;
8852
+ const textY = center2.y - 4;
8342
8853
  const metrics = ctx.measureText(label);
8343
8854
  const padX = 4;
8344
8855
  const padY = 2;
@@ -8388,7 +8899,7 @@ var TemplateTool = class {
8388
8899
  };
8389
8900
 
8390
8901
  // src/index.ts
8391
- var VERSION = "0.34.0";
8902
+ var VERSION = "0.36.0";
8392
8903
  // Annotate the CommonJS export names for ESM import in node:
8393
8904
  0 && (module.exports = {
8394
8905
  ArrowTool,