@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.js CHANGED
@@ -524,6 +524,36 @@ function distSqToSegment(p, a, b) {
524
524
  const dy = p.y - (a.y + t * aby);
525
525
  return dx * dx + dy * dy;
526
526
  }
527
+ function rotatePoint(p, center2, angle) {
528
+ if (angle === 0) return p;
529
+ const cos = Math.cos(angle);
530
+ const sin = Math.sin(angle);
531
+ const dx = p.x - center2.x;
532
+ const dy = p.y - center2.y;
533
+ return { x: center2.x + dx * cos - dy * sin, y: center2.y + dx * sin + dy * cos };
534
+ }
535
+ function rotatedAABB(bounds, angle) {
536
+ if (angle === 0) return bounds;
537
+ const c = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
538
+ const corners = [
539
+ { x: bounds.x, y: bounds.y },
540
+ { x: bounds.x + bounds.w, y: bounds.y },
541
+ { x: bounds.x + bounds.w, y: bounds.y + bounds.h },
542
+ { x: bounds.x, y: bounds.y + bounds.h }
543
+ ].map((p) => rotatePoint(p, c, angle));
544
+ const xs = corners.map((p) => p.x);
545
+ const ys = corners.map((p) => p.y);
546
+ const minX = Math.min(...xs);
547
+ const minY = Math.min(...ys);
548
+ return { x: minX, y: minY, w: Math.max(...xs) - minX, h: Math.max(...ys) - minY };
549
+ }
550
+ function normalizeAngle(angle) {
551
+ const twoPi = Math.PI * 2;
552
+ let a = angle % twoPi;
553
+ if (a <= -Math.PI) a += twoPi;
554
+ else if (a > Math.PI) a -= twoPi;
555
+ return a;
556
+ }
527
557
 
528
558
  // src/elements/arrow-geometry.ts
529
559
  function getArrowControlPoint(from, to, bend) {
@@ -880,6 +910,14 @@ var KeyboardActions = class {
880
910
  }
881
911
  this.pasteCount = 0;
882
912
  }
