@fieldnotes/core 0.31.0 → 0.32.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
@@ -439,7 +439,7 @@ new Viewport(container, {
439
439
 
440
440
  ```typescript
441
441
  new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
442
- new EraserTool({ radius: 30 }); // mode: 'partial' (default) splits strokes at the erased span; mode: 'stroke' deletes the whole stroke
442
+ new EraserTool({ radius: 30 }); // radius is screen pixels (converted to world units per zoom); mode: 'partial' (default) splits strokes at the erased span; mode: 'stroke' deletes the whole stroke
443
443
  new ArrowTool({ color: '#333', width: 2 });
444
444
  ```
445
445
 
@@ -563,6 +563,22 @@ 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
+
566
582
  ## Built-in Interactions
567
583
 
568
584
  | 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() {
@@ -5873,6 +5913,86 @@ var Viewport = class {
5873
5913
  }
5874
5914
  this.historyRecorder.commit();
5875
5915
  }
5916
+ alignSelection(edge) {
5917
+ const bounded = this.boundedSelection();
5918
+ if (bounded.length < 2) return;
5919
+ const B = unionBounds(bounded.map((e) => e.bounds));
5920
+ this.historyRecorder.begin();
5921
+ const moved = [];
5922
+ for (const { id, el, bounds: b } of bounded) {
5923
+ if (!this.isMovable(el)) continue;
5924
+ let dx = 0;
5925
+ let dy = 0;
5926
+ switch (edge) {
5927
+ case "left":
5928
+ dx = B.x - b.x;
5929
+ break;
5930
+ case "right":
5931
+ dx = B.x + B.w - (b.x + b.w);
5932
+ break;
5933
+ case "center-x":
5934
+ dx = B.x + B.w / 2 - (b.x + b.w / 2);
5935
+ break;
5936
+ case "top":
5937
+ dy = B.y - b.y;
5938
+ break;
5939
+ case "bottom":
5940
+ dy = B.y + B.h - (b.y + b.h);
5941
+ break;
5942
+ case "middle":
5943
+ dy = B.y + B.h / 2 - (b.y + b.h / 2);
5944
+ break;
5945
+ }
5946
+ if (dx === 0 && dy === 0) continue;
5947
+ this.store.update(id, translateElementPatch(el, dx, dy));
5948
+ moved.push(id);
5949
+ }
5950
+ updateArrowsBoundToElements(moved, this.store);
5951
+ this.historyRecorder.commit();
5952
+ this.requestRender();
5953
+ }
5954
+ distributeSelection(axis) {
5955
+ const bounded = this.boundedSelection();
5956
+ if (bounded.length < 3) return;
5957
+ const center = (b) => axis === "horizontal" ? b.x + b.w / 2 : b.y + b.h / 2;
5958
+ const sorted = [...bounded].sort((p, q) => center(p.bounds) - center(q.bounds));
5959
+ const first = sorted[0];
5960
+ const last = sorted[sorted.length - 1];
5961
+ if (!first || !last) return;
5962
+ const c0 = center(first.bounds);
5963
+ const cN = center(last.bounds);
5964
+ const n = sorted.length;
5965
+ this.historyRecorder.begin();
5966
+ const moved = [];
5967
+ for (let i = 1; i < n - 1; i++) {
5968
+ const item = sorted[i];
5969
+ if (!item || !this.isMovable(item.el)) continue;
5970
+ const target = c0 + i * (cN - c0) / (n - 1);
5971
+ const delta = target - center(item.bounds);
5972
+ if (delta === 0) continue;
5973
+ const [dx, dy] = axis === "horizontal" ? [delta, 0] : [0, delta];
5974
+ this.store.update(item.id, translateElementPatch(item.el, dx, dy));
5975
+ moved.push(item.id);
5976
+ }
5977
+ updateArrowsBoundToElements(moved, this.store);
5978
+ this.historyRecorder.commit();
5979
+ this.requestRender();
5980
+ }
5981
+ boundedSelection() {
5982
+ const out = [];
5983
+ for (const id of this.getSelectedIds()) {
5984
+ const el = this.store.getById(id);
5985
+ if (!el) continue;
5986
+ const bounds = getElementBounds(el);
5987
+ if (bounds) out.push({ id, el, bounds });
5988
+ }
5989
+ return out;
5990
+ }
5991
+ isMovable(el) {
5992
+ if (el.locked) return false;
5993
+ if (el.type === "arrow" && (el.fromBinding ?? el.toBinding)) return false;
5994
+ return true;
5995
+ }
5876
5996
  getRenderStats() {
5877
5997
  return this.renderLoop.getStats();
5878
5998
  }
@@ -6457,11 +6577,12 @@ var EraserTool = class {
6457
6577
  }
6458
6578
  eraseAt(state, ctx) {
6459
6579
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
6580
+ const worldRadius = this.radius / ctx.camera.zoom;
6460
6581
  const queryBounds = {
6461
- x: world.x - this.radius,
6462
- y: world.y - this.radius,
6463
- w: this.radius * 2,
6464
- h: this.radius * 2
6582
+ x: world.x - worldRadius,
6583
+ y: world.y - worldRadius,
6584
+ w: worldRadius * 2,
6585
+ h: worldRadius * 2
6465
6586
  };
6466
6587
  const candidates = ctx.store.queryRect(queryBounds);
6467
6588
  let erased = false;
@@ -6469,14 +6590,14 @@ var EraserTool = class {
6469
6590
  if (el.type !== "stroke") continue;
6470
6591
  if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
6471
6592
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
6472
- if (!this.strokeIntersects(el, world)) continue;
6593
+ if (!this.strokeIntersects(el, world, worldRadius)) continue;
6473
6594
  if (this.mode === "stroke") {
6474
6595
  ctx.store.remove(el.id);
6475
6596
  erased = true;
6476
6597
  continue;
6477
6598
  }
6478
6599
  const localEraser = { x: world.x - el.position.x, y: world.y - el.position.y };
6479
- const runs = erasePoints(el.points, localEraser, this.radius);
6600
+ const runs = erasePoints(el.points, localEraser, worldRadius);
6480
6601
  if (runs === null) continue;
6481
6602
  ctx.store.remove(el.id);
6482
6603
  for (const run of runs) {
@@ -6496,8 +6617,8 @@ var EraserTool = class {
6496
6617
  }
6497
6618
  if (erased) ctx.requestRender();
6498
6619
  }
6499
- strokeIntersects(stroke, point) {
6500
- return hitTestStroke(stroke, point, this.radius);
6620
+ strokeIntersects(stroke, point, worldRadius) {
6621
+ return hitTestStroke(stroke, point, worldRadius);
6501
6622
  }
6502
6623
  };
6503
6624
 
@@ -6879,40 +7000,15 @@ var SelectTool = class {
6879
7000
  }
6880
7001
  }
6881
7002
  updateArrowsBoundTo(ids, ctx) {
6882
- const movedNonArrowIds = /* @__PURE__ */ new Set();
6883
- for (const id of ids) {
6884
- const el = ctx.store.getById(id);
6885
- if (el && el.type !== "arrow") movedNonArrowIds.add(id);
6886
- }
6887
- if (movedNonArrowIds.size === 0) return;
6888
- const updatedArrows = /* @__PURE__ */ new Set();
6889
- for (const id of movedNonArrowIds) {
6890
- const boundArrows = findBoundArrows(id, ctx.store);
6891
- for (const ba of boundArrows) {
6892
- if (updatedArrows.has(ba.id)) continue;
6893
- updatedArrows.add(ba.id);
6894
- const updates = updateBoundArrow(ba, ctx.store);
6895
- if (updates) ctx.store.update(ba.id, updates);
6896
- }
6897
- }
7003
+ updateArrowsBoundToElements(ids, ctx.store);
6898
7004
  }
6899
7005
  nudgeSelection(dx, dy, ctx) {
6900
7006
  let moved = false;
6901
7007
  for (const id of this._selectedIds) {
6902
7008
  const el = ctx.store.getById(id);
6903
7009
  if (!el || el.locked) continue;
6904
- if (el.type === "arrow") {
6905
- if (el.fromBinding || el.toBinding) continue;
6906
- ctx.store.update(id, {
6907
- position: { x: el.position.x + dx, y: el.position.y + dy },
6908
- from: { x: el.from.x + dx, y: el.from.y + dy },
6909
- to: { x: el.to.x + dx, y: el.to.y + dy }
6910
- });
6911
- } else {
6912
- ctx.store.update(id, {
6913
- position: { x: el.position.x + dx, y: el.position.y + dy }
6914
- });
6915
- }
7010
+ if (el.type === "arrow" && (el.fromBinding || el.toBinding)) continue;
7011
+ ctx.store.update(id, translateElementPatch(el, dx, dy));
6916
7012
  moved = true;
6917
7013
  }
6918
7014
  if (moved) {
@@ -8104,7 +8200,7 @@ var TemplateTool = class {
8104
8200
  };
8105
8201
 
8106
8202
  // src/index.ts
8107
- var VERSION = "0.31.0";
8203
+ var VERSION = "0.32.0";
8108
8204
  // Annotate the CommonJS export names for ESM import in node:
8109
8205
  0 && (module.exports = {
8110
8206
  ArrowTool,