@fieldnotes/core 0.33.0 → 0.35.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) {
@@ -1021,6 +1051,14 @@ var KeyboardActions = class {
1021
1051
  if (this.deps.isToolActive()) return;
1022
1052
  this.deps.fitToContent?.();
1023
1053
  }
1054
+ group() {
1055
+ if (this.deps.isToolActive()) return;
1056
+ this.deps.group?.();
1057
+ }
1058
+ ungroup() {
1059
+ if (this.deps.isToolActive()) return;
1060
+ this.deps.ungroup?.();
1061
+ }
1024
1062
  zOrder(operation) {
1025
1063
  if (this.deps.isToolActive()) return;
1026
1064
  this.flushPendingNudge();
@@ -1054,6 +1092,10 @@ var KeyboardActions = class {
1054
1092
  for (const el of source) {
1055
1093
  idMap.set(el.id, createId(el.type));
1056
1094
  }
1095
+ const groupIdMap = /* @__PURE__ */ new Map();
1096
+ for (const el of source) {
1097
+ if (el.groupId && !groupIdMap.has(el.groupId)) groupIdMap.set(el.groupId, createId("group"));
1098
+ }
1057
1099
  const newIds = [];
1058
1100
  const recorder = this.deps.getHistoryRecorder();
1059
1101
  recorder?.begin();
@@ -1062,6 +1104,7 @@ var KeyboardActions = class {
1062
1104
  const newId = idMap.get(el.id);
1063
1105
  if (!newId) continue;
1064
1106
  clone.id = newId;
1107
+ if (clone.groupId) clone.groupId = groupIdMap.get(clone.groupId) ?? clone.groupId;
1065
1108
  clone.position = { x: clone.position.x + offset.x, y: clone.position.y + offset.y };
1066
1109
  if (clone.type === "arrow") {
1067
1110
  const arrow = clone;
@@ -1115,6 +1158,8 @@ var DEFAULT_BINDINGS = [
1115
1158
  ["zoom-in", ["mod+="]],
1116
1159
  ["zoom-out", ["mod+-"]],
1117
1160
  ["zoom-reset", ["mod+0"]],
1161
+ ["group", ["mod+g"]],
1162
+ ["ungroup", ["mod+shift+g"]],
1118
1163
  ["nudge-left", ["arrowleft"]],
1119
1164
  ["nudge-right", ["arrowright"]],
1120
1165
  ["nudge-up", ["arrowup"]],
@@ -1274,6 +1319,8 @@ var InputHandler = class {
1274
1319
  getHistoryStack: () => this.historyStack,
1275
1320
  isToolActive: () => this.isToolActive,
1276
1321
  fitToContent: options.fitToContent,
1322
+ group: options.group,
1323
+ ungroup: options.ungroup,
1277
1324
  getLastPointerWorld: () => this.lastPointerWorld()
1278
1325
  });
1279
1326
  this.shortcutMap = new ShortcutMap(options.shortcuts?.bindings);
@@ -1513,6 +1560,14 @@ var InputHandler = class {
1513
1560
  e.preventDefault();
1514
1561
  this.actions.zoomToFit();
1515
1562
  return;
1563
+ case "group":
1564
+ e.preventDefault();
1565
+ this.actions.group();
1566
+ return;
1567
+ case "ungroup":
1568
+ e.preventDefault();
1569
+ this.actions.ungroup();
1570
+ return;
1516
1571
  case "zoom-in":
1517
1572
  e.preventDefault();
1518
1573
  this.zoomByFactor(ZOOM_STEP);
@@ -1557,18 +1612,18 @@ var InputHandler = class {
1557
1612
  handlePinchMove() {
1558
1613
  const [a, b] = this.getPinchPoints();
1559
1614
  const dist = this.distance(a, b);
1560
- const center = this.midpoint(a, b);
1615
+ const center2 = this.midpoint(a, b);
1561
1616
  if (this.lastPinchDistance > 0) {
1562
1617
  const scale = dist / this.lastPinchDistance;
1563
1618
  const newZoom = this.camera.zoom * scale;
1564
- this.camera.zoomAt(newZoom, center);
1619
+ this.camera.zoomAt(newZoom, center2);
1565
1620
  }
1566
- const dx = center.x - this.lastPointer.x;
1567
- const dy = center.y - this.lastPointer.y;
1621
+ const dx = center2.x - this.lastPointer.x;
1622
+ const dy = center2.y - this.lastPointer.y;
1568
1623
  this.camera.pan(dx, dy);
1569
1624
  this.lastPinchDistance = dist;
1570
- this.lastPinchCenter = center;
1571
- this.lastPointer = { ...center };
1625
+ this.lastPinchCenter = center2;
1626
+ this.lastPointer = { ...center2 };
1572
1627
  }
1573
1628
  getPinchPoints() {
1574
1629
  const pts = [...this.activePointers.values()];
@@ -2110,11 +2165,19 @@ var ElementStore = class {
2110
2165
  (el) => el.type === type
2111
2166
  );
2112
2167
  }
2168
+ // Spatial index stores the rotation-expanded AABB so rotated elements remain
2169
+ // broad-phase hit-test/marquee candidates; precise tests run against local bounds.
2170
+ indexBounds(element) {
2171
+ const bounds = getElementBounds(element);
2172
+ if (!bounds) return null;
2173
+ const angle = element.rotation ?? 0;
2174
+ return angle === 0 ? bounds : rotatedAABB(bounds, angle);
2175
+ }
2113
2176
  add(element) {
2114
2177
  this.sortedCache = null;
2115
2178
  this._versions.set(element.id, 0);
2116
2179
  this.elements.set(element.id, element);
2117
- const bounds = getElementBounds(element);
2180
+ const bounds = this.indexBounds(element);
2118
2181
  if (bounds) this.spatialIndex.insert(element.id, bounds);
2119
2182
  this.bus.emit("add", element);
2120
2183
  }
@@ -2136,7 +2199,7 @@ var ElementStore = class {
2136
2199
  updated.text = sanitizeNoteHtml(updated.text);
2137
2200
  }
2138
2201
  this.elements.set(id, updated);
2139
- const newBounds = getElementBounds(updated);
2202
+ const newBounds = this.indexBounds(updated);
2140
2203
  if (newBounds) {
2141
2204
  this.spatialIndex.update(id, newBounds);
2142
2205
  }
@@ -2169,7 +2232,7 @@ var ElementStore = class {
2169
2232
  for (const el of elements) {
2170
2233
  this.elements.set(el.id, el);
2171
2234
  this._versions.set(el.id, 0);
2172
- const bounds = getElementBounds(el);
2235
+ const bounds = this.indexBounds(el);
2173
2236
  if (bounds) this.spatialIndex.insert(el.id, bounds);
2174
2237
  if (el.type === "stroke") {
2175
2238
  computeStrokeSegments(el);
@@ -2374,9 +2437,9 @@ function updateBoundArrow(arrow, store) {
2374
2437
  if (arrow.fromBinding) {
2375
2438
  const el = store.getById(arrow.fromBinding.elementId);
2376
2439
  if (el) {
2377
- const center = getElementCenter(el);
2378
- updates.from = center;
2379
- updates.position = center;
2440
+ const center2 = getElementCenter(el);
2441
+ updates.from = center2;
2442
+ updates.position = center2;
2380
2443
  }
2381
2444
  }
2382
2445
  if (arrow.toBinding) {
@@ -2388,6 +2451,21 @@ function updateBoundArrow(arrow, store) {
2388
2451
  return Object.keys(updates).length > 0 ? updates : null;
2389
2452
  }
2390
2453
 
2454
+ // src/elements/rotate-canvas.ts
2455
+ function withRotation(ctx, el, center2, draw) {
2456
+ const angle = el.rotation ?? 0;
2457
+ if (angle === 0) {
2458
+ draw();
2459
+ return;
2460
+ }
2461
+ ctx.save();
2462
+ ctx.translate(center2.x, center2.y);
2463
+ ctx.rotate(angle);
2464
+ ctx.translate(-center2.x, -center2.y);
2465
+ draw();
2466
+ ctx.restore();
2467
+ }
2468
+
2391
2469
  // src/elements/grid-renderer.ts
2392
2470
  function getSquareGridLines(bounds, cellSize) {
2393
2471
  if (cellSize <= 0) return { verticals: [], horizontals: [] };
@@ -2651,18 +2729,18 @@ function getHexDistance(a, b, cellSize, orientation) {
2651
2729
  const ds = -dq - dr;
2652
2730
  return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
2653
2731
  }
2654
- function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
2732
+ function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
2655
2733
  const n = Math.round(radiusCells);
2656
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2734
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2657
2735
  const cube = offsetToCube(off.col, off.row, orientation);
2658
2736
  if (n <= 0) {
2659
2737
  return [offsetToPixel(off.col, off.row, cellSize, orientation)];
2660
2738
  }
2661
2739
  return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
2662
2740
  }
2663
- function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2741
+ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
2664
2742
  const n = Math.round(radiusCells);
2665
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2743
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2666
2744
  const cube = offsetToCube(off.col, off.row, orientation);
2667
2745
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2668
2746
  if (n <= 0) return [centerPixel];
@@ -2696,9 +2774,9 @@ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2696
2774
  }
2697
2775
  return cells;
2698
2776
  }
2699
- function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2777
+ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
2700
2778
  const n = Math.round(radiusCells);
2701
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2779
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2702
2780
  const cube = offsetToCube(off.col, off.row, orientation);
2703
2781
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2704
2782
  if (n <= 0) return [centerPixel];
@@ -2734,9 +2812,9 @@ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2734
2812
  }
2735
2813
  return cells;
2736
2814
  }
2737
- function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
2815
+ function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
2738
2816
  const n = Math.round(radiusCells);
2739
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2817
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2740
2818
  const cube = offsetToCube(off.col, off.row, orientation);
2741
2819
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2742
2820
  if (n <= 0) return [centerPixel];
@@ -2814,18 +2892,27 @@ var ElementRenderer = class {
2814
2892
  }
2815
2893
  renderCanvasElement(ctx, element) {
2816
2894
  switch (element.type) {
2817
- case "stroke":
2818
- this.renderStroke(ctx, element);
2895
+ case "stroke": {
2896
+ const b = getElementBounds(element);
2897
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2898
+ withRotation(ctx, element, c, () => this.renderStroke(ctx, element));
2819
2899
  break;
2900
+ }
2820
2901
  case "arrow":
2821
2902
  this.renderArrow(ctx, element);
2822
2903
  break;
2823
- case "shape":
2824
- this.renderShape(ctx, element);
2904
+ case "shape": {
2905
+ const b = getElementBounds(element);
2906
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2907
+ withRotation(ctx, element, c, () => this.renderShape(ctx, element));
2825
2908
  break;
2826
- case "image":
2827
- this.renderImage(ctx, element);
2909
+ }
2910
+ case "image": {
2911
+ const b = getElementBounds(element);
2912
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2913
+ withRotation(ctx, element, c, () => this.renderImage(ctx, element));
2828
2914
  break;
2915
+ }
2829
2916
  case "grid":
2830
2917
  this.renderGrid(ctx, element);
2831
2918
  break;
@@ -3122,20 +3209,20 @@ var ElementRenderer = class {
3122
3209
  renderHexTemplate(ctx, template, cellSize, orientation) {
3123
3210
  const snapUnit = Math.sqrt(3) * cellSize;
3124
3211
  const radiusCells = template.radius / snapUnit;
3125
- const center = template.position;
3212
+ const center2 = template.position;
3126
3213
  let cells;
3127
3214
  switch (template.templateShape) {
3128
3215
  case "circle":
3129
- cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
3216
+ cells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
3130
3217
  break;
3131
3218
  case "cone":
3132
- cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
3219
+ cells = getHexCellsInCone(center2, template.angle, radiusCells, cellSize, orientation);
3133
3220
  break;
3134
3221
  case "line":
3135
- cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
3222
+ cells = getHexCellsInLine(center2, template.angle, radiusCells, cellSize, orientation);
3136
3223
  break;
3137
3224
  case "square":
3138
- cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
3225
+ cells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
3139
3226
  break;
3140
3227
  }
3141
3228
  ctx.save();
@@ -3156,7 +3243,7 @@ var ElementRenderer = class {
3156
3243
  {
3157
3244
  ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
3158
3245
  ctx.beginPath();
3159
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
3246
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
3160
3247
  ctx.fillStyle = template.strokeColor;
3161
3248
  ctx.fill();
3162
3249
  ctx.strokeStyle = template.strokeColor;
@@ -3165,7 +3252,7 @@ var ElementRenderer = class {
3165
3252
  }
3166
3253
  if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
3167
3254
  const r = template.radius;
3168
- this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
3255
+ this.renderRadiusMarker(ctx, center2.x, center2.y, r, template.radiusFeet);
3169
3256
  }
3170
3257
  ctx.restore();
3171
3258
  }
@@ -4018,12 +4105,19 @@ var UpdateElementCommand = class {
4018
4105
  this.current = current;
4019
4106
  }
4020
4107
  execute(store) {
4021
- store.update(this.id, { ...this.current });
4108
+ store.update(this.id, diffPatch(this.previous, this.current));
4022
4109
  }
4023
4110
  undo(store) {
4024
- store.update(this.id, { ...this.previous });
4111
+ store.update(this.id, diffPatch(this.current, this.previous));
4025
4112
  }
4026
4113
  };
4114
+ function diffPatch(from, to) {
4115
+ const patch = { ...to };
4116
+ for (const key of Object.keys(from)) {
4117
+ if (!(key in to)) patch[key] = void 0;
4118
+ }
4119
+ return patch;
4120
+ }
4027
4121
  var BatchCommand = class {
4028
4122
  commands;
4029
4123
  constructor(commands) {
@@ -4277,6 +4371,7 @@ function renderStyledRuns(ctx, runs, startX, startY, maxWidth) {
4277
4371
  }
4278
4372
 
4279
4373
  // src/canvas/export-image.ts
4374
+ var center = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
4280
4375
  function getStrokeBounds(el) {
4281
4376
  if (el.type !== "stroke") return null;
4282
4377
  if (el.points.length === 0) return null;
@@ -4302,8 +4397,10 @@ function getStrokeBounds(el) {
4302
4397
  }
4303
4398
  function getElementRect(el) {
4304
4399
  switch (el.type) {
4305
- case "stroke":
4306
- return getStrokeBounds(el);
4400
+ case "stroke": {
4401
+ const r = getStrokeBounds(el);
4402
+ return r ? rotatedAABB(r, el.rotation ?? 0) : r;
4403
+ }
4307
4404
  case "arrow": {
4308
4405
  const b = getArrowBounds(el.from, el.to, el.bend);
4309
4406
  const pad = el.width / 2 + 14;
@@ -4322,7 +4419,10 @@ function getElementRect(el) {
4322
4419
  case "text":
4323
4420
  case "shape":
4324
4421
  if ("size" in el) {
4325
- return { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
4422
+ return rotatedAABB(
4423
+ { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h },
4424
+ el.rotation ?? 0
4425
+ );
4326
4426
  }
4327
4427
  return null;
4328
4428
  default:
@@ -4460,11 +4560,13 @@ async function exportImage(store, options = {}, layerManager) {
4460
4560
  continue;
4461
4561
  }
4462
4562
  if (el.type === "note") {
4463
- renderNoteOnCanvas(ctx, el);
4563
+ const b = getElementBounds(el);
4564
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderNoteOnCanvas(ctx, el));
4464
4565
  continue;
4465
4566
  }
4466
4567
  if (el.type === "text") {
4467
- renderTextOnCanvas(ctx, el);
4568
+ const b = getElementBounds(el);
4569
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderTextOnCanvas(ctx, el));
4468
4570
  continue;
4469
4571
  }
4470
4572
  if (el.type === "html") {
@@ -4473,7 +4575,13 @@ async function exportImage(store, options = {}, layerManager) {
4473
4575
  if (el.type === "image") {
4474
4576
  const img = imageCache.get(el.id);
4475
4577
  if (img) {
4476
- ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h);
4578
+ const b = getElementBounds(el);
4579
+ withRotation(
4580
+ ctx,
4581
+ el,
4582
+ b ? center(b) : el.position,
4583
+ () => ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h)
4584
+ );
4477
4585
  }
4478
4586
  continue;
4479
4587
  }
@@ -4809,7 +4917,9 @@ var DomNodeManager = class {
4809
4917
  top: `${element.position.y}px`,
4810
4918
  width: size ? `${size.w}px` : "auto",
4811
4919
  height: size ? `${size.h}px` : "auto",
4812
- zIndex: String(zIndex)
4920
+ zIndex: String(zIndex),
4921
+ transform: element.rotation ? `rotate(${element.rotation}rad)` : "",
4922
+ transformOrigin: "50% 50%"
4813
4923
  });
4814
4924
  this.renderDomContent(node, element);
4815
4925
  }
@@ -5586,6 +5696,8 @@ var Viewport = class {
5586
5696
  historyRecorder: this.historyRecorder,
5587
5697
  historyStack: this.history,
5588
5698
  fitToContent: () => this.fitToContent(),
5699
+ group: () => this.groupSelection(),
5700
+ ungroup: () => this.ungroupSelection(),
5589
5701
  shortcuts: options.shortcuts
5590
5702
  });
5591
5703
  this.domNodeManager = new DomNodeManager({
@@ -5923,6 +6035,26 @@ var Viewport = class {
5923
6035
  }
5924
6036
  this.historyRecorder.commit();
5925
6037
  }
6038
+ groupSelection() {
6039
+ const ids = this.getSelectedIds();
6040
+ if (ids.length < 2) return;
6041
+ const groupId = createId("group");
6042
+ this.historyRecorder.begin();
6043
+ for (const id of ids) {
6044
+ if (this.store.getById(id)) this.store.update(id, { groupId });
6045
+ }
6046
+ this.historyRecorder.commit();
6047
+ }
6048
+ ungroupSelection() {
6049
+ const ids = this.getSelectedIds();
6050
+ if (ids.length === 0) return;
6051
+ this.historyRecorder.begin();
6052
+ for (const id of ids) {
6053
+ const el = this.store.getById(id);
6054
+ if (el && el.groupId !== void 0) this.store.update(id, { groupId: void 0 });
6055
+ }
6056
+ this.historyRecorder.commit();
6057
+ }
5926
6058
  alignSelection(edge) {
5927
6059
  const bounded = this.boundedSelection();
5928
6060
  if (bounded.length < 2) return;
@@ -5964,13 +6096,13 @@ var Viewport = class {
5964
6096
  distributeSelection(axis) {
5965
6097
  const bounded = this.boundedSelection();
5966
6098
  if (bounded.length < 3) return;
5967
- const center = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
5968
- const sorted = [...bounded].sort((p, q) => center(p.bounds) - center(q.bounds));
6099
+ const center2 = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
6100
+ const sorted = [...bounded].sort((p, q) => center2(p.bounds) - center2(q.bounds));
5969
6101
  const first = sorted[0];
5970
6102
  const last = sorted[sorted.length - 1];
5971
6103
  if (!first || !last) return;
5972
- const c0 = center(first.bounds);
5973
- const cN = center(last.bounds);
6104
+ const c0 = center2(first.bounds);
6105
+ const cN = center2(last.bounds);
5974
6106
  const n = sorted.length;
5975
6107
  this.historyRecorder.begin();
5976
6108
  const moved = [];
@@ -5978,7 +6110,7 @@ var Viewport = class {
5978
6110
  const item = sorted[i];
5979
6111
  if (!item || !this.isMovable(item.el)) continue;
5980
6112
  const target = c0 + i * (cN - c0) / (n - 1);
5981
- const delta = target - center(item.bounds);
6113
+ const delta = target - center2(item.bounds);
5982
6114
  if (delta === 0) continue;
5983
6115
  const [dx, dy] = axis === "horizontal" ? [delta, 0] : [0, delta];
5984
6116
  this.store.update(item.id, translateElementPatch(item.el, dx, dy));
@@ -6632,6 +6764,26 @@ var EraserTool = class {
6632
6764
  }
6633
6765
  };
6634
6766
 
6767
+ // src/elements/group.ts
6768
+ function expandToGroups(ids, elements) {
6769
+ const byId = new Map(elements.map((e) => [e.id, e]));
6770
+ const groupIds = /* @__PURE__ */ new Set();
6771
+ for (const id of ids) {
6772
+ const g = byId.get(id)?.groupId;
6773
+ if (g) groupIds.add(g);
6774
+ }
6775
+ if (groupIds.size === 0) return ids;
6776
+ const idSet = new Set(ids);
6777
+ const result = [...ids];
6778
+ for (const el of elements) {
6779
+ if (el.groupId && groupIds.has(el.groupId) && !idSet.has(el.id)) {
6780
+ result.push(el.id);
6781
+ idSet.add(el.id);
6782
+ }
6783
+ }
6784
+ return result;
6785
+ }
6786
+
6635
6787
  // src/tools/arrow-handles.ts
6636
6788
  var BIND_THRESHOLD = 20;
6637
6789
  var HANDLE_RADIUS = 5;
@@ -6681,10 +6833,10 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6681
6833
  const excludeId = el.toBinding?.elementId;
6682
6834
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6683
6835
  if (target) {
6684
- const center = getElementCenter(target);
6836
+ const center2 = getElementCenter(target);
6685
6837
  ctx.store.update(elementId, {
6686
- from: center,
6687
- position: center,
6838
+ from: center2,
6839
+ position: center2,
6688
6840
  fromBinding: { elementId: target.id }
6689
6841
  });
6690
6842
  } else {
@@ -6700,9 +6852,9 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6700
6852
  const excludeId = el.fromBinding?.elementId;
6701
6853
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6702
6854
  if (target) {
6703
- const center = getElementCenter(target);
6855
+ const center2 = getElementCenter(target);
6704
6856
  ctx.store.update(elementId, {
6705
- to: center,
6857
+ to: center2,
6706
6858
  toBinding: { elementId: target.id }
6707
6859
  });
6708
6860
  } else {
@@ -6791,6 +6943,9 @@ var SNAP_PX = 6;
6791
6943
  var HANDLE_HIT_PADDING2 = 4;
6792
6944
  var SELECTION_PAD = 4;
6793
6945
  var MIN_ELEMENT_SIZE = 20;
6946
+ var ROTATE_HANDLE_OFFSET = 24;
6947
+ var ROTATE_SNAP = Math.PI / 12;
6948
+ var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
6794
6949
  var HANDLE_CURSORS = {
6795
6950
  nw: "nwse-resize",
6796
6951
  se: "nwse-resize",
@@ -6879,6 +7034,22 @@ var SelectTool = class {
6879
7034
  ctx.requestRender();
6880
7035
  return;
6881
7036
  }
7037
+ const rotateHit = this.hitTestRotateHandle(world, ctx);
7038
+ if (rotateHit) {
7039
+ const el = ctx.store.getById(rotateHit.elementId);
7040
+ const layout = el ? this.getOverlayLayout(el, ctx.camera.zoom) : null;
7041
+ if (el && layout) {
7042
+ this.mode = {
7043
+ type: "rotating",
7044
+ elementId: rotateHit.elementId,
7045
+ center: layout.center,
7046
+ startPointerAngle: Math.atan2(world.y - layout.center.y, world.x - layout.center.x),
7047
+ startRotation: el.rotation ?? 0
7048
+ };
7049
+ ctx.requestRender();
7050
+ return;
7051
+ }
7052
+ }
6882
7053
  const resizeHit = this.hitTestResizeHandle(world, ctx);
6883
7054
  if (resizeHit) {
6884
7055
  const el = ctx.store.getById(resizeHit.elementId);
@@ -6897,18 +7068,20 @@ var SelectTool = class {
6897
7068
  this.hasDragged = false;
6898
7069
  const hit = this.hitTest(world, ctx);
6899
7070
  if (hit) {
7071
+ const all = ctx.store.getAll();
6900
7072
  const alreadySelected = this._selectedIds.includes(hit.id);
6901
7073
  if (state.shiftKey) {
6902
7074
  if (alreadySelected) {
6903
- this.setSelectedIds(this._selectedIds.filter((id) => id !== hit.id));
7075
+ const grp = new Set(expandToGroups([hit.id], all));
7076
+ this.setSelectedIds(this._selectedIds.filter((id) => !grp.has(id)));
6904
7077
  this.mode = { type: "idle" };
6905
7078
  } else {
6906
- this.setSelectedIds([...this._selectedIds, hit.id]);
7079
+ this.setSelectedIds(expandToGroups([...this._selectedIds, hit.id], all));
6907
7080
  this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
6908
7081
  }
6909
7082
  } else {
6910
7083
  if (!alreadySelected) {
6911
- this.setSelectedIds([hit.id]);
7084
+ this.setSelectedIds(expandToGroups([hit.id], all));
6912
7085
  } else if (this._selectedIds.length > 1) {
6913
7086
  this.pendingSingleSelectId = hit.id;
6914
7087
  }
@@ -6942,6 +7115,15 @@ var SelectTool = class {
6942
7115
  this.handleTemplateResize(world, ctx);
6943
7116
  return;
6944
7117
  }
7118
+ if (this.mode.type === "rotating") {
7119
+ const { elementId, center: center2, startPointerAngle, startRotation } = this.mode;
7120
+ const a = Math.atan2(world.y - center2.y, world.x - center2.x);
7121
+ let next = startRotation + (a - startPointerAngle);
7122
+ if (state.shiftKey) next = Math.round(next / ROTATE_SNAP) * ROTATE_SNAP;
7123
+ ctx.store.update(elementId, { rotation: normalizeAngle(next) });
7124
+ ctx.requestRender();
7125
+ return;
7126
+ }
6945
7127
  if (this.mode.type === "resizing") {
6946
7128
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
6947
7129
  this.handleResize(world, ctx, state.shiftKey);
@@ -7022,12 +7204,12 @@ var SelectTool = class {
7022
7204
  if (this.mode.type === "marquee") {
7023
7205
  const rect = this.getMarqueeRect();
7024
7206
  if (rect) {
7025
- this.setSelectedIds(this.findElementsInRect(rect, ctx));
7207
+ this.setSelectedIds(expandToGroups(this.findElementsInRect(rect, ctx), ctx.store.getAll()));
7026
7208
  }
7027
7209
  ctx.requestRender();
7028
7210
  }
7029
7211
  if (!this.hasDragged && this.pendingSingleSelectId !== null) {
7030
- this.setSelectedIds([this.pendingSingleSelectId]);
7212
+ this.setSelectedIds(expandToGroups([this.pendingSingleSelectId], ctx.store.getAll()));
7031
7213
  }
7032
7214
  this.pendingSingleSelectId = null;
7033
7215
  this.hasDragged = false;
@@ -7144,6 +7326,10 @@ var SelectTool = class {
7144
7326
  ctx.setCursor?.("nwse-resize");
7145
7327
  return null;
7146
7328
  }
7329
+ if (this.hitTestRotateHandle(world, ctx)) {
7330
+ ctx.setCursor?.("grab");
7331
+ return null;
7332
+ }
7147
7333
  const resizeHit = this.hitTestResizeHandle(world, ctx);
7148
7334
  if (resizeHit) {
7149
7335
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -7162,6 +7348,11 @@ var SelectTool = class {
7162
7348
  if (this.mode.type !== "resizing") return;
7163
7349
  const el = ctx.store.getById(this.mode.elementId);
7164
7350
  if (!el || !("size" in el) || el.locked) return;
7351
+ const angle = el.rotation ?? 0;
7352
+ if (angle !== 0) {
7353
+ this.handleRotatedResize(world, el, angle, ctx, shiftKey);
7354
+ return;
7355
+ }
7165
7356
  const { handle } = this.mode;
7166
7357
  const dx = world.x - this.lastWorld.x;
7167
7358
  const dy = world.y - this.lastWorld.y;
@@ -7219,6 +7410,78 @@ var SelectTool = class {
7219
7410
  this.updateArrowsBoundTo([this.mode.elementId], ctx);
7220
7411
  ctx.requestRender();
7221
7412
  }
7413
+ anchorOffset(handle, w, h) {
7414
+ switch (handle) {
7415
+ case "se":
7416
+ return { x: -w / 2, y: -h / 2 };
7417
+ case "sw":
7418
+ return { x: w / 2, y: -h / 2 };
7419
+ case "ne":
7420
+ return { x: -w / 2, y: h / 2 };
7421
+ case "nw":
7422
+ return { x: w / 2, y: h / 2 };
7423
+ default:
7424
+ return { x: 0, y: 0 };
7425
+ }
7426
+ }
7427
+ handleRotatedResize(world, el, angle, ctx, shiftKey) {
7428
+ if (this.mode.type !== "resizing") return;
7429
+ const { handle } = this.mode;
7430
+ const wdx = world.x - this.lastWorld.x;
7431
+ const wdy = world.y - this.lastWorld.y;
7432
+ this.lastWorld = world;
7433
+ const cosN = Math.cos(-angle);
7434
+ const sinN = Math.sin(-angle);
7435
+ const ldx = wdx * cosN - wdy * sinN;
7436
+ const ldy = wdx * sinN + wdy * cosN;
7437
+ let w = el.size.w;
7438
+ let h = el.size.h;
7439
+ switch (handle) {
7440
+ case "se":
7441
+ w += ldx;
7442
+ h += ldy;
7443
+ break;
7444
+ case "sw":
7445
+ w -= ldx;
7446
+ h += ldy;
7447
+ break;
7448
+ case "ne":
7449
+ w += ldx;
7450
+ h -= ldy;
7451
+ break;
7452
+ case "nw":
7453
+ w -= ldx;
7454
+ h -= ldy;
7455
+ break;
7456
+ }
7457
+ if (shiftKey && this.resizeAspectRatio > 0) {
7458
+ const absDw = Math.abs(w - el.size.w);
7459
+ const absDh = Math.abs(h - el.size.h);
7460
+ if (absDw >= absDh) h = w / this.resizeAspectRatio;
7461
+ else w = h * this.resizeAspectRatio;
7462
+ }
7463
+ w = Math.max(w, MIN_ELEMENT_SIZE);
7464
+ h = Math.max(h, MIN_ELEMENT_SIZE);
7465
+ const oldCenter = { x: el.position.x + el.size.w / 2, y: el.position.y + el.size.h / 2 };
7466
+ const oldAnchorLocal = this.anchorOffset(handle, el.size.w, el.size.h);
7467
+ const anchorWorld = rotatePoint(
7468
+ { x: oldCenter.x + oldAnchorLocal.x, y: oldCenter.y + oldAnchorLocal.y },
7469
+ oldCenter,
7470
+ angle
7471
+ );
7472
+ const newAnchorLocal = this.anchorOffset(handle, w, h);
7473
+ const cos = Math.cos(angle);
7474
+ const sin = Math.sin(angle);
7475
+ const rotatedAnchor = {
7476
+ x: newAnchorLocal.x * cos - newAnchorLocal.y * sin,
7477
+ y: newAnchorLocal.x * sin + newAnchorLocal.y * cos
7478
+ };
7479
+ const newCenter = { x: anchorWorld.x - rotatedAnchor.x, y: anchorWorld.y - rotatedAnchor.y };
7480
+ const position = { x: newCenter.x - w / 2, y: newCenter.y - h / 2 };
7481
+ ctx.store.update(this.mode.elementId, { position, size: { w, h } });
7482
+ this.updateArrowsBoundTo([this.mode.elementId], ctx);
7483
+ ctx.requestRender();
7484
+ }
7222
7485
  hitTestResizeHandle(world, ctx) {
7223
7486
  if (this._selectedIds.length === 0) return null;
7224
7487
  const zoom = ctx.camera.zoom;
@@ -7227,10 +7490,9 @@ var SelectTool = class {
7227
7490
  const el = ctx.store.getById(id);
7228
7491
  if (!el || !("size" in el)) continue;
7229
7492
  if (el.type === "shape" && el.shape === "line") continue;
7230
- const bounds = getElementBounds(el);
7231
- if (!bounds) continue;
7232
- const corners = this.getHandlePositions(bounds);
7233
- for (const [handle, pos] of corners) {
7493
+ const layout = this.getOverlayLayout(el, zoom);
7494
+ if (!layout) continue;
7495
+ for (const [handle, pos] of layout.corners) {
7234
7496
  if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
7235
7497
  return { elementId: id, handle };
7236
7498
  }
@@ -7238,6 +7500,19 @@ var SelectTool = class {
7238
7500
  }
7239
7501
  return null;
7240
7502
  }
7503
+ hitTestRotateHandle(world, ctx) {
7504
+ if (this._selectedIds.length !== 1) return null;
7505
+ const id = this._selectedIds[0];
7506
+ if (!id) return null;
7507
+ const el = ctx.store.getById(id);
7508
+ if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
7509
+ const layout = this.getOverlayLayout(el, ctx.camera.zoom);
7510
+ if (!layout) return null;
7511
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
7512
+ const dx = world.x - layout.rotateHandle.x;
7513
+ const dy = world.y - layout.rotateHandle.y;
7514
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
7515
+ }
7241
7516
  hitTestLineHandles(world, ctx) {
7242
7517
  if (this._selectedIds.length === 0) return null;
7243
7518
  const zoom = ctx.camera.zoom;
@@ -7260,6 +7535,30 @@ var SelectTool = class {
7260
7535
  ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
7261
7536
  ];
7262
7537
  }
7538
+ getOverlayLayout(el, zoom) {
7539
+ const bounds = getElementBounds(el);
7540
+ if (!bounds) return null;
7541
+ const angle = el.rotation ?? 0;
7542
+ const pad = SELECTION_PAD / zoom;
7543
+ const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
7544
+ const raw = [
7545
+ ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
7546
+ ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
7547
+ ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
7548
+ ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
7549
+ ];
7550
+ const corners = raw.map(
7551
+ ([h, p]) => [h, rotatePoint(p, center2, angle)]
7552
+ );
7553
+ const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
7554
+ const rotateHandle = rotatePoint(topMid, center2, angle);
7555
+ return { center: center2, corners, rotateHandle, angle };
7556
+ }
7557
+ topMidpoint(layout) {
7558
+ const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
7559
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
7560
+ return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
7561
+ }
7263
7562
  renderMarquee(canvasCtx) {
7264
7563
  if (this.mode.type !== "marquee") return;
7265
7564
  const rect = this.getMarqueeRect();
@@ -7304,12 +7603,31 @@ var SelectTool = class {
7304
7603
  }
7305
7604
  const bounds = getElementBounds(el);
7306
7605
  if (!bounds) continue;
7606
+ const layout = this.getOverlayLayout(el, zoom);
7607
+ if (!layout) continue;
7307
7608
  const pad = SELECTION_PAD / zoom;
7308
- canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
7609
+ if (layout.angle === 0) {
7610
+ canvasCtx.strokeRect(
7611
+ bounds.x - pad,
7612
+ bounds.y - pad,
7613
+ bounds.w + pad * 2,
7614
+ bounds.h + pad * 2
7615
+ );
7616
+ } else {
7617
+ const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((p) => !!p);
7618
+ const [p0, ...others] = ordered;
7619
+ if (p0) {
7620
+ canvasCtx.beginPath();
7621
+ canvasCtx.moveTo(p0.x, p0.y);
7622
+ for (const p of others) canvasCtx.lineTo(p.x, p.y);
7623
+ canvasCtx.closePath();
7624
+ canvasCtx.stroke();
7625
+ }
7626
+ }
7309
7627
  if ("size" in el) {
7310
7628
  canvasCtx.setLineDash([]);
7311
7629
  canvasCtx.fillStyle = "#ffffff";
7312
- const corners = this.getHandlePositions(bounds);
7630
+ const corners = layout.angle === 0 ? this.getHandlePositions(bounds) : layout.corners;
7313
7631
  for (const [, pos] of corners) {
7314
7632
  canvasCtx.fillRect(
7315
7633
  pos.x - handleWorldSize / 2,
@@ -7344,6 +7662,21 @@ var SelectTool = class {
7344
7662
  );
7345
7663
  canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7346
7664
  }
7665
+ if (this._selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
7666
+ const stemStart = this.topMidpoint(layout);
7667
+ const stemEnd = layout.rotateHandle;
7668
+ canvasCtx.beginPath();
7669
+ canvasCtx.moveTo(stemStart.x, stemStart.y);
7670
+ canvasCtx.lineTo(stemEnd.x, stemEnd.y);
7671
+ canvasCtx.stroke();
7672
+ canvasCtx.setLineDash([]);
7673
+ canvasCtx.fillStyle = "#ffffff";
7674
+ canvasCtx.beginPath();
7675
+ canvasCtx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
7676
+ canvasCtx.fill();
7677
+ canvasCtx.stroke();
7678
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7679
+ }
7347
7680
  }
7348
7681
  canvasCtx.restore();
7349
7682
  }
@@ -7423,7 +7756,7 @@ var SelectTool = class {
7423
7756
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
7424
7757
  if (el.type === "grid") continue;
7425
7758
  const bounds = getElementBounds(el);
7426
- if (bounds && this.rectsOverlap(marquee, bounds)) {
7759
+ if (bounds && this.rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
7427
7760
  ids.push(el.id);
7428
7761
  }
7429
7762
  }
@@ -7445,6 +7778,13 @@ var SelectTool = class {
7445
7778
  }
7446
7779
  isInsideBounds(point, el) {
7447
7780
  if (el.type === "grid") return false;
7781
+ const angle = el.rotation ?? 0;
7782
+ if (angle !== 0) {
7783
+ const b = getElementBounds(el);
7784
+ if (b) {
7785
+ point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
7786
+ }
7787
+ }
7448
7788
  if (el.type === "shape" && el.shape === "line") {
7449
7789
  const [a, b] = lineEndpoints(el);
7450
7790
  const threshold = Math.max(el.strokeWidth / 2, 6);
@@ -8211,20 +8551,20 @@ var TemplateTool = class {
8211
8551
  const snapUnit = Math.sqrt(3) * cellSize;
8212
8552
  const radiusCells = radius / snapUnit;
8213
8553
  const angle = this.computeAngle();
8214
- const center = this.origin;
8554
+ const center2 = this.origin;
8215
8555
  let hexCells;
8216
8556
  switch (this.templateShape) {
8217
8557
  case "circle":
8218
- hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
8558
+ hexCells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
8219
8559
  break;
8220
8560
  case "cone":
8221
- hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
8561
+ hexCells = getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation);
8222
8562
  break;
8223
8563
  case "line":
8224
- hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
8564
+ hexCells = getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation);
8225
8565
  break;
8226
8566
  case "square":
8227
- hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
8567
+ hexCells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
8228
8568
  break;
8229
8569
  }
8230
8570
  ctx.save();
@@ -8245,7 +8585,7 @@ var TemplateTool = class {
8245
8585
  if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
8246
8586
  ctx.globalAlpha = 0.5;
8247
8587
  ctx.beginPath();
8248
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
8588
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
8249
8589
  ctx.fillStyle = this.strokeColor;
8250
8590
  ctx.fill();
8251
8591
  ctx.strokeStyle = this.strokeColor;
@@ -8261,8 +8601,8 @@ var TemplateTool = class {
8261
8601
  ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
8262
8602
  ctx.textAlign = "center";
8263
8603
  ctx.textBaseline = "bottom";
8264
- const textX = center.x;
8265
- const textY = center.y - 4;
8604
+ const textX = center2.x;
8605
+ const textY = center2.y - 4;
8266
8606
  const metrics = ctx.measureText(label);
8267
8607
  const padX = 4;
8268
8608
  const padY = 2;
@@ -8312,7 +8652,7 @@ var TemplateTool = class {
8312
8652
  };
8313
8653
 
8314
8654
  // src/index.ts
8315
- var VERSION = "0.33.0";
8655
+ var VERSION = "0.35.0";
8316
8656
  // Annotate the CommonJS export names for ESM import in node:
8317
8657
  0 && (module.exports = {
8318
8658
  ArrowTool,