@fieldnotes/core 0.31.1 → 0.33.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
@@ -563,6 +563,31 @@ const unsub = viewport.onSelectionChange((ids) => {
563
563
  // call unsub() to unsubscribe
564
564
  ```
565
565
 
566
+ ## Aligning the Selection
567
+
568
+ Two methods on `Viewport` let you snap or space selected elements in one undo step.
569
+
570
+ - **`viewport.alignSelection(edge)`** — `edge`: `AlignEdge` = `'left' | 'center-x' | 'right' | 'top' | 'middle' | 'bottom'`; aligns every selected element to the corresponding edge or center of the selection's bounding box. Needs 2+ selected elements. Locked elements anchor the bounding box without moving.
571
+ - **`viewport.distributeSelection(axis)`** — `axis`: `DistributeAxis` = `'horizontal' | 'vertical'`; evenly spaces selected elements' centers along the axis. Needs 3+ selected elements. Locked elements anchor the span without moving.
572
+
573
+ ```typescript
574
+ viewport.alignSelection('left'); // flush left edges
575
+ viewport.alignSelection('center-x'); // center on vertical axis
576
+ viewport.alignSelection('middle'); // center on horizontal axis
577
+ viewport.distributeSelection('horizontal'); // equal horizontal spacing
578
+ ```
579
+
580
+ Grids are ignored by both operations.
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
+
566
591
  ## Built-in Interactions
567
592
 
568
593
  | Input | Action |
package/dist/index.cjs CHANGED
@@ -2351,6 +2351,23 @@ function distToBounds(point, bounds) {
2351
2351
  function findBoundArrows(elementId, store) {
2352
2352
  return store.getElementsByType("arrow").filter((a) => a.fromBinding?.elementId === elementId || a.toBinding?.elementId === elementId);
2353
2353
  }
2354
+ function updateArrowsBoundToElements(movedIds, store) {
2355
+ const movedNonArrowIds = /* @__PURE__ */ new Set();
2356
+ for (const id of movedIds) {
2357
+ const el = store.getById(id);
2358
+ if (el && el.type !== "arrow") movedNonArrowIds.add(id);
2359
+ }
2360
+ if (movedNonArrowIds.size === 0) return;
2361
+ const updated = /* @__PURE__ */ new Set();
2362
+ for (const id of movedNonArrowIds) {
2363
+ for (const ba of findBoundArrows(id, store)) {
2364
+ if (updated.has(ba.id)) continue;
2365
+ updated.add(ba.id);
2366
+ const updates = updateBoundArrow(ba, store);
2367
+ if (updates) store.update(ba.id, updates);
2368
+ }
2369
+ }
2370
+ }
2354
2371
  function updateBoundArrow(arrow, store) {
2355
2372
  if (!arrow.fromBinding && !arrow.toBinding) return null;
2356
2373
  const updates = {};
@@ -3778,6 +3795,19 @@ var NoteEditor = class {
3778
3795
  }
3779
3796
  };
3780
3797
 
3798
+ // src/elements/translate.ts
3799
+ function translateElementPatch(el, dx, dy) {
3800
+ const position = { x: el.position.x + dx, y: el.position.y + dy };
3801
+ if (el.type === "arrow") {
3802
+ return {
3803
+ position,
3804
+ from: { x: el.from.x + dx, y: el.from.y + dy },
3805
+ to: { x: el.to.x + dx, y: el.to.y + dy }
3806
+ };
3807
+ }
3808
+ return { position };
3809
+ }
3810
+
3781
3811
  // src/elements/arrow-label-editor.ts
3782
3812
  var ArrowLabelEditor = class {
3783
3813
  input = null;
@@ -5464,6 +5494,16 @@ function getElementStyle(element) {
5464
5494
  }
5465
5495
 
5466
5496
  // src/canvas/viewport.ts
5497
+ function unionBounds(list) {
5498
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
5499
+ for (const b of list) {
5500
+ minX = Math.min(minX, b.x);
5501
+ minY = Math.min(minY, b.y);
5502
+ maxX = Math.max(maxX, b.x + b.w);
5503
+ maxY = Math.max(maxY, b.y + b.h);
5504
+ }
5505
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
5506
+ }
5467
5507
  var EMPTY_IDS = [];
5468
5508
  var ARROW_HIT_THRESHOLD = 10;
5469
5509
  function noop() {
@@ -5536,7 +5576,9 @@ var Viewport = class {
5536
5576
  gridSize: this._gridSize,
5537
5577
  activeLayerId: this.layerManager.activeLayerId,
5538
5578
  isLayerVisible: (id) => this.layerManager.isLayerVisible(id),
5539
- isLayerLocked: (id) => this.layerManager.isLayerLocked(id)
5579
+ isLayerLocked: (id) => this.layerManager.isLayerLocked(id),
5580
+ smartGuides: false,
5581
+ getVisibleRect: () => this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight)
5540
5582
  };
5541
5583
  this.inputHandler = new InputHandler(this.wrapper, this.camera, {
5542
5584
  toolManager: this.toolManager,
@@ -5640,6 +5682,7 @@ var Viewport = class {
5640
5682
  marginViewport;
5641
5683
  resizeObserver = null;
5642
5684
  _snapToGrid = false;
5685
+ _smartGuides = false;
5643
5686
  _gridSize;
5644
5687
  renderLoop;
5645
5688
  domNodeManager;
@@ -5660,6 +5703,13 @@ var Viewport = class {
5660
5703
  this._snapToGrid = enabled;
5661
5704
  this.toolContext.snapToGrid = enabled;
5662
5705
  }
5706
+ get smartGuides() {
5707
+ return this._smartGuides;
5708
+ }
5709
+ setSmartGuides(enabled) {
5710
+ this._smartGuides = enabled;
5711
+ this.toolContext.smartGuides = enabled;
5712
+ }
5663
5713
  fitToContent(padding = 40) {
5664
5714
  if (this.wrapper.clientWidth === 0 || this.wrapper.clientHeight === 0) return;
5665
5715
  const visibleElements = this.store.getAll().filter((el) => this.layerManager.isLayerVisible(el.layerId));
@@ -5873,6 +5923,86 @@ var Viewport = class {
5873
5923
  }
5874
5924
  this.historyRecorder.commit();
5875
5925
  }
5926
+ alignSelection(edge) {
5927
+ const bounded = this.boundedSelection();
5928
+ if (bounded.length < 2) return;
5929
+ const B = unionBounds(bounded.map((e) => e.bounds));
5930
+ this.historyRecorder.begin();
5931
+ const moved = [];
5932
+ for (const { id, el, bounds: b } of bounded) {
5933
+ if (!this.isMovable(el)) continue;
5934
+ let dx = 0;
5935
+ let dy = 0;
5936
+ switch (edge) {
5937
+ case "left":
5938
+ dx = B.x - b.x;
5939
+ break;
5940
+ case "right":
5941
+ dx = B.x + B.w - (b.x + b.w);
5942
+ break;
5943
+ case "center-x":
5944
+ dx = B.x + B.w / 2 - (b.x + b.w / 2);
5945
+ break;
5946
+ case "top":
5947
+ dy = B.y - b.y;
5948
+ break;
5949
+ case "bottom":
5950
+ dy = B.y + B.h - (b.y + b.h);
5951
+ break;
5952
+ case "middle":
5953
+ dy = B.y + B.h / 2 - (b.y + b.h / 2);
5954
+ break;
5955
+ }
5956
+ if (dx === 0 && dy === 0) continue;
5957
+ this.store.update(id, translateElementPatch(el, dx, dy));
5958
+ moved.push(id);
5959
+ }
5960
+ updateArrowsBoundToElements(moved, this.store);
5961
+ this.historyRecorder.commit();
5962
+ this.requestRender();
5963
+ }
5964
+ distributeSelection(axis) {
5965
+ const bounded = this.boundedSelection();
5966
+ 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));
5969
+ const first = sorted[0];
5970
+ const last = sorted[sorted.length - 1];
5971
+ if (!first || !last) return;
5972
+ const c0 = center(first.bounds);
5973
+ const cN = center(last.bounds);
5974
+ const n = sorted.length;
5975
+ this.historyRecorder.begin();
5976
+ const moved = [];
5977
+ for (let i = 1; i < n - 1; i++) {
5978
+ const item = sorted[i];
5979
+ if (!item || !this.isMovable(item.el)) continue;
5980
+ const target = c0 + i * (cN - c0) / (n - 1);
5981
+ const delta = target - center(item.bounds);
5982
+ if (delta === 0) continue;
5983
+ const [dx, dy] = axis === "horizontal" ? [delta, 0] : [0, delta];
5984
+ this.store.update(item.id, translateElementPatch(item.el, dx, dy));
5985
+ moved.push(item.id);
5986
+ }
5987
+ updateArrowsBoundToElements(moved, this.store);
5988
+ this.historyRecorder.commit();
5989
+ this.requestRender();
5990
+ }
5991
+ boundedSelection() {
5992
+ const out = [];
5993
+ for (const id of this.getSelectedIds()) {
5994
+ const el = this.store.getById(id);
5995
+ if (!el) continue;
5996
+ const bounds = getElementBounds(el);
5997
+ if (bounds) out.push({ id, el, bounds });
5998
+ }
5999
+ return out;
6000
+ }
6001
+ isMovable(el) {
6002
+ if (el.locked) return false;
6003
+ if (el.type === "arrow" && (el.fromBinding ?? el.toBinding)) return false;
6004
+ return true;
6005
+ }
5876
6006
  getRenderStats() {
5877
6007
  return this.renderLoop.getStats();
5878
6008
  }
@@ -6617,8 +6747,47 @@ function renderArrowHandles(canvasCtx, arrow, zoom) {
6617
6747
  }
6618
6748
  }
6619
6749
 
6750
+ // src/elements/snap-guides.ts
6751
+ function xAnchors(b) {
6752
+ return { lo: b.x, mid: b.x + b.w / 2, hi: b.x + b.w };
6753
+ }
6754
+ function yAnchors(b) {
6755
+ return { lo: b.y, mid: b.y + b.h / 2, hi: b.y + b.h };
6756
+ }
6757
+ function bestAxisSnap(moving, targets, anchorsFn, threshold) {
6758
+ let best = null;
6759
+ for (const t of targets) {
6760
+ const ta = anchorsFn(t);
6761
+ const pairs = [
6762
+ // colinear alignment: same-type edges/centers line up
6763
+ [ta.lo - moving.lo, ta.lo],
6764
+ [ta.mid - moving.mid, ta.mid],
6765
+ [ta.hi - moving.hi, ta.hi],
6766
+ // abutment: the moving box sits flush against the target's opposite edge
6767
+ [ta.lo - moving.hi, ta.lo],
6768
+ [ta.hi - moving.lo, ta.hi]
6769
+ ];
6770
+ for (const [delta, position] of pairs) {
6771
+ const abs = Math.abs(delta);
6772
+ if (abs <= threshold && (best === null || abs < Math.abs(best.delta))) {
6773
+ best = { delta, position };
6774
+ }
6775
+ }
6776
+ }
6777
+ return best;
6778
+ }
6779
+ function computeSnapGuides(moving, targets, threshold) {
6780
+ const xSnap = bestAxisSnap(xAnchors(moving), targets, xAnchors, threshold);
6781
+ const ySnap = bestAxisSnap(yAnchors(moving), targets, yAnchors, threshold);
6782
+ const guides = [];
6783
+ if (xSnap) guides.push({ axis: "x", position: xSnap.position });
6784
+ if (ySnap) guides.push({ axis: "y", position: ySnap.position });
6785
+ return { dx: xSnap?.delta ?? 0, dy: ySnap?.delta ?? 0, guides };
6786
+ }
6787
+
6620
6788
  // src/tools/select-tool.ts
6621
6789
  var HANDLE_SIZE = 8;
6790
+ var SNAP_PX = 6;
6622
6791
  var HANDLE_HIT_PADDING2 = 4;
6623
6792
  var SELECTION_PAD = 4;
6624
6793
  var MIN_ELEMENT_SIZE = 20;
@@ -6638,6 +6807,9 @@ var SelectTool = class {
6638
6807
  ctx = null;
6639
6808
  pendingSingleSelectId = null;
6640
6809
  hasDragged = false;
6810
+ activeGuides = [];
6811
+ dragSnapTargets = null;
6812
+ dragVisibleRect = null;
6641
6813
  resizeAspectRatio = 0;
6642
6814
  hoveredId = null;
6643
6815
  get selectedIds() {
@@ -6669,6 +6841,9 @@ var SelectTool = class {
6669
6841
  this.setSelectedIds([]);
6670
6842
  this.mode = { type: "idle" };
6671
6843
  this.hoveredId = null;
6844
+ this.activeGuides = [];
6845
+ this.dragSnapTargets = null;
6846
+ this.dragVisibleRect = null;
6672
6847
  ctx.setCursor?.("default");
6673
6848
  }
6674
6849
  snap(point, ctx) {
@@ -6677,6 +6852,8 @@ var SelectTool = class {
6677
6852
  onPointerDown(state, ctx) {
6678
6853
  this.ctx = ctx;
6679
6854
  this.setHovered(null, ctx);
6855
+ this.dragSnapTargets = null;
6856
+ this.dragVisibleRect = null;
6680
6857
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
6681
6858
  this.lastWorld = this.snap(world, ctx);
6682
6859
  this.currentWorld = world;
@@ -6777,6 +6954,31 @@ var SelectTool = class {
6777
6954
  const dx = snapped.x - this.lastWorld.x;
6778
6955
  const dy = snapped.y - this.lastWorld.y;
6779
6956
  this.lastWorld = snapped;
6957
+ let adjDx = dx;
6958
+ let adjDy = dy;
6959
+ this.activeGuides = [];
6960
+ if (ctx.smartGuides) {
6961
+ if (this.dragSnapTargets === null) {
6962
+ const selSet = new Set(this._selectedIds);
6963
+ this.dragVisibleRect = ctx.getVisibleRect?.() ?? null;
6964
+ const candidates = (this.dragVisibleRect ? ctx.store.queryRect(this.dragVisibleRect) : ctx.store.getAll()).filter((el) => !selSet.has(el.id) && el.type !== "grid");
6965
+ const targets = [];
6966
+ for (const el of candidates) {
6967
+ const b = getElementBounds(el);
6968
+ if (b) targets.push(b);
6969
+ }
6970
+ this.dragSnapTargets = targets;
6971
+ }
6972
+ const selectedEls = this._selectedIds.map((id) => ctx.store.getById(id)).filter((el) => !!el && !el.locked);
6973
+ const base = getElementsBoundingBox(selectedEls);
6974
+ if (base) {
6975
+ const moving = { x: base.x + dx, y: base.y + dy, w: base.w, h: base.h };
6976
+ const res = computeSnapGuides(moving, this.dragSnapTargets, SNAP_PX / ctx.camera.zoom);
6977
+ adjDx = dx + res.dx;
6978
+ adjDy = dy + res.dy;
6979
+ this.activeGuides = res.guides;
6980
+ }
6981
+ }
6780
6982
  for (const id of this._selectedIds) {
6781
6983
  const el = ctx.store.getById(id);
6782
6984
  if (!el || el.locked) continue;
@@ -6785,13 +6987,13 @@ var SelectTool = class {
6785
6987
  continue;
6786
6988
  }
6787
6989
  ctx.store.update(id, {
6788
- position: { x: el.position.x + dx, y: el.position.y + dy },
6789
- from: { x: el.from.x + dx, y: el.from.y + dy },
6790
- to: { x: el.to.x + dx, y: el.to.y + dy }
6990
+ position: { x: el.position.x + adjDx, y: el.position.y + adjDy },
6991
+ from: { x: el.from.x + adjDx, y: el.from.y + adjDy },
6992
+ to: { x: el.to.x + adjDx, y: el.to.y + adjDy }
6791
6993
  });
6792
- } else if (ctx.gridType && "size" in el) {
6793
- const centerX = el.position.x + el.size.w / 2 + dx;
6794
- const centerY = el.position.y + el.size.h / 2 + dy;
6994
+ } else if (!ctx.smartGuides && ctx.gridType && "size" in el) {
6995
+ const centerX = el.position.x + el.size.w / 2 + adjDx;
6996
+ const centerY = el.position.y + el.size.h / 2 + adjDy;
6795
6997
  const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
6796
6998
  ctx.store.update(id, {
6797
6999
  position: {
@@ -6801,7 +7003,7 @@ var SelectTool = class {
6801
7003
  });
6802
7004
  } else {
6803
7005
  ctx.store.update(id, {
6804
- position: { x: el.position.x + dx, y: el.position.y + dy }
7006
+ position: { x: el.position.x + adjDx, y: el.position.y + adjDy }
6805
7007
  });
6806
7008
  }
6807
7009
  }
@@ -6831,6 +7033,10 @@ var SelectTool = class {
6831
7033
  this.hasDragged = false;
6832
7034
  const resizedNoteId = this.mode.type === "resizing" ? this.mode.elementId : null;
6833
7035
  this.mode = { type: "idle" };
7036
+ this.activeGuides = [];
7037
+ this.dragSnapTargets = null;
7038
+ this.dragVisibleRect = null;
7039
+ ctx.requestRender();
6834
7040
  ctx.setCursor?.("default");
6835
7041
  if (resizedNoteId !== null) {
6836
7042
  const el = ctx.store.getById(resizedNoteId);
@@ -6878,42 +7084,43 @@ var SelectTool = class {
6878
7084
  }
6879
7085
  }
6880
7086
  }
7087
+ this.renderGuideLines(canvasCtx);
6881
7088
  }
6882
- updateArrowsBoundTo(ids, ctx) {
6883
- const movedNonArrowIds = /* @__PURE__ */ new Set();
6884
- for (const id of ids) {
6885
- const el = ctx.store.getById(id);
6886
- if (el && el.type !== "arrow") movedNonArrowIds.add(id);
6887
- }
6888
- if (movedNonArrowIds.size === 0) return;
6889
- const updatedArrows = /* @__PURE__ */ new Set();
6890
- for (const id of movedNonArrowIds) {
6891
- const boundArrows = findBoundArrows(id, ctx.store);
6892
- for (const ba of boundArrows) {
6893
- if (updatedArrows.has(ba.id)) continue;
6894
- updatedArrows.add(ba.id);
6895
- const updates = updateBoundArrow(ba, ctx.store);
6896
- if (updates) ctx.store.update(ba.id, updates);
7089
+ renderGuideLines(canvasCtx) {
7090
+ if (this.mode.type !== "dragging" || !this.ctx || this.activeGuides.length === 0) return;
7091
+ const zoom = this.ctx.camera.zoom;
7092
+ const rect = this.dragVisibleRect;
7093
+ canvasCtx.save();
7094
+ canvasCtx.strokeStyle = "#FF4081";
7095
+ canvasCtx.lineWidth = 1 / zoom;
7096
+ canvasCtx.setLineDash([]);
7097
+ for (const g of this.activeGuides) {
7098
+ canvasCtx.beginPath();
7099
+ if (g.axis === "x") {
7100
+ const y0 = rect ? rect.y : this.currentWorld.y - 1e5;
7101
+ const y1 = rect ? rect.y + rect.h : this.currentWorld.y + 1e5;
7102
+ canvasCtx.moveTo(g.position, y0);
7103
+ canvasCtx.lineTo(g.position, y1);
7104
+ } else {
7105
+ const x0 = rect ? rect.x : this.currentWorld.x - 1e5;
7106
+ const x1 = rect ? rect.x + rect.w : this.currentWorld.x + 1e5;
7107
+ canvasCtx.moveTo(x0, g.position);
7108
+ canvasCtx.lineTo(x1, g.position);
6897
7109
  }
7110
+ canvasCtx.stroke();
6898
7111
  }
7112
+ canvasCtx.restore();
7113
+ }
7114
+ updateArrowsBoundTo(ids, ctx) {
7115
+ updateArrowsBoundToElements(ids, ctx.store);
6899
7116
  }
6900
7117
  nudgeSelection(dx, dy, ctx) {
6901
7118
  let moved = false;
6902
7119
  for (const id of this._selectedIds) {
6903
7120
  const el = ctx.store.getById(id);
6904
7121
  if (!el || el.locked) continue;
6905
- if (el.type === "arrow") {
6906
- if (el.fromBinding || el.toBinding) continue;
6907
- 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 }
6911
- });
6912
- } else {
6913
- ctx.store.update(id, {
6914
- position: { x: el.position.x + dx, y: el.position.y + dy }
6915
- });
6916
- }
7122
+ if (el.type === "arrow" && (el.fromBinding || el.toBinding)) continue;
7123
+ ctx.store.update(id, translateElementPatch(el, dx, dy));
6917
7124
  moved = true;
6918
7125
  }
6919
7126
  if (moved) {
@@ -8105,7 +8312,7 @@ var TemplateTool = class {
8105
8312
  };
8106
8313
 
8107
8314
  // src/index.ts
8108
- var VERSION = "0.31.1";
8315
+ var VERSION = "0.33.0";
8109
8316
  // Annotate the CommonJS export names for ESM import in node:
8110
8317
  0 && (module.exports = {
8111
8318
  ArrowTool,