@fieldnotes/core 0.32.0 → 0.34.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/README.md CHANGED
@@ -579,6 +579,31 @@ viewport.distributeSelection('horizontal'); // equal horizontal spacing
579
579
 
580
580
  Grids are ignored by both operations.
581
581
 
582
+ ## Smart Alignment Guides
583
+
584
+ Call `viewport.setSmartGuides(true)` to enable drag-time alignment snapping. While dragging a selection, its edges and centers snap to the edges and centers of nearby visible elements (within 6 screen pixels), and guide lines are drawn at each matched alignment. Smart guides replace grid snapping for the duration of the drag; the result is still committed as a single undo step.
585
+
586
+ ```typescript
587
+ viewport.setSmartGuides(true); // enable
588
+ viewport.setSmartGuides(false); // disable (default)
589
+ ```
590
+
591
+ ## Grouping
592
+
593
+ Group elements so they select, move, delete, z-order, and align as a single unit.
594
+
595
+ - **`viewport.groupSelection()`** — groups the current selection under a new id.
596
+ - **`viewport.ungroupSelection()`** — dissolves any groups in the current selection.
597
+
598
+ Each is one undo step. Selecting any member selects its whole group, so to edit a single member individually, ungroup first. Pasting or duplicating a group keeps the copies grouped under a fresh id.
599
+
600
+ ```typescript
601
+ viewport.groupSelection(); // Ctrl/Cmd+G
602
+ viewport.ungroupSelection(); // Ctrl/Cmd+Shift+G
603
+ ```
604
+
605
+ The shortcuts are rebindable as `group` and `ungroup`.
606
+
582
607
  ## Built-in Interactions
583
608
 
584
609
  | Input | Action |
package/dist/index.cjs CHANGED
@@ -1021,6 +1021,14 @@ var KeyboardActions = class {
1021
1021
  if (this.deps.isToolActive()) return;
1022
1022
  this.deps.fitToContent?.();
1023
1023
  }