913
+ cut() {
914
+ if (this.deps.isToolActive()) return;
915
+ this.copy();
916
+ this.deleteSelected();
917
+ }
918
+ hasClipboard() {
919
+ return this.clipboard.length > 0;
920
+ }
883
921
  paste() {
884
922
  if (this.deps.isToolActive()) return;
885
923
  this.flushPendingNudge();
@@ -948,6 +986,10 @@ var KeyboardActions = class {
948
986
  if (this.deps.isToolActive()) return;
949
987
  this.deps.ungroup?.();
950
988
  }
989
+ toggleLock() {
990
+ if (this.deps.isToolActive()) return;
991
+ this.deps.toggleLock?.();
992
+ }
951
993
  zOrder(operation) {
952
994
  if (this.deps.isToolActive()) return;
953
995
  this.flushPendingNudge();
@@ -1049,6 +1091,8 @@ var DEFAULT_BINDINGS = [
1049
1091
  ["zoom-reset", ["mod+0"]],
1050
1092
  ["group", ["mod+g"]],
1051
1093
  ["ungroup", ["mod+shift+g"]],
1094
+ ["cut", ["mod+x"]],
1095
+ ["toggle-lock", ["mod+shift+l"]],
1052
1096
  ["nudge-left", ["arrowleft"]],
1053
1097
  ["nudge-right", ["arrowright"]],
1054
1098
  ["nudge-up", ["arrowup"]],
@@ -1187,6 +1231,7 @@ var ShortcutMap = class {
1187
1231
  var ZOOM_SENSITIVITY = 1e-3;
1188
1232
  var ZOOM_STEP = 1.2;
1189
1233
  var MIDDLE_BUTTON = 1;
1234
+ var LONG_PRESS_MS = 500;
1190
1235
  var NUDGE_DELTAS = {
1191
1236
  "nudge-left": [-1, 0],
1192
1237
  "nudge-right": [1, 0],
@@ -1210,8 +1255,10 @@ var InputHandler = class {
1210
1255
  fitToContent: options.fitToContent,
1211
1256
  group: options.group,
1212
1257
  ungroup: options.ungroup,
1258
+ toggleLock: options.toggleLock,
1213
1259
  getLastPointerWorld: () => this.lastPointerWorld()
1214
1260
  });
1261
+ this.openContextMenu = options.openContextMenu;
1215
1262
  this.shortcutMap = new ShortcutMap(options.shortcuts?.bindings);
1216
1263
  this.scope = options.shortcuts?.scope ?? "focus";
1217
1264
  this.element.style.touchAction = "none";
@@ -1235,10 +1282,13 @@ var InputHandler = class {
1235
1282
  lastPointerEvent = null;
1236
1283
  inputFilter = new InputFilter();
1237
1284
  deferredDown = null;
1285
+ longPressTimer = null;
1286
+ longPressStart = null;
1238
1287
  abortController = new AbortController();
1239
1288
  actions;
1240
1289
  shortcutMap;
1241
1290
  scope;
1291
+ openContextMenu;
1242
1292
  setToolManager(toolManager, toolContext) {
1243
1293
  this.toolManager = toolManager;
1244
1294
  this.toolContext = toolContext;
@@ -1253,6 +1303,7 @@ var InputHandler = class {
1253
1303
  this.actions.dispose();
1254
1304
  this.abortController.abort();
1255
1305
  this.inputFilter.reset();
1306
+ this.cancelLongPress();
1256
1307
  this.deferredDown = null;
1257
1308
  this.lastPointerEvent = null;
1258
1309
  if (this.scope === "focus") {
@@ -1268,6 +1319,7 @@ var InputHandler = class {
1268
1319
  this.element.addEventListener("pointerup", this.onPointerUp, opts);
1269
1320
  this.element.addEventListener("pointerleave", this.onPointerLeave, opts);
1270
1321
  this.element.addEventListener("pointercancel", this.onPointerUp, opts);
1322
+ this.element.addEventListener("contextmenu", this.onContextMenu, opts);
1271
1323
  window.addEventListener("keydown", this.onKeyDown, opts);
1272
1324
  window.addEventListener("keyup", this.onKeyUp, opts);
1273
1325
  }
@@ -1296,11 +1348,13 @@ var InputHandler = class {
1296
1348
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
1297
1349
  this.element.setPointerCapture?.(e.pointerId);
1298
1350
  if (this.activePointers.size === 2) {
1351
+ this.cancelLongPress();
1299
1352
  this.startPinch();
1300
1353
  this.cancelToolIfActive(e);
1301
1354
  return;
1302
1355
  }
1303
1356
  if (e.button === MIDDLE_BUTTON || e.button === 0 && this.spaceHeld) {
1357
+ this.cancelLongPress();
1304
1358
  this.isPanning = true;
1305
1359
  this.lastPointer = { x: e.clientX, y: e.clientY };
1306
1360
  return;
@@ -1310,6 +1364,7 @@ var InputHandler = class {
1310
1364
  if (result.action === "suppress") return;
1311
1365
  if (result.action === "defer") {
1312
1366
  this.deferredDown = e;
1367
+ this.startLongPress(e);
1313
1368
  return;
1314
1369
  }
1315
1370
  this.dispatchToolDown(e);
@@ -1338,6 +1393,7 @@ var InputHandler = class {
1338
1393
  } else if (this.deferredDown) {
1339
1394
  const result = this.inputFilter.filterMove(e);
1340
1395
  if (result.action === "dispatch") {
1396
+ this.cancelLongPress();
1341
1397
  this.dispatchToolDown(this.deferredDown);
1342
1398
  this.deferredDown = null;
1343
1399
  this.dispatchToolMove(e);
@@ -1347,6 +1403,7 @@ var InputHandler = class {
1347
1403
  }
1348
1404
  };
1349
1405
  onPointerUp = (e) => {
1406
+ this.cancelLongPress();
1350
1407
  try {
1351
1408
  this.element.releasePointerCapture(e.pointerId);
1352
1409
  } catch {
@@ -1399,74 +1456,82 @@ var InputHandler = class {
1399
1456
  runAction(action, e) {
1400
1457
  switch (action) {
1401
1458
  case "delete":
1402
- e.preventDefault();
1459
+ e?.preventDefault();
1403
1460
  this.actions.deleteSelected();
1404
1461
  return;
1405
1462
  case "deselect":
1406
1463
  this.actions.deselect();
1407
1464
  return;
1408
1465
  case "undo":
1409
- e.preventDefault();
1466
+ e?.preventDefault();
1410
1467
  this.actions.undo();
1411
1468
  return;
1412
1469
  case "redo":
1413
- e.preventDefault();
1470
+ e?.preventDefault();
1414
1471
  this.actions.redo();
1415
1472
  return;
1416
1473
  case "select-all":
1417
- e.preventDefault();
1474
+ e?.preventDefault();
1418
1475
  this.actions.selectAll();
1419
1476
  return;
1420
1477
  case "copy":
1421
- e.preventDefault();
1478
+ e?.preventDefault();
1422
1479
  this.actions.copy();
1423
1480
  return;
1424
1481
  case "paste":
1425
- e.preventDefault();
1482
+ e?.preventDefault();
1426
1483
  this.actions.paste();
1427
1484
  return;
1428
1485
  case "duplicate":
1429
- e.preventDefault();
1486
+ e?.preventDefault();
1430
1487
  this.actions.duplicate();
1431
1488
  return;
1432
1489
  case "z-forward":
1433
- e.preventDefault();
1490
+ e?.preventDefault();
1434
1491
  this.actions.zOrder("forward");
1435
1492
  return;
1436
1493
  case "z-backward":
1437
- e.preventDefault();
1494
+ e?.preventDefault();
1438
1495
  this.actions.zOrder("backward");
1439
1496
  return;
1440
1497
  case "z-front":
1441
- e.preventDefault();
1498
+ e?.preventDefault();
1442
1499
  this.actions.zOrder("front");
1443
1500
  return;
1444
1501
  case "z-back":
1445
- e.preventDefault();
1502
+ e?.preventDefault();
1446
1503
  this.actions.zOrder("back");
1447
1504
  return;
1448
1505
  case "zoom-fit":
1449
- e.preventDefault();
1506
+ e?.preventDefault();
1450
1507
  this.actions.zoomToFit();
1451
1508
  return;
1452
1509
  case "group":
1453
- e.preventDefault();
1510
+ e?.preventDefault();
1454
1511
  this.actions.group();
1455
1512
  return;
1456
1513
  case "ungroup":
1457
- e.preventDefault();
1514
+ e?.preventDefault();
1458
1515
  this.actions.ungroup();
1459
1516
  return;
1517
+ case "cut":
1518
+ e?.preventDefault();
1519
+ this.actions.cut();
1520
+ return;
1521
+ case "toggle-lock":
1522
+ e?.preventDefault();
1523
+ this.actions.toggleLock();
1524
+ return;
1460
1525
  case "zoom-in":
1461
- e.preventDefault();
1526
+ e?.preventDefault();
1462
1527
  this.zoomByFactor(ZOOM_STEP);
1463
1528
  return;
1464
1529
  case "zoom-out":
1465
- e.preventDefault();
1530
+ e?.preventDefault();
1466
1531
  this.zoomByFactor(1 / ZOOM_STEP);
1467
1532
  return;
1468
1533
  case "zoom-reset":
1469
- e.preventDefault();
1534
+ e?.preventDefault();
1470
1535
  this.zoomToLevel(1);
1471
1536
  return;
1472
1537
  case "nudge-left":
@@ -1474,22 +1539,26 @@ var InputHandler = class {
1474
1539
  case "nudge-up":
1475
1540
  case "nudge-down": {
1476
1541
  const delta = NUDGE_DELTAS[action];
1477
- if (delta && this.actions.nudge(delta[0], delta[1], e.shiftKey)) {
1478
- e.preventDefault();
1542
+ if (delta && this.actions.nudge(delta[0], delta[1], e?.shiftKey ?? false)) {
1543
+ e?.preventDefault();
1479
1544
  }
1480
1545
  return;
1481
1546
  }
1482
1547
  default:
1483
1548
  if (action.startsWith("tool:")) {
1484
1549
  if (this.isToolActive) return;
1485
- e.preventDefault();
1550
+ e?.preventDefault();
1486
1551
  this.toolContext?.switchTool?.(action.slice("tool:".length));
1487
1552
  return;
1488
1553
  }
1489
1554
  console.warn(`[fieldnotes] unknown shortcut action "${action}"`);
1490
1555
  }
1491
1556
  }
1557
+ hasClipboard() {
1558
+ return this.actions.hasClipboard();
1559
+ }
1492
1560
  startPinch() {
1561
+ this.cancelLongPress();
1493
1562
  this.inputFilter.reset();
1494
1563
  this.deferredDown = null;
1495
1564
  this.isPanning = true;
@@ -1501,18 +1570,18 @@ var InputHandler = class {
1501
1570
  handlePinchMove() {
1502
1571
  const [a, b] = this.getPinchPoints();
1503
1572
  const dist = this.distance(a, b);
1504
- const center = this.midpoint(a, b);
1573
+ const center2 = this.midpoint(a, b);
1505
1574
  if (this.lastPinchDistance > 0) {
1506
1575
  const scale = dist / this.lastPinchDistance;
1507
1576
  const newZoom = this.camera.zoom * scale;
1508
- this.camera.zoomAt(newZoom, center);
1577
+ this.camera.zoomAt(newZoom, center2);
1509
1578
  }
1510
- const dx = center.x - this.lastPointer.x;
1511
- const dy = center.y - this.lastPointer.y;
1579
+ const dx = center2.x - this.lastPointer.x;
1580
+ const dy = center2.y - this.lastPointer.y;
1512
1581
  this.camera.pan(dx, dy);
1513
1582
  this.lastPinchDistance = dist;
1514
- this.lastPinchCenter = center;
1515
- this.lastPointer = { ...center };
1583
+ this.lastPinchCenter = center2;
1584
+ this.lastPointer = { ...center2 };
1516
1585
  }
1517
1586
  getPinchPoints() {
1518
1587
  const pts = [...this.activePointers.values()];
@@ -1532,6 +1601,13 @@ var InputHandler = class {
1532
1601
  const rect = this.element.getBoundingClientRect();
1533
1602
  return this.camera.screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top });
1534
1603
  }
1604
+ onContextMenu = (e) => {
1605
+ e.preventDefault();
1606
+ if (this.toolManager?.activeTool?.name !== "select") return;
1607
+ const rect = this.element.getBoundingClientRect();
1608
+ const world = this.camera.screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top });
1609
+ this.openContextMenu?.({ x: e.clientX, y: e.clientY }, world);
1610
+ };
1535
1611
  onPointerLeave = (e) => {
1536
1612
  this.lastPointerEvent = null;
1537
1613
  this.onPointerUp(e);
@@ -1579,12 +1655,36 @@ var InputHandler = class {
1579
1655
  this.element.focus({ preventScroll: true });
1580
1656
  }
1581
1657
  cancelToolIfActive(e) {
1658
+ this.cancelLongPress();
1582
1659
  if (this.isToolActive) {
1583
1660
  this.dispatchToolUp(e);
1584
1661
  this.isToolActive = false;
1585
1662
  }
1586
1663
  this.deferredDown = null;
1587
1664
  }
1665
+ startLongPress(e) {
1666
+ if (e.pointerType !== "touch") return;
1667
+ if (this.toolManager?.activeTool?.name !== "select") return;
1668
+ this.longPressStart = { x: e.clientX, y: e.clientY };
1669
+ this.longPressTimer = setTimeout(() => this.fireLongPress(), LONG_PRESS_MS);
1670
+ }
1671
+ cancelLongPress() {
1672
+ if (this.longPressTimer !== null) {
1673
+ clearTimeout(this.longPressTimer);
1674
+ this.longPressTimer = null;
1675
+ }
1676
+ this.longPressStart = null;
1677
+ }
1678
+ fireLongPress() {
1679
+ this.longPressTimer = null;
1680
+ if (!this.deferredDown || this.activePointers.size !== 1 || this.isPanning) return;
1681
+ const start = this.longPressStart;
1682
+ if (!start) return;
1683
+ const rect = this.element.getBoundingClientRect();
1684
+ const world = this.camera.screenToWorld({ x: start.x - rect.left, y: start.y - rect.top });
1685
+ this.deferredDown = null;
1686
+ this.openContextMenu?.({ x: start.x, y: start.y }, world);
1687
+ }
1588
1688
  };
1589
1689
 
1590
1690
  // src/canvas/background.ts
@@ -2054,11 +2154,19 @@ var ElementStore = class {
2054
2154
  (el) => el.type === type
2055
2155
  );
2056
2156
  }
2157
+ // Spatial index stores the rotation-expanded AABB so rotated elements remain
2158
+ // broad-phase hit-test/marquee candidates; precise tests run against local bounds.
2159
+ indexBounds(element) {
2160
+ const bounds = getElementBounds(element);
2161
+ if (!bounds) return null;
2162
+ const angle = element.rotation ?? 0;
2163
+ return angle === 0 ? bounds : rotatedAABB(bounds, angle);
2164
+ }
2057
2165
  add(element) {
2058
2166
  this.sortedCache = null;
2059
2167
  this._versions.set(element.id, 0);
2060
2168
  this.elements.set(element.id, element);
2061
- const bounds = getElementBounds(element);
2169
+ const bounds = this.indexBounds(element);
2062
2170
  if (bounds) this.spatialIndex.insert(element.id, bounds);
2063
2171
  this.bus.emit("add", element);
2064
2172
  }
@@ -2080,7 +2188,7 @@ var ElementStore = class {
2080
2188
  updated.text = sanitizeNoteHtml(updated.text);
2081
2189
  }
2082
2190
  this.elements.set(id, updated);
2083
- const newBounds = getElementBounds(updated);
2191
+ const newBounds = this.indexBounds(updated);
2084
2192
  if (newBounds) {
2085
2193
  this.spatialIndex.update(id, newBounds);
2086
2194
  }
@@ -2113,7 +2221,7 @@ var ElementStore = class {
2113
2221
  for (const el of elements) {
2114
2222
  this.elements.set(el.id, el);
2115
2223
  this._versions.set(el.id, 0);
2116
- const bounds = getElementBounds(el);
2224
+ const bounds = this.indexBounds(el);
2117
2225
  if (bounds) this.spatialIndex.insert(el.id, bounds);
2118
2226
  if (el.type === "stroke") {
2119
2227
  computeStrokeSegments(el);
@@ -2318,9 +2426,9 @@ function updateBoundArrow(arrow, store) {
2318
2426
  if (arrow.fromBinding) {
2319
2427
  const el = store.getById(arrow.fromBinding.elementId);
2320
2428
  if (el) {
2321
- const center = getElementCenter(el);
2322
- updates.from = center;
2323
- updates.position = center;
2429
+ const center2 = getElementCenter(el);
2430
+ updates.from = center2;
2431
+ updates.position = center2;
2324
2432
  }
2325
2433
  }
2326
2434
  if (arrow.toBinding) {
@@ -2332,6 +2440,21 @@ function updateBoundArrow(arrow, store) {
2332
2440
  return Object.keys(updates).length > 0 ? updates : null;
2333
2441
  }
2334
2442
 
2443
+ // src/elements/rotate-canvas.ts
2444
+ function withRotation(ctx, el, center2, draw) {
2445
+ const angle = el.rotation ?? 0;
2446
+ if (angle === 0) {
2447
+ draw();
2448
+ return;
2449
+ }
2450
+ ctx.save();
2451
+ ctx.translate(center2.x, center2.y);
2452
+ ctx.rotate(angle);
2453
+ ctx.translate(-center2.x, -center2.y);
2454
+ draw();
2455
+ ctx.restore();
2456
+ }
2457
+
2335
2458
  // src/elements/grid-renderer.ts
2336
2459
  function getSquareGridLines(bounds, cellSize) {
2337
2460
  if (cellSize <= 0) return { verticals: [], horizontals: [] };
@@ -2595,18 +2718,18 @@ function getHexDistance(a, b, cellSize, orientation) {
2595
2718
  const ds = -dq - dr;
2596
2719
  return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
2597
2720
  }
2598
- function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
2721
+ function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
2599
2722
  const n = Math.round(radiusCells);
2600
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2723
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2601
2724
  const cube = offsetToCube(off.col, off.row, orientation);
2602
2725
  if (n <= 0) {
2603
2726
  return [offsetToPixel(off.col, off.row, cellSize, orientation)];
2604
2727
  }
2605
2728
  return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
2606
2729
  }
2607
- function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2730
+ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
2608
2731
  const n = Math.round(radiusCells);
2609
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2732
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2610
2733
  const cube = offsetToCube(off.col, off.row, orientation);
2611
2734
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2612
2735
  if (n <= 0) return [centerPixel];
@@ -2640,9 +2763,9 @@ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2640
2763
  }
2641
2764
  return cells;
2642
2765
  }
2643
- function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2766
+ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
2644
2767
  const n = Math.round(radiusCells);
2645
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2768
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2646
2769
  const cube = offsetToCube(off.col, off.row, orientation);
2647
2770
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2648
2771
  if (n <= 0) return [centerPixel];
@@ -2678,9 +2801,9 @@ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2678
2801
  }
2679
2802
  return cells;
2680
2803
  }
2681
- function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
2804
+ function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
2682
2805
  const n = Math.round(radiusCells);
2683
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2806
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2684
2807
  const cube = offsetToCube(off.col, off.row, orientation);
2685
2808
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2686
2809
  if (n <= 0) return [centerPixel];
@@ -2758,18 +2881,27 @@ var ElementRenderer = class {
2758
2881
  }
2759
2882
  renderCanvasElement(ctx, element) {
2760
2883
  switch (element.type) {
2761
- case "stroke":
2762
- this.renderStroke(ctx, element);
2884
+ case "stroke": {
2885
+ const b = getElementBounds(element);
2886
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2887
+ withRotation(ctx, element, c, () => this.renderStroke(ctx, element));
2763
2888
  break;
2889
+ }
2764
2890
  case "arrow":
2765
2891
  this.renderArrow(ctx, element);
2766
2892
  break;
2767
- case "shape":
2768
- this.renderShape(ctx, element);
2893
+ case "shape": {
2894
+ const b = getElementBounds(element);
2895
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2896
+ withRotation(ctx, element, c, () => this.renderShape(ctx, element));
2769
2897
  break;
2770
- case "image":
2771
- this.renderImage(ctx, element);
2898
+ }
2899
+ case "image": {
2900
+ const b = getElementBounds(element);
2901
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2902
+ withRotation(ctx, element, c, () => this.renderImage(ctx, element));
2772
2903
  break;
2904
+ }
2773
2905
  case "grid":
2774
2906
  this.renderGrid(ctx, element);
2775
2907
  break;
@@ -3066,20 +3198,20 @@ var ElementRenderer = class {
3066
3198
  renderHexTemplate(ctx, template, cellSize, orientation) {
3067
3199
  const snapUnit = Math.sqrt(3) * cellSize;
3068
3200
  const radiusCells = template.radius / snapUnit;
3069
- const center = template.position;
3201
+ const center2 = template.position;
3070
3202
  let cells;
3071
3203
  switch (template.templateShape) {
3072
3204
  case "circle":
3073
- cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
3205
+ cells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
3074
3206
  break;
3075
3207
  case "cone":
3076
- cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
3208
+ cells = getHexCellsInCone(center2, template.angle, radiusCells, cellSize, orientation);
3077
3209
  break;
3078
3210
  case "line":
3079
- cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
3211
+ cells = getHexCellsInLine(center2, template.angle, radiusCells, cellSize, orientation);
3080
3212
  break;
3081
3213
  case "square":
3082
- cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
3214
+ cells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
3083
3215
  break;
3084
3216
  }
3085
3217
  ctx.save();
@@ -3100,7 +3232,7 @@ var ElementRenderer = class {
3100
3232
  {
3101
3233
  ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
3102
3234
  ctx.beginPath();
3103
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
3235
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
3104
3236
  ctx.fillStyle = template.strokeColor;
3105
3237
  ctx.fill();
3106
3238
  ctx.strokeStyle = template.strokeColor;
@@ -3109,7 +3241,7 @@ var ElementRenderer = class {
3109
3241
  }
3110
3242
  if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
3111
3243
  const r = template.radius;
3112
- this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
3244
+ this.renderRadiusMarker(ctx, center2.x, center2.y, r, template.radiusFeet);
3113
3245
  }
3114
3246
  ctx.restore();
3115
3247
  }
@@ -3739,6 +3871,87 @@ var NoteEditor = class {
3739
3871
  }
3740
3872
  };
3741
3873
 
3874
+ // src/canvas/context-menu.ts
3875
+ var ContextMenu = class {
3876
+ constructor(options) {
3877
+ this.options = options;
3878
+ }
3879
+ el = null;
3880
+ outsideListener = null;
3881
+ keyListener = null;
3882
+ isOpen() {
3883
+ return this.el !== null;
3884
+ }
3885
+ open(items, screenPos) {
3886
+ this.close();
3887
+ const el = document.createElement("div");
3888
+ el.className = "fieldnotes-context-menu";
3889
+ Object.assign(el.style, {
3890
+ position: "fixed",
3891
+ left: `${screenPos.x}px`,
3892
+ top: `${screenPos.y}px`,
3893
+ zIndex: "10000",
3894
+ display: "flex",
3895
+ flexDirection: "column"
3896
+ });
3897
+ for (const item of items) {
3898
+ const btn = document.createElement("button");
3899
+ btn.type = "button";
3900
+ btn.className = "fieldnotes-context-menu-item" + (item.disabled ? " fieldnotes-context-menu-item--disabled" : "");
3901
+ btn.textContent = item.label;
3902
+ if (item.disabled) {
3903
+ btn.disabled = true;
3904
+ } else {
3905
+ btn.addEventListener("click", () => {
3906
+ this.options.onCommand(item.action);
3907
+ this.close();
3908
+ });
3909
+ }
3910
+ el.appendChild(btn);
3911
+ }
3912
+ document.body.appendChild(el);
3913
+ this.el = el;
3914
+ this.clampToViewport(el, screenPos);
3915
+ this.keyListener = (e) => {
3916
+ if (e.key === "Escape") this.close();
3917
+ };
3918
+ document.addEventListener("keydown", this.keyListener);
3919
+ this.outsideListener = (e) => {
3920
+ if (this.el && !this.el.contains(e.target)) this.close();
3921
+ };
3922
+ setTimeout(() => {
3923
+ if (this.outsideListener) document.addEventListener("pointerdown", this.outsideListener);
3924
+ }, 0);
3925
+ }
3926
+ close() {
3927
+ if (this.keyListener) {
3928
+ document.removeEventListener("keydown", this.keyListener);
3929
+ this.keyListener = null;
3930
+ }
3931
+ if (this.outsideListener) {
3932
+ document.removeEventListener("pointerdown", this.outsideListener);
3933
+ this.outsideListener = null;
3934
+ }
3935
+ if (this.el) {
3936
+ this.el.remove();
3937
+ this.el = null;
3938
+ this.options.onClose();
3939
+ }
3940
+ }
3941
+ dispose() {
3942
+ this.close();
3943
+ }
3944
+ clampToViewport(el, screenPos) {
3945
+ const rect = el.getBoundingClientRect();
3946
+ if (rect.width > 0 && screenPos.x + rect.width > window.innerWidth) {
3947
+ el.style.left = `${Math.max(0, screenPos.x - rect.width)}px`;
3948
+ }
3949
+ if (rect.height > 0 && screenPos.y + rect.height > window.innerHeight) {
3950
+ el.style.top = `${Math.max(0, screenPos.y - rect.height)}px`;
3951
+ }
3952
+ }
3953
+ };
3954
+
3742
3955
  // src/elements/translate.ts
3743
3956
  function translateElementPatch(el, dx, dy) {
3744
3957
  const position = { x: el.position.x + dx, y: el.position.y + dy };
@@ -4228,6 +4441,7 @@ function renderStyledRuns(ctx, runs, startX, startY, maxWidth) {
4228
4441
  }
4229
4442
 
4230
4443
  // src/canvas/export-image.ts
4444
+ var center = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
4231
4445
  function getStrokeBounds(el) {
4232
4446
  if (el.type !== "stroke") return null;
4233
4447
  if (el.points.length === 0) return null;
@@ -4253,8 +4467,10 @@ function getStrokeBounds(el) {
4253
4467
  }
4254
4468
  function getElementRect(el) {
4255
4469
  switch (el.type) {
4256
- case "stroke":
4257
- return getStrokeBounds(el);
4470
+ case "stroke": {
4471
+ const r = getStrokeBounds(el);
4472
+ return r ? rotatedAABB(r, el.rotation ?? 0) : r;
4473
+ }
4258
4474
  case "arrow": {
4259
4475
  const b = getArrowBounds(el.from, el.to, el.bend);
4260
4476
  const pad = el.width / 2 + 14;
@@ -4273,7 +4489,10 @@ function getElementRect(el) {
4273
4489
  case "text":
4274
4490
  case "shape":
4275
4491
  if ("size" in el) {
4276
- return { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
4492
+ return rotatedAABB(
4493
+ { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h },
4494
+ el.rotation ?? 0
4495
+ );
4277
4496
  }
4278
4497
  return null;
4279
4498
  default:
@@ -4411,11 +4630,13 @@ async function exportImage(store, options = {}, layerManager) {
4411
4630
  continue;
4412
4631
  }
4413
4632
  if (el.type === "note") {
4414
- renderNoteOnCanvas(ctx, el);
4633
+ const b = getElementBounds(el);
4634
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderNoteOnCanvas(ctx, el));
4415
4635
  continue;
4416
4636
  }
4417
4637
  if (el.type === "text") {
4418
- renderTextOnCanvas(ctx, el);
4638
+ const b = getElementBounds(el);
4639
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderTextOnCanvas(ctx, el));
4419
4640
  continue;
4420
4641
  }
4421
4642
  if (el.type === "html") {
@@ -4424,7 +4645,13 @@ async function exportImage(store, options = {}, layerManager) {
4424
4645
  if (el.type === "image") {
4425
4646
  const img = imageCache.get(el.id);
4426
4647
  if (img) {
4427
- ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h);
4648
+ const b = getElementBounds(el);
4649
+ withRotation(
4650
+ ctx,
4651
+ el,
4652
+ b ? center(b) : el.position,
4653
+ () => ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h)
4654
+ );
4428
4655
  }
4429
4656
  continue;
4430
4657
  }
@@ -4760,7 +4987,9 @@ var DomNodeManager = class {
4760
4987
  top: `${element.position.y}px`,
4761
4988
  width: size ? `${size.w}px` : "auto",
4762
4989
  height: size ? `${size.h}px` : "auto",
4763
- zIndex: String(zIndex)
4990
+ zIndex: String(zIndex),
4991
+ transform: element.rotation ? `rotate(${element.rotation}rad)` : "",
4992
+ transformOrigin: "50% 50%"
4764
4993
  });
4765
4994
  this.renderDomContent(node, element);
4766
4995
  }
@@ -5539,8 +5768,20 @@ var Viewport = class {
5539
5768
  fitToContent: () => this.fitToContent(),
5540
5769
  group: () => this.groupSelection(),
5541
5770
  ungroup: () => this.ungroupSelection(),
5771
+ toggleLock: () => this.toggleLockSelection(),
5772
+ openContextMenu: (screenPos, world) => {
5773
+ this.getSelectTool()?.selectAtPoint(world, this.toolContext);
5774
+ this.openContextMenu(screenPos);
5775
+ },
5542
5776
  shortcuts: options.shortcuts
5543
5777
  });
5778
+ if (options.contextMenu !== false) {
5779
+ this.contextMenu = new ContextMenu({
5780
+ onCommand: (action) => this.runAction(action),
5781
+ onClose: noop
5782
+ });
5783
+ }
5784
+ this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
5544
5785
  this.domNodeManager = new DomNodeManager({
5545
5786
  domLayer: this.domLayer,
5546
5787
  onEditRequest: (id) => this.startEditingElement(id),
@@ -5572,6 +5813,7 @@ var Viewport = class {
5572
5813
  this.unsubCamera = this.camera.onChange(() => {
5573
5814
  this.applyCameraTransform();
5574
5815
  this.noteEditor.updateToolbarPosition();
5816
+ this.contextMenu?.close();
5575
5817
  this.requestRender();
5576
5818
  });
5577
5819
  this.unsubStore = [
@@ -5624,6 +5866,7 @@ var Viewport = class {
5624
5866
  canvasEl;
5625
5867
  wrapper;
5626
5868
  unsubCamera;
5869
+ unsubToolChange;
5627
5870
  unsubStore;
5628
5871
  inputHandler;
5629
5872
  background;
@@ -5646,6 +5889,7 @@ var Viewport = class {
5646
5889
  doubleTapDetector = new DoubleTapDetector();
5647
5890
  tapDownX = 0;
5648
5891
  tapDownY = 0;
5892
+ contextMenu = null;
5649
5893
  get ctx() {
5650
5894
  return this.canvasEl.getContext("2d");
5651
5895
  }
@@ -5836,6 +6080,34 @@ var Viewport = class {
5836
6080
  getSelectedIds() {
5837
6081
  return this.getSelectTool()?.selectedIds ?? EMPTY_IDS;
5838
6082
  }
6083
+ runAction(action) {
6084
+ this.inputHandler.runAction(action);
6085
+ }
6086
+ canPaste() {
6087
+ return this.inputHandler.hasClipboard();
6088
+ }
6089
+ openContextMenu(screenPos) {
6090
+ if (!this.contextMenu) return;
6091
+ const ids = this.getSelectedIds();
6092
+ const items = [];
6093
+ if (ids.length > 0) {
6094
+ items.push({ label: "Cut", action: "cut" });
6095
+ items.push({ label: "Copy", action: "copy" });
6096
+ if (this.canPaste()) items.push({ label: "Paste", action: "paste" });
6097
+ items.push({ label: "Duplicate", action: "duplicate" });
6098
+ items.push({ label: "Delete", action: "delete" });
6099
+ items.push({ label: "Bring to Front", action: "z-front" });
6100
+ items.push({ label: "Bring Forward", action: "z-forward" });
6101
+ items.push({ label: "Send Backward", action: "z-backward" });
6102
+ items.push({ label: "Send to Back", action: "z-back" });
6103
+ const allLocked = ids.every((id) => this.store.getById(id)?.locked);
6104
+ items.push({ label: allLocked ? "Unlock" : "Lock", action: "toggle-lock" });
6105
+ } else if (this.canPaste()) {
6106
+ items.push({ label: "Paste", action: "paste" });
6107
+ }
6108
+ if (items.length === 0) return;
6109
+ this.contextMenu.open(items, screenPos);
6110
+ }
5839
6111
  onSelectionChange(listener) {
5840
6112
  const tool = this.getSelectTool();
5841
6113
  return tool ? tool.onSelectionChange(listener) : noop;
@@ -5896,6 +6168,20 @@ var Viewport = class {
5896
6168
  }
5897
6169
  this.historyRecorder.commit();
5898
6170
  }
6171
+ toggleLockSelection() {
6172
+ const ids = this.getSelectedIds();
6173
+ if (ids.length === 0) return;
6174
+ const anyUnlocked = ids.some((id) => {
6175
+ const el = this.store.getById(id);
6176
+ return el ? !el.locked : false;
6177
+ });
6178
+ this.historyRecorder.begin();
6179
+ for (const id of ids) {
6180
+ const el = this.store.getById(id);
6181
+ if (el && el.locked !== anyUnlocked) this.store.update(id, { locked: anyUnlocked });
6182
+ }
6183
+ this.historyRecorder.commit();
6184
+ }
5899
6185
  alignSelection(edge) {
5900
6186
  const bounded = this.boundedSelection();
5901
6187
  if (bounded.length < 2) return;
@@ -5937,13 +6223,13 @@ var Viewport = class {
5937
6223
  distributeSelection(axis) {
5938
6224
  const bounded = this.boundedSelection();
5939
6225
  if (bounded.length < 3) return;
5940
- const center = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
5941
- const sorted = [...bounded].sort((p, q) => center(p.bounds) - center(q.bounds));
6226
+ const center2 = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
6227
+ const sorted = [...bounded].sort((p, q) => center2(p.bounds) - center2(q.bounds));
5942
6228
  const first = sorted[0];
5943
6229
  const last = sorted[sorted.length - 1];
5944
6230
  if (!first || !last) return;
5945
- const c0 = center(first.bounds);
5946
- const cN = center(last.bounds);
6231
+ const c0 = center2(first.bounds);
6232
+ const cN = center2(last.bounds);
5947
6233
  const n = sorted.length;
5948
6234
  this.historyRecorder.begin();
5949
6235
  const moved = [];
@@ -5951,7 +6237,7 @@ var Viewport = class {
5951
6237
  const item = sorted[i];
5952
6238
  if (!item || !this.isMovable(item.el)) continue;
5953
6239
  const target = c0 + i * (cN - c0) / (n - 1);
5954
- const delta = target - center(item.bounds);
6240
+ const delta = target - center2(item.bounds);
5955
6241
  if (delta === 0) continue;
5956
6242
  const [dx, dy] = axis === "horizontal" ? [delta, 0] : [0, delta];
5957
6243
  this.store.update(item.id, translateElementPatch(item.el, dx, dy));
@@ -5994,12 +6280,14 @@ var Viewport = class {
5994
6280
  this.noteEditor.destroy(this.store);
5995
6281
  this.arrowLabelEditor.cancel();
5996
6282
  this.historyRecorder.destroy();
6283
+ this.contextMenu?.dispose();
5997
6284
  this.wrapper.removeEventListener("pointerdown", this.onTapDown);
5998
6285
  this.wrapper.removeEventListener("pointerup", this.onDoubleTap);
5999
6286
  this.wrapper.removeEventListener("dragover", this.onDragOver);
6000
6287
  this.wrapper.removeEventListener("drop", this.onDrop);
6001
6288
  this.inputHandler.destroy();
6002
6289
  this.unsubCamera();
6290
+ this.unsubToolChange();
6003
6291
  this.unsubStore.forEach((fn) => fn());
6004
6292
  this.resizeObserver?.disconnect();
6005
6293
  this.resizeObserver = null;
@@ -6674,10 +6962,10 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6674
6962
  const excludeId = el.toBinding?.elementId;
6675
6963
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6676
6964
  if (target) {
6677
- const center = getElementCenter(target);
6965
+ const center2 = getElementCenter(target);
6678
6966
  ctx.store.update(elementId, {
6679
- from: center,
6680
- position: center,
6967
+ from: center2,
6968
+ position: center2,
6681
6969
  fromBinding: { elementId: target.id }
6682
6970
  });
6683
6971
  } else {
@@ -6693,9 +6981,9 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6693
6981
  const excludeId = el.fromBinding?.elementId;
6694
6982
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6695
6983
  if (target) {
6696
- const center = getElementCenter(target);
6984
+ const center2 = getElementCenter(target);
6697
6985
  ctx.store.update(elementId, {
6698
- to: center,
6986
+ to: center2,
6699
6987
  toBinding: { elementId: target.id }
6700
6988
  });
6701
6989
  } else {
@@ -6784,6 +7072,9 @@ var SNAP_PX = 6;
6784
7072
  var HANDLE_HIT_PADDING2 = 4;
6785
7073
  var SELECTION_PAD = 4;
6786
7074
  var MIN_ELEMENT_SIZE = 20;
7075
+ var ROTATE_HANDLE_OFFSET = 24;
7076
+ var ROTATE_SNAP = Math.PI / 12;
7077
+ var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
6787
7078
  var HANDLE_CURSORS = {
6788
7079
  nw: "nwse-resize",
6789
7080
  se: "nwse-resize",
@@ -6824,6 +7115,15 @@ var SelectTool = class {
6824
7115
  this.setSelectedIds(ids);
6825
7116
  this.ctx?.requestRender();
6826
7117
  }
7118
+ selectAtPoint(world, ctx) {
7119
+ const hit = this.hitTest(world, ctx);
7120
+ if (!hit) {
7121
+ this.setSelectedIds([]);
7122
+ return;
7123
+ }
7124
+ if (this._selectedIds.includes(hit.id)) return;
7125
+ this.setSelectedIds(expandToGroups([hit.id], ctx.store.getAll()));
7126
+ }
6827
7127
  get isMarqueeActive() {
6828
7128
  return this.mode.type === "marquee";
6829
7129
  }
@@ -6872,6 +7172,22 @@ var SelectTool = class {
6872
7172
  ctx.requestRender();
6873
7173
  return;
6874
7174
  }
7175
+ const rotateHit = this.hitTestRotateHandle(world, ctx);
7176
+ if (rotateHit) {
7177
+ const el = ctx.store.getById(rotateHit.elementId);
7178
+ const layout = el ? this.getOverlayLayout(el, ctx.camera.zoom) : null;
7179
+ if (el && layout) {
7180
+ this.mode = {
7181
+ type: "rotating",
7182
+ elementId: rotateHit.elementId,
7183
+ center: layout.center,
7184
+ startPointerAngle: Math.atan2(world.y - layout.center.y, world.x - layout.center.x),
7185
+ startRotation: el.rotation ?? 0
7186
+ };
7187
+ ctx.requestRender();
7188
+ return;
7189
+ }
7190
+ }
6875
7191
  const resizeHit = this.hitTestResizeHandle(world, ctx);
6876
7192
  if (resizeHit) {
6877
7193
  const el = ctx.store.getById(resizeHit.elementId);
@@ -6937,6 +7253,15 @@ var SelectTool = class {
6937
7253
  this.handleTemplateResize(world, ctx);
6938
7254
  return;
6939
7255
  }
7256
+ if (this.mode.type === "rotating") {
7257
+ const { elementId, center: center2, startPointerAngle, startRotation } = this.mode;
7258
+ const a = Math.atan2(world.y - center2.y, world.x - center2.x);
7259
+ let next = startRotation + (a - startPointerAngle);
7260
+ if (state.shiftKey) next = Math.round(next / ROTATE_SNAP) * ROTATE_SNAP;
7261
+ ctx.store.update(elementId, { rotation: normalizeAngle(next) });
7262
+ ctx.requestRender();
7263
+ return;
7264
+ }
6940
7265
  if (this.mode.type === "resizing") {
6941
7266
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
6942
7267
  this.handleResize(world, ctx, state.shiftKey);
@@ -7139,6 +7464,10 @@ var SelectTool = class {
7139
7464
  ctx.setCursor?.("nwse-resize");
7140
7465
  return null;
7141
7466
  }
7467
+ if (this.hitTestRotateHandle(world, ctx)) {
7468
+ ctx.setCursor?.("grab");
7469
+ return null;
7470
+ }
7142
7471
  const resizeHit = this.hitTestResizeHandle(world, ctx);
7143
7472
  if (resizeHit) {
7144
7473
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -7157,6 +7486,11 @@ var SelectTool = class {
7157
7486
  if (this.mode.type !== "resizing") return;
7158
7487
  const el = ctx.store.getById(this.mode.elementId);
7159
7488
  if (!el || !("size" in el) || el.locked) return;
7489
+ const angle = el.rotation ?? 0;
7490
+ if (angle !== 0) {
7491
+ this.handleRotatedResize(world, el, angle, ctx, shiftKey);
7492
+ return;
7493
+ }
7160
7494
  const { handle } = this.mode;
7161
7495
  const dx = world.x - this.lastWorld.x;
7162
7496
  const dy = world.y - this.lastWorld.y;
@@ -7214,6 +7548,78 @@ var SelectTool = class {
7214
7548
  this.updateArrowsBoundTo([this.mode.elementId], ctx);
7215
7549
  ctx.requestRender();
7216
7550
  }
7551
+ anchorOffset(handle, w, h) {
7552
+ switch (handle) {
7553
+ case "se":
7554
+ return { x: -w / 2, y: -h / 2 };
7555
+ case "sw":
7556
+ return { x: w / 2, y: -h / 2 };
7557
+ case "ne":
7558
+ return { x: -w / 2, y: h / 2 };
7559
+ case "nw":
7560
+ return { x: w / 2, y: h / 2 };
7561
+ default:
7562
+ return { x: 0, y: 0 };
7563
+ }
7564
+ }
7565
+ handleRotatedResize(world, el, angle, ctx, shiftKey) {
7566
+ if (this.mode.type !== "resizing") return;
7567
+ const { handle } = this.mode;
7568
+ const wdx = world.x - this.lastWorld.x;
7569
+ const wdy = world.y - this.lastWorld.y;
7570
+ this.lastWorld = world;
7571
+ const cosN = Math.cos(-angle);
7572
+ const sinN = Math.sin(-angle);
7573
+ const ldx = wdx * cosN - wdy * sinN;
7574
+ const ldy = wdx * sinN + wdy * cosN;
7575
+ let w = el.size.w;
7576
+ let h = el.size.h;
7577
+ switch (handle) {
7578
+ case "se":
7579
+ w += ldx;
7580
+ h += ldy;
7581
+ break;
7582
+ case "sw":
7583
+ w -= ldx;
7584
+ h += ldy;
7585
+ break;
7586
+ case "ne":
7587
+ w += ldx;
7588
+ h -= ldy;
7589
+ break;
7590
+ case "nw":
7591
+ w -= ldx;
7592
+ h -= ldy;
7593
+ break;
7594
+ }
7595
+ if (shiftKey && this.resizeAspectRatio > 0) {
7596
+ const absDw = Math.abs(w - el.size.w);
7597
+ const absDh = Math.abs(h - el.size.h);
7598
+ if (absDw >= absDh) h = w / this.resizeAspectRatio;
7599
+ else w = h * this.resizeAspectRatio;
7600
+ }
7601
+ w = Math.max(w, MIN_ELEMENT_SIZE);
7602
+ h = Math.max(h, MIN_ELEMENT_SIZE);
7603
+ const oldCenter = { x: el.position.x + el.size.w / 2, y: el.position.y + el.size.h / 2 };
7604
+ const oldAnchorLocal = this.anchorOffset(handle, el.size.w, el.size.h);
7605
+ const anchorWorld = rotatePoint(
7606
+ { x: oldCenter.x + oldAnchorLocal.x, y: oldCenter.y + oldAnchorLocal.y },
7607
+ oldCenter,
7608
+ angle
7609
+ );
7610
+ const newAnchorLocal = this.anchorOffset(handle, w, h);
7611
+ const cos = Math.cos(angle);
7612
+ const sin = Math.sin(angle);
7613
+ const rotatedAnchor = {
7614
+ x: newAnchorLocal.x * cos - newAnchorLocal.y * sin,
7615
+ y: newAnchorLocal.x * sin + newAnchorLocal.y * cos
7616
+ };
7617
+ const newCenter = { x: anchorWorld.x - rotatedAnchor.x, y: anchorWorld.y - rotatedAnchor.y };
7618
+ const position = { x: newCenter.x - w / 2, y: newCenter.y - h / 2 };
7619
+ ctx.store.update(this.mode.elementId, { position, size: { w, h } });
7620
+ this.updateArrowsBoundTo([this.mode.elementId], ctx);
7621
+ ctx.requestRender();
7622
+ }
7217
7623
  hitTestResizeHandle(world, ctx) {
7218
7624
  if (this._selectedIds.length === 0) return null;
7219
7625
  const zoom = ctx.camera.zoom;
@@ -7221,11 +7627,11 @@ var SelectTool = class {
7221
7627
  for (const id of this._selectedIds) {
7222
7628
  const el = ctx.store.getById(id);
7223
7629
  if (!el || !("size" in el)) continue;
7630
+ if (el.locked) continue;
7224
7631
  if (el.type === "shape" && el.shape === "line") continue;
7225
- const bounds = getElementBounds(el);
7226
- if (!bounds) continue;
7227
- const corners = this.getHandlePositions(bounds);
7228
- for (const [handle, pos] of corners) {
7632
+ const layout = this.getOverlayLayout(el, zoom);
7633
+ if (!layout) continue;
7634
+ for (const [handle, pos] of layout.corners) {
7229
7635
  if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
7230
7636
  return { elementId: id, handle };
7231
7637
  }
@@ -7233,6 +7639,19 @@ var SelectTool = class {
7233
7639
  }
7234
7640
  return null;
7235
7641
  }
7642
+ hitTestRotateHandle(world, ctx) {
7643
+ if (this._selectedIds.length !== 1) return null;
7644
+ const id = this._selectedIds[0];
7645
+ if (!id) return null;
7646
+ const el = ctx.store.getById(id);
7647
+ if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
7648
+ const layout = this.getOverlayLayout(el, ctx.camera.zoom);
7649
+ if (!layout) return null;
7650
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
7651
+ const dx = world.x - layout.rotateHandle.x;
7652
+ const dy = world.y - layout.rotateHandle.y;
7653
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
7654
+ }
7236
7655
  hitTestLineHandles(world, ctx) {
7237
7656
  if (this._selectedIds.length === 0) return null;
7238
7657
  const zoom = ctx.camera.zoom;
@@ -7255,6 +7674,30 @@ var SelectTool = class {
7255
7674
  ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
7256
7675
  ];
7257
7676
  }
7677
+ getOverlayLayout(el, zoom) {
7678
+ const bounds = getElementBounds(el);
7679
+ if (!bounds) return null;
7680
+ const angle = el.rotation ?? 0;
7681
+ const pad = SELECTION_PAD / zoom;
7682
+ const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
7683
+ const raw = [
7684
+ ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
7685
+ ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
7686
+ ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
7687
+ ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
7688
+ ];
7689
+ const corners = raw.map(
7690
+ ([h, p]) => [h, rotatePoint(p, center2, angle)]
7691
+ );
7692
+ const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
7693
+ const rotateHandle = rotatePoint(topMid, center2, angle);
7694
+ return { center: center2, corners, rotateHandle, angle };
7695
+ }
7696
+ topMidpoint(layout) {
7697
+ const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
7698
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
7699
+ return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
7700
+ }
7258
7701
  renderMarquee(canvasCtx) {
7259
7702
  if (this.mode.type !== "marquee") return;
7260
7703
  const rect = this.getMarqueeRect();
@@ -7299,49 +7742,110 @@ var SelectTool = class {
7299
7742
  }
7300
7743
  const bounds = getElementBounds(el);
7301
7744
  if (!bounds) continue;
7745
+ const layout = this.getOverlayLayout(el, zoom);
7746
+ if (!layout) continue;
7302
7747
  const pad = SELECTION_PAD / zoom;
7303
- canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
7304
- if ("size" in el) {
7305
- canvasCtx.setLineDash([]);
7306
- canvasCtx.fillStyle = "#ffffff";
7307
- const corners = this.getHandlePositions(bounds);
7308
- for (const [, pos] of corners) {
7748
+ if (layout.angle === 0) {
7749
+ canvasCtx.strokeRect(
7750
+ bounds.x - pad,
7751
+ bounds.y - pad,
7752
+ bounds.w + pad * 2,
7753
+ bounds.h + pad * 2
7754
+ );
7755
+ } else {
7756
+ const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((p) => !!p);
7757
+ const [p0, ...others] = ordered;
7758
+ if (p0) {
7759
+ canvasCtx.beginPath();
7760
+ canvasCtx.moveTo(p0.x, p0.y);
7761
+ for (const p of others) canvasCtx.lineTo(p.x, p.y);
7762
+ canvasCtx.closePath();
7763
+ canvasCtx.stroke();
7764
+ }
7765
+ }
7766
+ if (!el.locked) {
7767
+ if ("size" in el) {
7768
+ canvasCtx.setLineDash([]);
7769
+ canvasCtx.fillStyle = "#ffffff";
7770
+ const corners = layout.angle === 0 ? this.getHandlePositions(bounds) : layout.corners;
7771
+ for (const [, pos] of corners) {
7772
+ canvasCtx.fillRect(
7773
+ pos.x - handleWorldSize / 2,
7774
+ pos.y - handleWorldSize / 2,
7775
+ handleWorldSize,
7776
+ handleWorldSize
7777
+ );
7778
+ canvasCtx.strokeRect(
7779
+ pos.x - handleWorldSize / 2,
7780
+ pos.y - handleWorldSize / 2,
7781
+ handleWorldSize,
7782
+ handleWorldSize
7783
+ );
7784
+ }
7785
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7786
+ } else if (el.type === "template") {
7787
+ canvasCtx.setLineDash([]);
7788
+ canvasCtx.fillStyle = "#ffffff";
7789
+ const hx = bounds.x + bounds.w;
7790
+ const hy = bounds.y + bounds.h;
7309
7791
  canvasCtx.fillRect(
7310
- pos.x - handleWorldSize / 2,
7311
- pos.y - handleWorldSize / 2,
7792
+ hx - handleWorldSize / 2,
7793
+ hy - handleWorldSize / 2,
7312
7794
  handleWorldSize,
7313
7795
  handleWorldSize
7314
7796
  );
7315
7797
  canvasCtx.strokeRect(
7316
- pos.x - handleWorldSize / 2,
7317
- pos.y - handleWorldSize / 2,
7798
+ hx - handleWorldSize / 2,
7799
+ hy - handleWorldSize / 2,
7318
7800
  handleWorldSize,
7319
7801
  handleWorldSize
7320
7802
  );
7803
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7804
+ }
7805
+ if (this._selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
7806
+ const stemStart = this.topMidpoint(layout);
7807
+ const stemEnd = layout.rotateHandle;
7808
+ canvasCtx.beginPath();
7809
+ canvasCtx.moveTo(stemStart.x, stemStart.y);
7810
+ canvasCtx.lineTo(stemEnd.x, stemEnd.y);
7811
+ canvasCtx.stroke();
7812
+ canvasCtx.setLineDash([]);
7813
+ canvasCtx.fillStyle = "#ffffff";
7814
+ canvasCtx.beginPath();
7815
+ canvasCtx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
7816
+ canvasCtx.fill();
7817
+ canvasCtx.stroke();
7818
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7321
7819
  }
7322
- canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7323
- } else if (el.type === "template") {
7324
- canvasCtx.setLineDash([]);
7325
- canvasCtx.fillStyle = "#ffffff";
7326
- const hx = bounds.x + bounds.w;
7327
- const hy = bounds.y + bounds.h;
7328
- canvasCtx.fillRect(
7329
- hx - handleWorldSize / 2,
7330
- hy - handleWorldSize / 2,
7331
- handleWorldSize,
7332
- handleWorldSize
7333
- );
7334
- canvasCtx.strokeRect(
7335
- hx - handleWorldSize / 2,
7336
- hy - handleWorldSize / 2,
7337
- handleWorldSize,
7338
- handleWorldSize
7339
- );
7340
- canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7820
+ }
7821
+ if (el.locked) {
7822
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1];
7823
+ if (ne) this.drawLockBadge(canvasCtx, ne, zoom);
7341
7824
  }
7342
7825
  }
7343
7826
  canvasCtx.restore();
7344
7827
  }
7828
+ drawLockBadge(ctx, at, zoom) {
7829
+ const r = 9 / zoom;
7830
+ ctx.save();
7831
+ ctx.setLineDash([]);
7832
+ ctx.beginPath();
7833
+ ctx.arc(at.x, at.y, r, 0, Math.PI * 2);
7834
+ ctx.fillStyle = "#ffffff";
7835
+ ctx.fill();
7836
+ ctx.strokeStyle = "#2196F3";
7837
+ ctx.lineWidth = 1.5 / zoom;
7838
+ ctx.stroke();
7839
+ const bw = 8 / zoom;
7840
+ const bh = 6 / zoom;
7841
+ ctx.fillStyle = "#2196F3";
7842
+ ctx.fillRect(at.x - bw / 2, at.y - bh / 2 + 1 / zoom, bw, bh);
7843
+ ctx.beginPath();
7844
+ ctx.arc(at.x, at.y - bh / 2 + 1 / zoom, 2.5 / zoom, Math.PI, 0);
7845
+ ctx.lineWidth = 1.4 / zoom;
7846
+ ctx.stroke();
7847
+ ctx.restore();
7848
+ }
7345
7849
  renderBindingHighlights(canvasCtx, arrow, zoom) {
7346
7850
  if (!this.ctx) return;
7347
7851
  if (!arrow.fromBinding && !arrow.toBinding) return;
@@ -7418,7 +7922,7 @@ var SelectTool = class {
7418
7922
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
7419
7923
  if (el.type === "grid") continue;
7420
7924
  const bounds = getElementBounds(el);
7421
- if (bounds && this.rectsOverlap(marquee, bounds)) {
7925
+ if (bounds && this.rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
7422
7926
  ids.push(el.id);
7423
7927
  }
7424
7928
  }
@@ -7440,6 +7944,13 @@ var SelectTool = class {
7440
7944
  }
7441
7945
  isInsideBounds(point, el) {
7442
7946
  if (el.type === "grid") return false;
7947
+ const angle = el.rotation ?? 0;
7948
+ if (angle !== 0) {
7949
+ const b = getElementBounds(el);
7950
+ if (b) {
7951
+ point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
7952
+ }
7953
+ }
7443
7954
  if (el.type === "shape" && el.shape === "line") {
7444
7955
  const [a, b] = lineEndpoints(el);
7445
7956
  const threshold = Math.max(el.strokeWidth / 2, 6);
@@ -8206,20 +8717,20 @@ var TemplateTool = class {
8206
8717
  const snapUnit = Math.sqrt(3) * cellSize;
8207
8718
  const radiusCells = radius / snapUnit;
8208
8719
  const angle = this.computeAngle();
8209
- const center = this.origin;
8720
+ const center2 = this.origin;
8210
8721
  let hexCells;
8211
8722
  switch (this.templateShape) {
8212
8723
  case "circle":
8213
- hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
8724
+ hexCells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
8214
8725
  break;
8215
8726
  case "cone":
8216
- hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
8727
+ hexCells = getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation);
8217
8728
  break;
8218
8729
  case "line":
8219
- hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
8730
+ hexCells = getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation);
8220
8731
  break;
8221
8732
  case "square":
8222
- hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
8733
+ hexCells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
8223
8734
  break;
8224
8735
  }
8225
8736
  ctx.save();
@@ -8240,7 +8751,7 @@ var TemplateTool = class {
8240
8751
  if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
8241
8752
  ctx.globalAlpha = 0.5;
8242
8753
  ctx.beginPath();
8243
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
8754
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
8244
8755
  ctx.fillStyle = this.strokeColor;
8245
8756
  ctx.fill();
8246
8757
  ctx.strokeStyle = this.strokeColor;
@@ -8256,8 +8767,8 @@ var TemplateTool = class {
8256
8767
  ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
8257
8768
  ctx.textAlign = "center";
8258
8769
  ctx.textBaseline = "bottom";
8259
- const textX = center.x;
8260
- const textY = center.y - 4;
8770
+ const textX = center2.x;
8771
+ const textY = center2.y - 4;
8261
8772
  const metrics = ctx.measureText(label);
8262
8773
  const padX = 4;
8263
8774
  const padY = 2;
@@ -8307,7 +8818,7 @@ var TemplateTool = class {
8307
8818
  };
8308
8819
 
8309
8820
  // src/index.ts
8310
- var VERSION = "0.34.0";
8821
+ var VERSION = "0.36.0";
8311
8822
  export {
8312
8823
  ArrowTool,
8313
8824
  AutoSave,