@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.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) {
@@ -940,6 +970,14 @@ var KeyboardActions = class {
940
970
  if (this.deps.isToolActive()) return;
941
971
  this.deps.fitToContent?.();
942
972
  }
973
+ group() {
974
+ if (this.deps.isToolActive()) return;
975
+ this.deps.group?.();
976
+ }
977
+ ungroup() {
978
+ if (this.deps.isToolActive()) return;
979
+ this.deps.ungroup?.();
980
+ }
943
981
  zOrder(operation) {
944
982
  if (this.deps.isToolActive()) return;
945
983
  this.flushPendingNudge();
@@ -973,6 +1011,10 @@ var KeyboardActions = class {
973
1011
  for (const el of source) {
974
1012
  idMap.set(el.id, createId(el.type));
975
1013
  }
1014
+ const groupIdMap = /* @__PURE__ */ new Map();
1015
+ for (const el of source) {
1016
+ if (el.groupId && !groupIdMap.has(el.groupId)) groupIdMap.set(el.groupId, createId("group"));
1017
+ }
976
1018
  const newIds = [];
977
1019
  const recorder = this.deps.getHistoryRecorder();
978
1020
  recorder?.begin();
@@ -981,6 +1023,7 @@ var KeyboardActions = class {
981
1023
  const newId = idMap.get(el.id);
982
1024
  if (!newId) continue;
983
1025
  clone.id = newId;
1026
+ if (clone.groupId) clone.groupId = groupIdMap.get(clone.groupId) ?? clone.groupId;
984
1027
  clone.position = { x: clone.position.x + offset.x, y: clone.position.y + offset.y };
985
1028
  if (clone.type === "arrow") {
986
1029
  const arrow = clone;
@@ -1034,6 +1077,8 @@ var DEFAULT_BINDINGS = [
1034
1077
  ["zoom-in", ["mod+="]],
1035
1078
  ["zoom-out", ["mod+-"]],
1036
1079
  ["zoom-reset", ["mod+0"]],
1080
+ ["group", ["mod+g"]],
1081
+ ["ungroup", ["mod+shift+g"]],
1037
1082
  ["nudge-left", ["arrowleft"]],
1038
1083
  ["nudge-right", ["arrowright"]],
1039
1084
  ["nudge-up", ["arrowup"]],
@@ -1193,6 +1238,8 @@ var InputHandler = class {
1193
1238
  getHistoryStack: () => this.historyStack,
1194
1239
  isToolActive: () => this.isToolActive,
1195
1240
  fitToContent: options.fitToContent,
1241
+ group: options.group,
1242
+ ungroup: options.ungroup,
1196
1243
  getLastPointerWorld: () => this.lastPointerWorld()
1197
1244
  });
1198
1245
  this.shortcutMap = new ShortcutMap(options.shortcuts?.bindings);
@@ -1432,6 +1479,14 @@ var InputHandler = class {
1432
1479
  e.preventDefault();
1433
1480
  this.actions.zoomToFit();
1434
1481
  return;
1482
+ case "group":
1483
+ e.preventDefault();
1484
+ this.actions.group();
1485
+ return;
1486
+ case "ungroup":
1487
+ e.preventDefault();
1488
+ this.actions.ungroup();
1489
+ return;
1435
1490
  case "zoom-in":
1436
1491
  e.preventDefault();
1437
1492
  this.zoomByFactor(ZOOM_STEP);
@@ -1476,18 +1531,18 @@ var InputHandler = class {
1476
1531
  handlePinchMove() {
1477
1532
  const [a, b] = this.getPinchPoints();
1478
1533
  const dist = this.distance(a, b);
1479
- const center = this.midpoint(a, b);
1534
+ const center2 = this.midpoint(a, b);
1480
1535
  if (this.lastPinchDistance > 0) {
1481
1536
  const scale = dist / this.lastPinchDistance;
1482
1537
  const newZoom = this.camera.zoom * scale;
1483
- this.camera.zoomAt(newZoom, center);
1538
+ this.camera.zoomAt(newZoom, center2);
1484
1539
  }
1485
- const dx = center.x - this.lastPointer.x;
1486
- const dy = center.y - this.lastPointer.y;
1540
+ const dx = center2.x - this.lastPointer.x;
1541
+ const dy = center2.y - this.lastPointer.y;
1487
1542
  this.camera.pan(dx, dy);
1488
1543
  this.lastPinchDistance = dist;
1489
- this.lastPinchCenter = center;
1490
- this.lastPointer = { ...center };
1544
+ this.lastPinchCenter = center2;
1545
+ this.lastPointer = { ...center2 };
1491
1546
  }
1492
1547
  getPinchPoints() {
1493
1548
  const pts = [...this.activePointers.values()];
@@ -2029,11 +2084,19 @@ var ElementStore = class {
2029
2084
  (el) => el.type === type
2030
2085
  );
2031
2086
  }
2087
+ // Spatial index stores the rotation-expanded AABB so rotated elements remain
2088
+ // broad-phase hit-test/marquee candidates; precise tests run against local bounds.
2089
+ indexBounds(element) {
2090
+ const bounds = getElementBounds(element);
2091
+ if (!bounds) return null;
2092
+ const angle = element.rotation ?? 0;
2093
+ return angle === 0 ? bounds : rotatedAABB(bounds, angle);
2094
+ }
2032
2095
  add(element) {
2033
2096
  this.sortedCache = null;
2034
2097
  this._versions.set(element.id, 0);
2035
2098
  this.elements.set(element.id, element);
2036
- const bounds = getElementBounds(element);
2099
+ const bounds = this.indexBounds(element);
2037
2100
  if (bounds) this.spatialIndex.insert(element.id, bounds);
2038
2101
  this.bus.emit("add", element);
2039
2102
  }
@@ -2055,7 +2118,7 @@ var ElementStore = class {
2055
2118
  updated.text = sanitizeNoteHtml(updated.text);
2056
2119
  }
2057
2120
  this.elements.set(id, updated);
2058
- const newBounds = getElementBounds(updated);
2121
+ const newBounds = this.indexBounds(updated);
2059
2122
  if (newBounds) {
2060
2123
  this.spatialIndex.update(id, newBounds);
2061
2124
  }
@@ -2088,7 +2151,7 @@ var ElementStore = class {
2088
2151
  for (const el of elements) {
2089
2152
  this.elements.set(el.id, el);
2090
2153
  this._versions.set(el.id, 0);
2091
- const bounds = getElementBounds(el);
2154
+ const bounds = this.indexBounds(el);
2092
2155
  if (bounds) this.spatialIndex.insert(el.id, bounds);
2093
2156
  if (el.type === "stroke") {
2094
2157
  computeStrokeSegments(el);
@@ -2293,9 +2356,9 @@ function updateBoundArrow(arrow, store) {
2293
2356
  if (arrow.fromBinding) {
2294
2357
  const el = store.getById(arrow.fromBinding.elementId);
2295
2358
  if (el) {
2296
- const center = getElementCenter(el);
2297
- updates.from = center;
2298
- updates.position = center;
2359
+ const center2 = getElementCenter(el);
2360
+ updates.from = center2;
2361
+ updates.position = center2;
2299
2362
  }
2300
2363
  }
2301
2364
  if (arrow.toBinding) {
@@ -2307,6 +2370,21 @@ function updateBoundArrow(arrow, store) {
2307
2370
  return Object.keys(updates).length > 0 ? updates : null;
2308
2371
  }
2309
2372
 
2373
+ // src/elements/rotate-canvas.ts
2374
+ function withRotation(ctx, el, center2, draw) {
2375
+ const angle = el.rotation ?? 0;
2376
+ if (angle === 0) {
2377
+ draw();
2378
+ return;
2379
+ }
2380
+ ctx.save();
2381
+ ctx.translate(center2.x, center2.y);
2382
+ ctx.rotate(angle);
2383
+ ctx.translate(-center2.x, -center2.y);
2384
+ draw();
2385
+ ctx.restore();
2386
+ }
2387
+
2310
2388
  // src/elements/grid-renderer.ts
2311
2389
  function getSquareGridLines(bounds, cellSize) {
2312
2390
  if (cellSize <= 0) return { verticals: [], horizontals: [] };
@@ -2570,18 +2648,18 @@ function getHexDistance(a, b, cellSize, orientation) {
2570
2648
  const ds = -dq - dr;
2571
2649
  return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
2572
2650
  }
2573
- function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
2651
+ function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
2574
2652
  const n = Math.round(radiusCells);
2575
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2653
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2576
2654
  const cube = offsetToCube(off.col, off.row, orientation);
2577
2655
  if (n <= 0) {
2578
2656
  return [offsetToPixel(off.col, off.row, cellSize, orientation)];
2579
2657
  }
2580
2658
  return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
2581
2659
  }
2582
- function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2660
+ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
2583
2661
  const n = Math.round(radiusCells);
2584
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2662
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2585
2663
  const cube = offsetToCube(off.col, off.row, orientation);
2586
2664
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2587
2665
  if (n <= 0) return [centerPixel];
@@ -2615,9 +2693,9 @@ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
2615
2693
  }
2616
2694
  return cells;
2617
2695
  }
2618
- function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2696
+ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
2619
2697
  const n = Math.round(radiusCells);
2620
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2698
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2621
2699
  const cube = offsetToCube(off.col, off.row, orientation);
2622
2700
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2623
2701
  if (n <= 0) return [centerPixel];
@@ -2653,9 +2731,9 @@ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
2653
2731
  }
2654
2732
  return cells;
2655
2733
  }
2656
- function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
2734
+ function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
2657
2735
  const n = Math.round(radiusCells);
2658
- const off = pixelToOffset(center.x, center.y, cellSize, orientation);
2736
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2659
2737
  const cube = offsetToCube(off.col, off.row, orientation);
2660
2738
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2661
2739
  if (n <= 0) return [centerPixel];
@@ -2733,18 +2811,27 @@ var ElementRenderer = class {
2733
2811
  }
2734
2812
  renderCanvasElement(ctx, element) {
2735
2813
  switch (element.type) {
2736
- case "stroke":
2737
- this.renderStroke(ctx, element);
2814
+ case "stroke": {
2815
+ const b = getElementBounds(element);
2816
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2817
+ withRotation(ctx, element, c, () => this.renderStroke(ctx, element));
2738
2818
  break;
2819
+ }
2739
2820
  case "arrow":
2740
2821
  this.renderArrow(ctx, element);
2741
2822
  break;
2742
- case "shape":
2743
- this.renderShape(ctx, element);
2823
+ case "shape": {
2824
+ const b = getElementBounds(element);
2825
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2826
+ withRotation(ctx, element, c, () => this.renderShape(ctx, element));
2744
2827
  break;
2745
- case "image":
2746
- this.renderImage(ctx, element);
2828
+ }
2829
+ case "image": {
2830
+ const b = getElementBounds(element);
2831
+ const c = b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : element.position;
2832
+ withRotation(ctx, element, c, () => this.renderImage(ctx, element));
2747
2833
  break;
2834
+ }
2748
2835
  case "grid":
2749
2836
  this.renderGrid(ctx, element);
2750
2837
  break;
@@ -3041,20 +3128,20 @@ var ElementRenderer = class {
3041
3128
  renderHexTemplate(ctx, template, cellSize, orientation) {
3042
3129
  const snapUnit = Math.sqrt(3) * cellSize;
3043
3130
  const radiusCells = template.radius / snapUnit;
3044
- const center = template.position;
3131
+ const center2 = template.position;
3045
3132
  let cells;
3046
3133
  switch (template.templateShape) {
3047
3134
  case "circle":
3048
- cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
3135
+ cells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
3049
3136
  break;
3050
3137
  case "cone":
3051
- cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
3138
+ cells = getHexCellsInCone(center2, template.angle, radiusCells, cellSize, orientation);
3052
3139
  break;
3053
3140
  case "line":
3054
- cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
3141
+ cells = getHexCellsInLine(center2, template.angle, radiusCells, cellSize, orientation);
3055
3142
  break;
3056
3143
  case "square":
3057
- cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
3144
+ cells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
3058
3145
  break;
3059
3146
  }
3060
3147
  ctx.save();
@@ -3075,7 +3162,7 @@ var ElementRenderer = class {
3075
3162
  {
3076
3163
  ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
3077
3164
  ctx.beginPath();
3078
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
3165
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
3079
3166
  ctx.fillStyle = template.strokeColor;
3080
3167
  ctx.fill();
3081
3168
  ctx.strokeStyle = template.strokeColor;
@@ -3084,7 +3171,7 @@ var ElementRenderer = class {
3084
3171
  }
3085
3172
  if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
3086
3173
  const r = template.radius;
3087
- this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
3174
+ this.renderRadiusMarker(ctx, center2.x, center2.y, r, template.radiusFeet);
3088
3175
  }
3089
3176
  ctx.restore();
3090
3177
  }
@@ -3937,12 +4024,19 @@ var UpdateElementCommand = class {
3937
4024
  this.current = current;
3938
4025
  }
3939
4026
  execute(store) {
3940
- store.update(this.id, { ...this.current });
4027
+ store.update(this.id, diffPatch(this.previous, this.current));
3941
4028
  }
3942
4029
  undo(store) {
3943
- store.update(this.id, { ...this.previous });
4030
+ store.update(this.id, diffPatch(this.current, this.previous));
3944
4031
  }
3945
4032
  };
4033
+ function diffPatch(from, to) {
4034
+ const patch = { ...to };
4035
+ for (const key of Object.keys(from)) {
4036
+ if (!(key in to)) patch[key] = void 0;
4037
+ }
4038
+ return patch;
4039
+ }
3946
4040
  var BatchCommand = class {
3947
4041
  commands;
3948
4042
  constructor(commands) {
@@ -4196,6 +4290,7 @@ function renderStyledRuns(ctx, runs, startX, startY, maxWidth) {
4196
4290
  }
4197
4291
 
4198
4292
  // src/canvas/export-image.ts
4293
+ var center = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
4199
4294
  function getStrokeBounds(el) {
4200
4295
  if (el.type !== "stroke") return null;
4201
4296
  if (el.points.length === 0) return null;
@@ -4221,8 +4316,10 @@ function getStrokeBounds(el) {
4221
4316
  }
4222
4317
  function getElementRect(el) {
4223
4318
  switch (el.type) {
4224
- case "stroke":
4225
- return getStrokeBounds(el);
4319
+ case "stroke": {
4320
+ const r = getStrokeBounds(el);
4321
+ return r ? rotatedAABB(r, el.rotation ?? 0) : r;
4322
+ }
4226
4323
  case "arrow": {
4227
4324
  const b = getArrowBounds(el.from, el.to, el.bend);
4228
4325
  const pad = el.width / 2 + 14;
@@ -4241,7 +4338,10 @@ function getElementRect(el) {
4241
4338
  case "text":
4242
4339
  case "shape":
4243
4340
  if ("size" in el) {
4244
- return { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
4341
+ return rotatedAABB(
4342
+ { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h },
4343
+ el.rotation ?? 0
4344
+ );
4245
4345
  }
4246
4346
  return null;
4247
4347
  default:
@@ -4379,11 +4479,13 @@ async function exportImage(store, options = {}, layerManager) {
4379
4479
  continue;
4380
4480
  }
4381
4481
  if (el.type === "note") {
4382
- renderNoteOnCanvas(ctx, el);
4482
+ const b = getElementBounds(el);
4483
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderNoteOnCanvas(ctx, el));
4383
4484
  continue;
4384
4485
  }
4385
4486
  if (el.type === "text") {
4386
- renderTextOnCanvas(ctx, el);
4487
+ const b = getElementBounds(el);
4488
+ withRotation(ctx, el, b ? center(b) : el.position, () => renderTextOnCanvas(ctx, el));
4387
4489
  continue;
4388
4490
  }
4389
4491
  if (el.type === "html") {
@@ -4392,7 +4494,13 @@ async function exportImage(store, options = {}, layerManager) {
4392
4494
  if (el.type === "image") {
4393
4495
  const img = imageCache.get(el.id);
4394
4496
  if (img) {
4395
- ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h);
4497
+ const b = getElementBounds(el);
4498
+ withRotation(
4499
+ ctx,
4500
+ el,
4501
+ b ? center(b) : el.position,
4502
+ () => ctx.drawImage(img, el.position.x, el.position.y, el.size.w, el.size.h)
4503
+ );
4396
4504
  }
4397
4505
  continue;
4398
4506
  }
@@ -4728,7 +4836,9 @@ var DomNodeManager = class {
4728
4836
  top: `${element.position.y}px`,
4729
4837
  width: size ? `${size.w}px` : "auto",
4730
4838
  height: size ? `${size.h}px` : "auto",
4731
- zIndex: String(zIndex)
4839
+ zIndex: String(zIndex),
4840
+ transform: element.rotation ? `rotate(${element.rotation}rad)` : "",
4841
+ transformOrigin: "50% 50%"
4732
4842
  });
4733
4843
  this.renderDomContent(node, element);
4734
4844
  }
@@ -5505,6 +5615,8 @@ var Viewport = class {
5505
5615
  historyRecorder: this.historyRecorder,
5506
5616
  historyStack: this.history,
5507
5617
  fitToContent: () => this.fitToContent(),
5618
+ group: () => this.groupSelection(),
5619
+ ungroup: () => this.ungroupSelection(),
5508
5620
  shortcuts: options.shortcuts
5509
5621
  });
5510
5622
  this.domNodeManager = new DomNodeManager({
@@ -5842,6 +5954,26 @@ var Viewport = class {
5842
5954
  }
5843
5955
  this.historyRecorder.commit();
5844
5956
  }
5957
+ groupSelection() {
5958
+ const ids = this.getSelectedIds();
5959
+ if (ids.length < 2) return;
5960
+ const groupId = createId("group");
5961
+ this.historyRecorder.begin();
5962
+ for (const id of ids) {
5963
+ if (this.store.getById(id)) this.store.update(id, { groupId });
5964
+ }
5965
+ this.historyRecorder.commit();
5966
+ }
5967
+ ungroupSelection() {
5968
+ const ids = this.getSelectedIds();
5969
+ if (ids.length === 0) return;
5970
+ this.historyRecorder.begin();
5971
+ for (const id of ids) {
5972
+ const el = this.store.getById(id);
5973
+ if (el && el.groupId !== void 0) this.store.update(id, { groupId: void 0 });
5974
+ }
5975
+ this.historyRecorder.commit();
5976
+ }
5845
5977
  alignSelection(edge) {
5846
5978
  const bounded = this.boundedSelection();
5847
5979
  if (bounded.length < 2) return;
@@ -5883,13 +6015,13 @@ var Viewport = class {
5883
6015
  distributeSelection(axis) {
5884
6016
  const bounded = this.boundedSelection();
5885
6017
  if (bounded.length < 3) return;
5886
- const center = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
5887
- const sorted = [...bounded].sort((p, q) => center(p.bounds) - center(q.bounds));
6018
+ const center2 = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
6019
+ const sorted = [...bounded].sort((p, q) => center2(p.bounds) - center2(q.bounds));
5888
6020
  const first = sorted[0];
5889
6021
  const last = sorted[sorted.length - 1];
5890
6022
  if (!first || !last) return;
5891
- const c0 = center(first.bounds);
5892
- const cN = center(last.bounds);
6023
+ const c0 = center2(first.bounds);
6024
+ const cN = center2(last.bounds);
5893
6025
  const n = sorted.length;
5894
6026
  this.historyRecorder.begin();
5895
6027
  const moved = [];
@@ -5897,7 +6029,7 @@ var Viewport = class {
5897
6029
  const item = sorted[i];
5898
6030
  if (!item || !this.isMovable(item.el)) continue;
5899
6031
  const target = c0 + i * (cN - c0) / (n - 1);
5900
- const delta = target - center(item.bounds);
6032
+ const delta = target - center2(item.bounds);
5901
6033
  if (delta === 0) continue;
5902
6034
  const [dx, dy] = axis === "horizontal" ? [delta, 0] : [0, delta];
5903
6035
  this.store.update(item.id, translateElementPatch(item.el, dx, dy));
@@ -6551,6 +6683,26 @@ var EraserTool = class {
6551
6683
  }
6552
6684
  };
6553
6685
 
6686
+ // src/elements/group.ts
6687
+ function expandToGroups(ids, elements) {
6688
+ const byId = new Map(elements.map((e) => [e.id, e]));
6689
+ const groupIds = /* @__PURE__ */ new Set();
6690
+ for (const id of ids) {
6691
+ const g = byId.get(id)?.groupId;
6692
+ if (g) groupIds.add(g);
6693
+ }
6694
+ if (groupIds.size === 0) return ids;
6695
+ const idSet = new Set(ids);
6696
+ const result = [...ids];
6697
+ for (const el of elements) {
6698
+ if (el.groupId && groupIds.has(el.groupId) && !idSet.has(el.id)) {
6699
+ result.push(el.id);
6700
+ idSet.add(el.id);
6701
+ }
6702
+ }
6703
+ return result;
6704
+ }
6705
+
6554
6706
  // src/tools/arrow-handles.ts
6555
6707
  var BIND_THRESHOLD = 20;
6556
6708
  var HANDLE_RADIUS = 5;
@@ -6600,10 +6752,10 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6600
6752
  const excludeId = el.toBinding?.elementId;
6601
6753
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6602
6754
  if (target) {
6603
- const center = getElementCenter(target);
6755
+ const center2 = getElementCenter(target);
6604
6756
  ctx.store.update(elementId, {
6605
- from: center,
6606
- position: center,
6757
+ from: center2,
6758
+ position: center2,
6607
6759
  fromBinding: { elementId: target.id }
6608
6760
  });
6609
6761
  } else {
@@ -6619,9 +6771,9 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
6619
6771
  const excludeId = el.fromBinding?.elementId;
6620
6772
  const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
6621
6773
  if (target) {
6622
- const center = getElementCenter(target);
6774
+ const center2 = getElementCenter(target);
6623
6775
  ctx.store.update(elementId, {
6624
- to: center,
6776
+ to: center2,
6625
6777
  toBinding: { elementId: target.id }
6626
6778
  });
6627
6779
  } else {
@@ -6710,6 +6862,9 @@ var SNAP_PX = 6;
6710
6862
  var HANDLE_HIT_PADDING2 = 4;
6711
6863
  var SELECTION_PAD = 4;
6712
6864
  var MIN_ELEMENT_SIZE = 20;
6865
+ var ROTATE_HANDLE_OFFSET = 24;
6866
+ var ROTATE_SNAP = Math.PI / 12;
6867
+ var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
6713
6868
  var HANDLE_CURSORS = {
6714
6869
  nw: "nwse-resize",
6715
6870
  se: "nwse-resize",
@@ -6798,6 +6953,22 @@ var SelectTool = class {
6798
6953
  ctx.requestRender();
6799
6954
  return;
6800
6955
  }
6956
+ const rotateHit = this.hitTestRotateHandle(world, ctx);
6957
+ if (rotateHit) {
6958
+ const el = ctx.store.getById(rotateHit.elementId);
6959
+ const layout = el ? this.getOverlayLayout(el, ctx.camera.zoom) : null;
6960
+ if (el && layout) {
6961
+ this.mode = {
6962
+ type: "rotating",
6963
+ elementId: rotateHit.elementId,
6964
+ center: layout.center,
6965
+ startPointerAngle: Math.atan2(world.y - layout.center.y, world.x - layout.center.x),
6966
+ startRotation: el.rotation ?? 0
6967
+ };
6968
+ ctx.requestRender();
6969
+ return;
6970
+ }
6971
+ }
6801
6972
  const resizeHit = this.hitTestResizeHandle(world, ctx);
6802
6973
  if (resizeHit) {
6803
6974
  const el = ctx.store.getById(resizeHit.elementId);
@@ -6816,18 +6987,20 @@ var SelectTool = class {
6816
6987
  this.hasDragged = false;
6817
6988
  const hit = this.hitTest(world, ctx);
6818
6989
  if (hit) {
6990
+ const all = ctx.store.getAll();
6819
6991
  const alreadySelected = this._selectedIds.includes(hit.id);
6820
6992
  if (state.shiftKey) {
6821
6993
  if (alreadySelected) {
6822
- this.setSelectedIds(this._selectedIds.filter((id) => id !== hit.id));
6994
+ const grp = new Set(expandToGroups([hit.id], all));
6995
+ this.setSelectedIds(this._selectedIds.filter((id) => !grp.has(id)));
6823
6996
  this.mode = { type: "idle" };
6824
6997
  } else {
6825
- this.setSelectedIds([...this._selectedIds, hit.id]);
6998
+ this.setSelectedIds(expandToGroups([...this._selectedIds, hit.id], all));
6826
6999
  this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
6827
7000
  }
6828
7001
  } else {
6829
7002
  if (!alreadySelected) {
6830
- this.setSelectedIds([hit.id]);
7003
+ this.setSelectedIds(expandToGroups([hit.id], all));
6831
7004
  } else if (this._selectedIds.length > 1) {
6832
7005
  this.pendingSingleSelectId = hit.id;
6833
7006
  }
@@ -6861,6 +7034,15 @@ var SelectTool = class {
6861
7034
  this.handleTemplateResize(world, ctx);
6862
7035
  return;
6863
7036
  }
7037
+ if (this.mode.type === "rotating") {
7038
+ const { elementId, center: center2, startPointerAngle, startRotation } = this.mode;
7039
+ const a = Math.atan2(world.y - center2.y, world.x - center2.x);
7040
+ let next = startRotation + (a - startPointerAngle);
7041
+ if (state.shiftKey) next = Math.round(next / ROTATE_SNAP) * ROTATE_SNAP;
7042
+ ctx.store.update(elementId, { rotation: normalizeAngle(next) });
7043
+ ctx.requestRender();
7044
+ return;
7045
+ }
6864
7046
  if (this.mode.type === "resizing") {
6865
7047
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
6866
7048
  this.handleResize(world, ctx, state.shiftKey);
@@ -6941,12 +7123,12 @@ var SelectTool = class {
6941
7123
  if (this.mode.type === "marquee") {
6942
7124
  const rect = this.getMarqueeRect();
6943
7125
  if (rect) {
6944
- this.setSelectedIds(this.findElementsInRect(rect, ctx));
7126
+ this.setSelectedIds(expandToGroups(this.findElementsInRect(rect, ctx), ctx.store.getAll()));
6945
7127
  }
6946
7128
  ctx.requestRender();
6947
7129
  }
6948
7130
  if (!this.hasDragged && this.pendingSingleSelectId !== null) {
6949
- this.setSelectedIds([this.pendingSingleSelectId]);
7131
+ this.setSelectedIds(expandToGroups([this.pendingSingleSelectId], ctx.store.getAll()));
6950
7132
  }
6951
7133
  this.pendingSingleSelectId = null;
6952
7134
  this.hasDragged = false;
@@ -7063,6 +7245,10 @@ var SelectTool = class {
7063
7245
  ctx.setCursor?.("nwse-resize");
7064
7246
  return null;
7065
7247
  }
7248
+ if (this.hitTestRotateHandle(world, ctx)) {
7249
+ ctx.setCursor?.("grab");
7250
+ return null;
7251
+ }
7066
7252
  const resizeHit = this.hitTestResizeHandle(world, ctx);
7067
7253
  if (resizeHit) {
7068
7254
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -7081,6 +7267,11 @@ var SelectTool = class {
7081
7267
  if (this.mode.type !== "resizing") return;
7082
7268
  const el = ctx.store.getById(this.mode.elementId);
7083
7269
  if (!el || !("size" in el) || el.locked) return;
7270
+ const angle = el.rotation ?? 0;
7271
+ if (angle !== 0) {
7272
+ this.handleRotatedResize(world, el, angle, ctx, shiftKey);
7273
+ return;
7274
+ }
7084
7275
  const { handle } = this.mode;
7085
7276
  const dx = world.x - this.lastWorld.x;
7086
7277
  const dy = world.y - this.lastWorld.y;
@@ -7138,6 +7329,78 @@ var SelectTool = class {
7138
7329
  this.updateArrowsBoundTo([this.mode.elementId], ctx);
7139
7330
  ctx.requestRender();
7140
7331
  }
7332
+ anchorOffset(handle, w, h) {
7333
+ switch (handle) {
7334
+ case "se":
7335
+ return { x: -w / 2, y: -h / 2 };
7336
+ case "sw":
7337
+ return { x: w / 2, y: -h / 2 };
7338
+ case "ne":
7339
+ return { x: -w / 2, y: h / 2 };
7340
+ case "nw":
7341
+ return { x: w / 2, y: h / 2 };
7342
+ default:
7343
+ return { x: 0, y: 0 };
7344
+ }
7345
+ }
7346
+ handleRotatedResize(world, el, angle, ctx, shiftKey) {
7347
+ if (this.mode.type !== "resizing") return;
7348
+ const { handle } = this.mode;
7349
+ const wdx = world.x - this.lastWorld.x;
7350
+ const wdy = world.y - this.lastWorld.y;
7351
+ this.lastWorld = world;
7352
+ const cosN = Math.cos(-angle);
7353
+ const sinN = Math.sin(-angle);
7354
+ const ldx = wdx * cosN - wdy * sinN;
7355
+ const ldy = wdx * sinN + wdy * cosN;
7356
+ let w = el.size.w;
7357
+ let h = el.size.h;
7358
+ switch (handle) {
7359
+ case "se":
7360
+ w += ldx;
7361
+ h += ldy;
7362
+ break;
7363
+ case "sw":
7364
+ w -= ldx;
7365
+ h += ldy;
7366
+ break;
7367
+ case "ne":
7368
+ w += ldx;
7369
+ h -= ldy;
7370
+ break;
7371
+ case "nw":
7372
+ w -= ldx;
7373
+ h -= ldy;
7374
+ break;
7375
+ }
7376
+ if (shiftKey && this.resizeAspectRatio > 0) {
7377
+ const absDw = Math.abs(w - el.size.w);
7378
+ const absDh = Math.abs(h - el.size.h);
7379
+ if (absDw >= absDh) h = w / this.resizeAspectRatio;
7380
+ else w = h * this.resizeAspectRatio;
7381
+ }
7382
+ w = Math.max(w, MIN_ELEMENT_SIZE);
7383
+ h = Math.max(h, MIN_ELEMENT_SIZE);
7384
+ const oldCenter = { x: el.position.x + el.size.w / 2, y: el.position.y + el.size.h / 2 };
7385
+ const oldAnchorLocal = this.anchorOffset(handle, el.size.w, el.size.h);
7386
+ const anchorWorld = rotatePoint(
7387
+ { x: oldCenter.x + oldAnchorLocal.x, y: oldCenter.y + oldAnchorLocal.y },
7388
+ oldCenter,
7389
+ angle
7390
+ );
7391
+ const newAnchorLocal = this.anchorOffset(handle, w, h);
7392
+ const cos = Math.cos(angle);
7393
+ const sin = Math.sin(angle);
7394
+ const rotatedAnchor = {
7395
+ x: newAnchorLocal.x * cos - newAnchorLocal.y * sin,
7396
+ y: newAnchorLocal.x * sin + newAnchorLocal.y * cos
7397
+ };
7398
+ const newCenter = { x: anchorWorld.x - rotatedAnchor.x, y: anchorWorld.y - rotatedAnchor.y };
7399
+ const position = { x: newCenter.x - w / 2, y: newCenter.y - h / 2 };
7400
+ ctx.store.update(this.mode.elementId, { position, size: { w, h } });
7401
+ this.updateArrowsBoundTo([this.mode.elementId], ctx);
7402
+ ctx.requestRender();
7403
+ }
7141
7404
  hitTestResizeHandle(world, ctx) {
7142
7405
  if (this._selectedIds.length === 0) return null;
7143
7406
  const zoom = ctx.camera.zoom;
@@ -7146,10 +7409,9 @@ var SelectTool = class {
7146
7409
  const el = ctx.store.getById(id);
7147
7410
  if (!el || !("size" in el)) continue;
7148
7411
  if (el.type === "shape" && el.shape === "line") continue;
7149
- const bounds = getElementBounds(el);
7150
- if (!bounds) continue;
7151
- const corners = this.getHandlePositions(bounds);
7152
- for (const [handle, pos] of corners) {
7412
+ const layout = this.getOverlayLayout(el, zoom);
7413
+ if (!layout) continue;
7414
+ for (const [handle, pos] of layout.corners) {
7153
7415
  if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
7154
7416
  return { elementId: id, handle };
7155
7417
  }
@@ -7157,6 +7419,19 @@ var SelectTool = class {
7157
7419
  }
7158
7420
  return null;
7159
7421
  }
7422
+ hitTestRotateHandle(world, ctx) {
7423
+ if (this._selectedIds.length !== 1) return null;
7424
+ const id = this._selectedIds[0];
7425
+ if (!id) return null;
7426
+ const el = ctx.store.getById(id);
7427
+ if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
7428
+ const layout = this.getOverlayLayout(el, ctx.camera.zoom);
7429
+ if (!layout) return null;
7430
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
7431
+ const dx = world.x - layout.rotateHandle.x;
7432
+ const dy = world.y - layout.rotateHandle.y;
7433
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
7434
+ }
7160
7435
  hitTestLineHandles(world, ctx) {
7161
7436
  if (this._selectedIds.length === 0) return null;
7162
7437
  const zoom = ctx.camera.zoom;
@@ -7179,6 +7454,30 @@ var SelectTool = class {
7179
7454
  ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
7180
7455
  ];
7181
7456
  }
7457
+ getOverlayLayout(el, zoom) {
7458
+ const bounds = getElementBounds(el);
7459
+ if (!bounds) return null;
7460
+ const angle = el.rotation ?? 0;
7461
+ const pad = SELECTION_PAD / zoom;
7462
+ const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
7463
+ const raw = [
7464
+ ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
7465
+ ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
7466
+ ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
7467
+ ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
7468
+ ];
7469
+ const corners = raw.map(
7470
+ ([h, p]) => [h, rotatePoint(p, center2, angle)]
7471
+ );
7472
+ const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
7473
+ const rotateHandle = rotatePoint(topMid, center2, angle);
7474
+ return { center: center2, corners, rotateHandle, angle };
7475
+ }
7476
+ topMidpoint(layout) {
7477
+ const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
7478
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
7479
+ return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
7480
+ }
7182
7481
  renderMarquee(canvasCtx) {
7183
7482
  if (this.mode.type !== "marquee") return;
7184
7483
  const rect = this.getMarqueeRect();
@@ -7223,12 +7522,31 @@ var SelectTool = class {
7223
7522
  }
7224
7523
  const bounds = getElementBounds(el);
7225
7524
  if (!bounds) continue;
7525
+ const layout = this.getOverlayLayout(el, zoom);
7526
+ if (!layout) continue;
7226
7527
  const pad = SELECTION_PAD / zoom;
7227
- canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
7528
+ if (layout.angle === 0) {
7529
+ canvasCtx.strokeRect(
7530
+ bounds.x - pad,
7531
+ bounds.y - pad,
7532
+ bounds.w + pad * 2,
7533
+ bounds.h + pad * 2
7534
+ );
7535
+ } else {
7536
+ const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((p) => !!p);
7537
+ const [p0, ...others] = ordered;
7538
+ if (p0) {
7539
+ canvasCtx.beginPath();
7540
+ canvasCtx.moveTo(p0.x, p0.y);
7541
+ for (const p of others) canvasCtx.lineTo(p.x, p.y);
7542
+ canvasCtx.closePath();
7543
+ canvasCtx.stroke();
7544
+ }
7545
+ }
7228
7546
  if ("size" in el) {
7229
7547
  canvasCtx.setLineDash([]);
7230
7548
  canvasCtx.fillStyle = "#ffffff";
7231
- const corners = this.getHandlePositions(bounds);
7549
+ const corners = layout.angle === 0 ? this.getHandlePositions(bounds) : layout.corners;
7232
7550
  for (const [, pos] of corners) {
7233
7551
  canvasCtx.fillRect(
7234
7552
  pos.x - handleWorldSize / 2,
@@ -7263,6 +7581,21 @@ var SelectTool = class {
7263
7581
  );
7264
7582
  canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7265
7583
  }
7584
+ if (this._selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
7585
+ const stemStart = this.topMidpoint(layout);
7586
+ const stemEnd = layout.rotateHandle;
7587
+ canvasCtx.beginPath();
7588
+ canvasCtx.moveTo(stemStart.x, stemStart.y);
7589
+ canvasCtx.lineTo(stemEnd.x, stemEnd.y);
7590
+ canvasCtx.stroke();
7591
+ canvasCtx.setLineDash([]);
7592
+ canvasCtx.fillStyle = "#ffffff";
7593
+ canvasCtx.beginPath();
7594
+ canvasCtx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
7595
+ canvasCtx.fill();
7596
+ canvasCtx.stroke();
7597
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
7598
+ }
7266
7599
  }
7267
7600
  canvasCtx.restore();
7268
7601
  }
@@ -7342,7 +7675,7 @@ var SelectTool = class {
7342
7675
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
7343
7676
  if (el.type === "grid") continue;
7344
7677
  const bounds = getElementBounds(el);
7345
- if (bounds && this.rectsOverlap(marquee, bounds)) {
7678
+ if (bounds && this.rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
7346
7679
  ids.push(el.id);
7347
7680
  }
7348
7681
  }
@@ -7364,6 +7697,13 @@ var SelectTool = class {
7364
7697
  }
7365
7698
  isInsideBounds(point, el) {
7366
7699
  if (el.type === "grid") return false;
7700
+ const angle = el.rotation ?? 0;
7701
+ if (angle !== 0) {
7702
+ const b = getElementBounds(el);
7703
+ if (b) {
7704
+ point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
7705
+ }
7706
+ }
7367
7707
  if (el.type === "shape" && el.shape === "line") {
7368
7708
  const [a, b] = lineEndpoints(el);
7369
7709
  const threshold = Math.max(el.strokeWidth / 2, 6);
@@ -8130,20 +8470,20 @@ var TemplateTool = class {
8130
8470
  const snapUnit = Math.sqrt(3) * cellSize;
8131
8471
  const radiusCells = radius / snapUnit;
8132
8472
  const angle = this.computeAngle();
8133
- const center = this.origin;
8473
+ const center2 = this.origin;
8134
8474
  let hexCells;
8135
8475
  switch (this.templateShape) {
8136
8476
  case "circle":
8137
- hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
8477
+ hexCells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
8138
8478
  break;
8139
8479
  case "cone":
8140
- hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
8480
+ hexCells = getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation);
8141
8481
  break;
8142
8482
  case "line":
8143
- hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
8483
+ hexCells = getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation);
8144
8484
  break;
8145
8485
  case "square":
8146
- hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
8486
+ hexCells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
8147
8487
  break;
8148
8488
  }
8149
8489
  ctx.save();
@@ -8164,7 +8504,7 @@ var TemplateTool = class {
8164
8504
  if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
8165
8505
  ctx.globalAlpha = 0.5;
8166
8506
  ctx.beginPath();
8167
- drawHexPath(ctx, center.x, center.y, cellSize, orientation);
8507
+ drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
8168
8508
  ctx.fillStyle = this.strokeColor;
8169
8509
  ctx.fill();
8170
8510
  ctx.strokeStyle = this.strokeColor;
@@ -8180,8 +8520,8 @@ var TemplateTool = class {
8180
8520
  ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
8181
8521
  ctx.textAlign = "center";
8182
8522
  ctx.textBaseline = "bottom";
8183
- const textX = center.x;
8184
- const textY = center.y - 4;
8523
+ const textX = center2.x;
8524
+ const textY = center2.y - 4;
8185
8525
  const metrics = ctx.measureText(label);
8186
8526
  const padX = 4;
8187
8527
  const padY = 2;
@@ -8231,7 +8571,7 @@ var TemplateTool = class {
8231
8571
  };
8232
8572
 
8233
8573
  // src/index.ts
8234
- var VERSION = "0.33.0";
8574
+ var VERSION = "0.35.0";
8235
8575
  export {
8236
8576
  ArrowTool,
8237
8577
  AutoSave,