1024
+ group() {
1025
+ if (this.deps.isToolActive()) return;
1026
+ this.deps.group?.();
1027
+ }
1028
+ ungroup() {
1029
+ if (this.deps.isToolActive()) return;
1030
+ this.deps.ungroup?.();
1031
+ }
1024
1032
  zOrder(operation) {
1025
1033
  if (this.deps.isToolActive()) return;
1026
1034
  this.flushPendingNudge();
@@ -1054,6 +1062,10 @@ var KeyboardActions = class {
1054
1062
  for (const el of source) {
1055
1063
  idMap.set(el.id, createId(el.type));
1056
1064
  }
1065
+ const groupIdMap = /* @__PURE__ */ new Map();
1066
+ for (const el of source) {
1067
+ if (el.groupId && !groupIdMap.has(el.groupId)) groupIdMap.set(el.groupId, createId("group"));
1068
+ }
1057
1069
  const newIds = [];
1058
1070
  const recorder = this.deps.getHistoryRecorder();
1059
1071
  recorder?.begin();
@@ -1062,6 +1074,7 @@ var KeyboardActions = class {
1062
1074
  const newId = idMap.get(el.id);
1063
1075
  if (!newId) continue;
1064
1076
  clone.id = newId;
1077
+ if (clone.groupId) clone.groupId = groupIdMap.get(clone.groupId) ?? clone.groupId;
1065
1078
  clone.position = { x: clone.position.x + offset.x, y: clone.position.y + offset.y };
1066
1079
  if (clone.type === "arrow") {
1067
1080
  const arrow = clone;
@@ -1115,6 +1128,8 @@ var DEFAULT_BINDINGS = [
1115
1128
  ["zoom-in", ["mod+="]],
1116
1129
  ["zoom-out", ["mod+-"]],
1117
1130
  ["zoom-reset", ["mod+0"]],
1131
+ ["group", ["mod+g"]],
1132
+ ["ungroup", ["mod+shift+g"]],
1118
1133
  ["nudge-left", ["arrowleft"]],
1119
1134
  ["nudge-right", ["arrowright"]],
1120
1135
  ["nudge-up", ["arrowup"]],
@@ -1274,6 +1289,8 @@ var InputHandler = class {
1274
1289
  getHistoryStack: () => this.historyStack,
1275
1290
  isToolActive: () => this.isToolActive,
1276
1291
  fitToContent: options.fitToContent,
1292
+ group: options.group,
1293
+ ungroup: options.ungroup,
1277
1294
  getLastPointerWorld: () => this.lastPointerWorld()
1278
1295
  });
1279
1296
  this.shortcutMap = new ShortcutMap(options.shortcuts?.bindings);
@@ -1513,6 +1530,14 @@ var InputHandler = class {
1513
1530
  e.preventDefault();
1514
1531
  this.actions.zoomToFit();
1515
1532
  return;
1533
+ case "group":
1534
+ e.preventDefault();
1535
+ this.actions.group();
1536
+ return;
1537
+ case "ungroup":
1538
+ e.preventDefault();
1539
+ this.actions.ungroup();
1540
+ return;
1516
1541
  case "zoom-in":
1517
1542
  e.preventDefault();
1518
1543
  this.zoomByFactor(ZOOM_STEP);
@@ -4018,12 +4043,19 @@ var UpdateElementCommand = class {
4018
4043
  this.current = current;
4019
4044
  }
4020
4045
  execute(store) {
4021
- store.update(this.id, { ...this.current });
4046
+ store.update(this.id, diffPatch(this.previous, this.current));
4022
4047
  }
4023
4048
  undo(store) {
4024
- store.update(this.id, { ...this.previous });
4049
+ store.update(this.id, diffPatch(this.current, this.previous));
4025
4050
  }
4026
4051
  };
4052
+ function diffPatch(from, to) {
4053
+ const patch = { ...to };
4054
+ for (const key of Object.keys(from)) {
4055
+ if (!(key in to)) patch[key] = void 0;
4056
+ }
4057
+ return patch;
4058
+ }
4027
4059
  var BatchCommand = class {
4028
4060
  commands;
4029
4061
  constructor(commands) {
@@ -5576,7 +5608,9 @@ var Viewport = class {
5576
5608
  gridSize: this._gridSize,
5577
5609
  activeLayerId: this.layerManager.activeLayerId,
5578
5610
  isLayerVisible: (id) => this.layerManager.isLayerVisible(id),
5579
- isLayerLocked: (id) => this.layerManager.isLayerLocked(id)
5611
+ isLayerLocked: (id) => this.layerManager.isLayerLocked(id),
5612
+ smartGuides: false,
5613
+ getVisibleRect: () => this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight)
5580
5614
  };
5581
5615
  this.inputHandler = new InputHandler(this.wrapper, this.camera, {
5582
5616
  toolManager: this.toolManager,
@@ -5584,6 +5618,8 @@ var Viewport = class {
5584
5618
  historyRecorder: this.historyRecorder,
5585
5619
  historyStack: this.history,
5586
5620
  fitToContent: () => this.fitToContent(),
5621
+ group: () => this.groupSelection(),
5622
+ ungroup: () => this.ungroupSelection(),
5587
5623
  shortcuts: options.shortcuts
5588
5624
  });
5589
5625
  this.domNodeManager = new DomNodeManager({
@@ -5680,6 +5716,7 @@ var Viewport = class {
5680
5716
  marginViewport;
5681
5717
  resizeObserver = null;
5682
5718
  _snapToGrid = false;
5719
+ _smartGuides = false;
5683
5720
  _gridSize;
5684
5721
  renderLoop;
5685
5722
  domNodeManager;
@@ -5700,6 +5737,13 @@ var Viewport = class {
5700
5737
  this._snapToGrid = enabled;
5701
5738
  this.toolContext.snapToGrid = enabled;
5702
5739
  }
5740
+ get smartGuides() {
5741
+ return this._smartGuides;
5742
+ }
5743
+ setSmartGuides(enabled) {
5744
+ this._smartGuides = enabled;
5745
+ this.toolContext.smartGuides = enabled;
5746
+ }
5703
5747
  fitToContent(padding = 40) {
5704
5748
  if (this.wrapper.clientWidth === 0 || this.wrapper.clientHeight === 0) return;
5705
5749
  const visibleElements = this.store.getAll().filter((el) => this.layerManager.isLayerVisible(el.layerId));
@@ -5913,6 +5957,26 @@ var Viewport = class {
5913
5957
  }
5914
5958
  this.historyRecorder.commit();
5915
5959
  }
5960
+ groupSelection() {
5961
+ const ids = this.getSelectedIds();
5962
+ if (ids.length < 2) return;
5963
+ const groupId = createId("group");
5964
+ this.historyRecorder.begin();
5965
+ for (const id of ids) {
5966
+ if (this.store.getById(id)) this.store.update(id, { groupId });
5967
+ }
5968
+ this.historyRecorder.commit();
5969
+ }
5970
+ ungroupSelection() {
5971
+ const ids = this.getSelectedIds();
5972
+ if (ids.length === 0) return;
5973
+ this.historyRecorder.begin();
5974
+ for (const id of ids) {
5975
+ const el = this.store.getById(id);
5976
+ if (el && el.groupId !== void 0) this.store.update(id, { groupId: void 0 });
5977
+ }
5978
+ this.historyRecorder.commit();
5979
+ }
5916
5980
  alignSelection(edge) {
5917
5981
  const bounded = this.boundedSelection();
5918
5982
  if (bounded.length < 2) return;
@@ -6622,6 +6686,26 @@ var EraserTool = class {
6622
6686
  }
6623
6687
  };
6624
6688
 
6689
+ // src/elements/group.ts
6690
+ function expandToGroups(ids, elements) {
6691
+ const byId = new Map(elements.map((e) => [e.id, e]));
6692
+ const groupIds = /* @__PURE__ */ new Set();
6693
+ for (const id of ids) {
6694
+ const g = byId.get(id)?.groupId;
6695
+ if (g) groupIds.add(g);
6696
+ }
6697
+ if (groupIds.size === 0) return ids;
6698
+ const idSet = new Set(ids);
6699
+ const result = [...ids];
6700
+ for (const el of elements) {
6701
+ if (el.groupId && groupIds.has(el.groupId) && !idSet.has(el.id)) {
6702
+ result.push(el.id);
6703
+ idSet.add(el.id);
6704
+ }
6705
+ }
6706
+ return result;
6707
+ }
6708
+
6625
6709
  // src/tools/arrow-handles.ts
6626
6710
  var BIND_THRESHOLD = 20;
6627
6711
  var HANDLE_RADIUS = 5;
@@ -6737,8 +6821,47 @@ function renderArrowHandles(canvasCtx, arrow, zoom) {
6737
6821
  }
6738
6822
  }
6739
6823
 
6824
+ // src/elements/snap-guides.ts
6825
+ function xAnchors(b) {
6826
+ return { lo: b.x, mid: b.x + b.w / 2, hi: b.x + b.w };
6827
+ }
6828
+ function yAnchors(b) {
6829
+ return { lo: b.y, mid: b.y + b.h / 2, hi: b.y + b.h };
6830
+ }
6831
+ function bestAxisSnap(moving, targets, anchorsFn, threshold) {
6832
+ let best = null;
6833
+ for (const t of targets) {
6834
+ const ta = anchorsFn(t);
6835
+ const pairs = [
6836
+ // colinear alignment: same-type edges/centers line up
6837
+ [ta.lo - moving.lo, ta.lo],
6838
+ [ta.mid - moving.mid, ta.mid],
6839
+ [ta.hi - moving.hi, ta.hi],
6840
+ // abutment: the moving box sits flush against the target's opposite edge
6841
+ [ta.lo - moving.hi, ta.lo],
6842
+ [ta.hi - moving.lo, ta.hi]
6843
+ ];
6844
+ for (const [delta, position] of pairs) {
6845
+ const abs = Math.abs(delta);
6846
+ if (abs <= threshold && (best === null || abs < Math.abs(best.delta))) {
6847
+ best = { delta, position };
6848
+ }
6849
+ }
6850
+ }
6851
+ return best;
6852
+ }
6853
+ function computeSnapGuides(moving, targets, threshold) {
6854
+ const xSnap = bestAxisSnap(xAnchors(moving), targets, xAnchors, threshold);
6855
+ const ySnap = bestAxisSnap(yAnchors(moving), targets, yAnchors, threshold);
6856
+ const guides = [];
6857
+ if (xSnap) guides.push({ axis: "x", position: xSnap.position });
6858
+ if (ySnap) guides.push({ axis: "y", position: ySnap.position });
6859
+ return { dx: xSnap?.delta ?? 0, dy: ySnap?.delta ?? 0, guides };
6860
+ }
6861
+
6740
6862
  // src/tools/select-tool.ts
6741
6863
  var HANDLE_SIZE = 8;
6864
+ var SNAP_PX = 6;
6742
6865
  var HANDLE_HIT_PADDING2 = 4;
6743
6866
  var SELECTION_PAD = 4;
6744
6867
  var MIN_ELEMENT_SIZE = 20;
@@ -6758,6 +6881,9 @@ var SelectTool = class {
6758
6881
  ctx = null;
6759
6882
  pendingSingleSelectId = null;
6760
6883
  hasDragged = false;
6884
+ activeGuides = [];
6885
+ dragSnapTargets = null;
6886
+ dragVisibleRect = null;
6761
6887
  resizeAspectRatio = 0;
6762
6888
  hoveredId = null;
6763
6889
  get selectedIds() {
@@ -6789,6 +6915,9 @@ var SelectTool = class {
6789
6915
  this.setSelectedIds([]);
6790
6916
  this.mode = { type: "idle" };
6791
6917
  this.hoveredId = null;
6918
+ this.activeGuides = [];
6919
+ this.dragSnapTargets = null;
6920
+ this.dragVisibleRect = null;
6792
6921
  ctx.setCursor?.("default");
6793
6922
  }
6794
6923
  snap(point, ctx) {
@@ -6797,6 +6926,8 @@ var SelectTool = class {
6797
6926
  onPointerDown(state, ctx) {
6798
6927
  this.ctx = ctx;
6799
6928
  this.setHovered(null, ctx);
6929
+ this.dragSnapTargets = null;
6930
+ this.dragVisibleRect = null;
6800
6931
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
6801
6932
  this.lastWorld = this.snap(world, ctx);
6802
6933
  this.currentWorld = world;
@@ -6840,18 +6971,20 @@ var SelectTool = class {
6840
6971
  this.hasDragged = false;
6841
6972
  const hit = this.hitTest(world, ctx);
6842
6973
  if (hit) {
6974
+ const all = ctx.store.getAll();
6843
6975
  const alreadySelected = this._selectedIds.includes(hit.id);
6844
6976
  if (state.shiftKey) {
6845
6977
  if (alreadySelected) {
6846
- this.setSelectedIds(this._selectedIds.filter((id) => id !== hit.id));
6978
+ const grp = new Set(expandToGroups([hit.id], all));
6979
+ this.setSelectedIds(this._selectedIds.filter((id) => !grp.has(id)));
6847
6980
  this.mode = { type: "idle" };
6848
6981
  } else {
6849
- this.setSelectedIds([...this._selectedIds, hit.id]);
6982
+ this.setSelectedIds(expandToGroups([...this._selectedIds, hit.id], all));
6850
6983
  this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
6851
6984
  }
6852
6985
  } else {
6853
6986
  if (!alreadySelected) {
6854
- this.setSelectedIds([hit.id]);
6987
+ this.setSelectedIds(expandToGroups([hit.id], all));
6855
6988
  } else if (this._selectedIds.length > 1) {
6856
6989
  this.pendingSingleSelectId = hit.id;
6857
6990
  }
@@ -6897,6 +7030,31 @@ var SelectTool = class {
6897
7030
  const dx = snapped.x - this.lastWorld.x;
6898
7031
  const dy = snapped.y - this.lastWorld.y;
6899
7032
  this.lastWorld = snapped;
7033
+ let adjDx = dx;
7034
+ let adjDy = dy;
7035
+ this.activeGuides = [];
7036
+ if (ctx.smartGuides) {
7037
+ if (this.dragSnapTargets === null) {
7038
+ const selSet = new Set(this._selectedIds);
7039
+ this.dragVisibleRect = ctx.getVisibleRect?.() ?? null;
7040
+ const candidates = (this.dragVisibleRect ? ctx.store.queryRect(this.dragVisibleRect) : ctx.store.getAll()).filter((el) => !selSet.has(el.id) && el.type !== "grid");
7041
+ const targets = [];
7042
+ for (const el of candidates) {
7043
+ const b = getElementBounds(el);
7044
+ if (b) targets.push(b);
7045
+ }
7046
+ this.dragSnapTargets = targets;
7047
+ }
7048
+ const selectedEls = this._selectedIds.map((id) => ctx.store.getById(id)).filter((el) => !!el && !el.locked);
7049
+ const base = getElementsBoundingBox(selectedEls);
7050
+ if (base) {
7051
+ const moving = { x: base.x + dx, y: base.y + dy, w: base.w, h: base.h };
7052
+ const res = computeSnapGuides(moving, this.dragSnapTargets, SNAP_PX / ctx.camera.zoom);
7053
+ adjDx = dx + res.dx;
7054
+ adjDy = dy + res.dy;
7055
+ this.activeGuides = res.guides;
7056
+ }
7057
+ }
6900
7058
  for (const id of this._selectedIds) {
6901
7059
  const el = ctx.store.getById(id);
6902
7060
  if (!el || el.locked) continue;
@@ -6905,13 +7063,13 @@ var SelectTool = class {
6905
7063
  continue;
6906
7064
  }
6907
7065
  ctx.store.update(id, {
6908
- position: { x: el.position.x + dx, y: el.position.y + dy },
6909
- from: { x: el.from.x + dx, y: el.from.y + dy },
6910
- to: { x: el.to.x + dx, y: el.to.y + dy }
7066
+ position: { x: el.position.x + adjDx, y: el.position.y + adjDy },
7067
+ from: { x: el.from.x + adjDx, y: el.from.y + adjDy },
7068
+ to: { x: el.to.x + adjDx, y: el.to.y + adjDy }
6911
7069
  });
6912
- } else if (ctx.gridType && "size" in el) {
6913
- const centerX = el.position.x + el.size.w / 2 + dx;
6914
- const centerY = el.position.y + el.size.h / 2 + dy;
7070
+ } else if (!ctx.smartGuides && ctx.gridType && "size" in el) {
7071
+ const centerX = el.position.x + el.size.w / 2 + adjDx;
7072
+ const centerY = el.position.y + el.size.h / 2 + adjDy;
6915
7073
  const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
6916
7074
  ctx.store.update(id, {
6917
7075
  position: {
@@ -6921,7 +7079,7 @@ var SelectTool = class {
6921
7079
  });
6922
7080
  } else {
6923
7081
  ctx.store.update(id, {
6924
- position: { x: el.position.x + dx, y: el.position.y + dy }
7082
+ position: { x: el.position.x + adjDx, y: el.position.y + adjDy }
6925
7083
  });
6926
7084
  }
6927
7085
  }
@@ -6940,17 +7098,21 @@ var SelectTool = class {
6940
7098
  if (this.mode.type === "marquee") {
6941
7099
  const rect = this.getMarqueeRect();
6942
7100
  if (rect) {
6943
- this.setSelectedIds(this.findElementsInRect(rect, ctx));
7101
+ this.setSelectedIds(expandToGroups(this.findElementsInRect(rect, ctx), ctx.store.getAll()));
6944
7102
  }
6945
7103
  ctx.requestRender();
6946
7104
  }
6947
7105
  if (!this.hasDragged && this.pendingSingleSelectId !== null) {
6948
- this.setSelectedIds([this.pendingSingleSelectId]);
7106
+ this.setSelectedIds(expandToGroups([this.pendingSingleSelectId], ctx.store.getAll()));
6949
7107
  }
6950
7108
  this.pendingSingleSelectId = null;
6951
7109
  this.hasDragged = false;
6952
7110
  const resizedNoteId = this.mode.type === "resizing" ? this.mode.elementId : null;
6953
7111
  this.mode = { type: "idle" };
7112
+ this.activeGuides = [];
7113
+ this.dragSnapTargets = null;
7114
+ this.dragVisibleRect = null;
7115
+ ctx.requestRender();
6954
7116
  ctx.setCursor?.("default");
6955
7117
  if (resizedNoteId !== null) {
6956
7118
  const el = ctx.store.getById(resizedNoteId);
@@ -6998,6 +7160,32 @@ var SelectTool = class {
6998
7160
  }
6999
7161
  }
7000
7162
  }
7163
+ this.renderGuideLines(canvasCtx);
7164
+ }
7165
+ renderGuideLines(canvasCtx) {
7166
+ if (this.mode.type !== "dragging" || !this.ctx || this.activeGuides.length === 0) return;
7167
+ const zoom = this.ctx.camera.zoom;
7168
+ const rect = this.dragVisibleRect;
7169
+ canvasCtx.save();
7170
+ canvasCtx.strokeStyle = "#FF4081";
7171
+ canvasCtx.lineWidth = 1 / zoom;
7172
+ canvasCtx.setLineDash([]);
7173
+ for (const g of this.activeGuides) {
7174
+ canvasCtx.beginPath();
7175
+ if (g.axis === "x") {
7176
+ const y0 = rect ? rect.y : this.currentWorld.y - 1e5;
7177
+ const y1 = rect ? rect.y + rect.h : this.currentWorld.y + 1e5;
7178
+ canvasCtx.moveTo(g.position, y0);
7179
+ canvasCtx.lineTo(g.position, y1);
7180
+ } else {
7181
+ const x0 = rect ? rect.x : this.currentWorld.x - 1e5;
7182
+ const x1 = rect ? rect.x + rect.w : this.currentWorld.x + 1e5;
7183
+ canvasCtx.moveTo(x0, g.position);
7184
+ canvasCtx.lineTo(x1, g.position);
7185
+ }
7186
+ canvasCtx.stroke();
7187
+ }
7188
+ canvasCtx.restore();
7001
7189
  }
7002
7190
  updateArrowsBoundTo(ids, ctx) {
7003
7191
  updateArrowsBoundToElements(ids, ctx.store);
@@ -8200,7 +8388,7 @@ var TemplateTool = class {
8200
8388
  };
8201
8389
 
8202
8390
  // src/index.ts
8203
- var VERSION = "0.32.0";
8391
+ var VERSION = "0.34.0";
8204
8392
  // Annotate the CommonJS export names for ESM import in node:
8205
8393
  0 && (module.exports = {
8206
8394
  ArrowTool,