@fieldnotes/core 0.3.0 → 0.4.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
@@ -40,26 +40,37 @@ __export(index_exports, {
40
40
  PencilTool: () => PencilTool,
41
41
  RemoveElementCommand: () => RemoveElementCommand,
42
42
  SelectTool: () => SelectTool,
43
+ ShapeTool: () => ShapeTool,
43
44
  TextTool: () => TextTool,
44
45
  ToolManager: () => ToolManager,
45
46
  UpdateElementCommand: () => UpdateElementCommand,
46
47
  VERSION: () => VERSION,
47
48
  Viewport: () => Viewport,
49
+ clearStaleBindings: () => clearStaleBindings,
48
50
  createArrow: () => createArrow,
49
51
  createHtmlElement: () => createHtmlElement,
50
52
  createId: () => createId,
51
53
  createImage: () => createImage,
52
54
  createNote: () => createNote,
55
+ createShape: () => createShape,
53
56
  createStroke: () => createStroke,
54
57
  createText: () => createText,
55
58
  exportState: () => exportState,
59
+ findBindTarget: () => findBindTarget,
60
+ findBoundArrows: () => findBoundArrows,
56
61
  getArrowBounds: () => getArrowBounds,
57
62
  getArrowControlPoint: () => getArrowControlPoint,
58
63
  getArrowMidpoint: () => getArrowMidpoint,
59
64
  getArrowTangentAngle: () => getArrowTangentAngle,
60
65
  getBendFromPoint: () => getBendFromPoint,
66
+ getEdgeIntersection: () => getEdgeIntersection,
67
+ getElementBounds: () => getElementBounds,
68
+ getElementCenter: () => getElementCenter,
69
+ isBindable: () => isBindable,
61
70
  isNearBezier: () => isNearBezier,
62
- parseState: () => parseState
71
+ parseState: () => parseState,
72
+ unbindArrow: () => unbindArrow,
73
+ updateBoundArrow: () => updateBoundArrow
63
74
  });
64
75
  module.exports = __toCommonJS(index_exports);
65
76
 
@@ -133,8 +144,9 @@ function validateState(data) {
133
144
  validateElement(el);
134
145
  migrateElement(el);
135
146
  }
147
+ cleanBindings(obj["elements"]);
136
148
  }
137
- var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text"]);
149
+ var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text", "shape"]);
138
150
  function validateElement(el) {
139
151
  if (!el || typeof el !== "object") {
140
152
  throw new Error("Invalid element: expected an object");
@@ -150,6 +162,20 @@ function validateElement(el) {
150
162
  throw new Error("Invalid element: missing zIndex");
151
163
  }
152
164
  }
165
+ function cleanBindings(elements) {
166
+ const ids = new Set(elements.map((el) => el["id"]));
167
+ for (const el of elements) {
168
+ if (el["type"] !== "arrow") continue;
169
+ const fromBinding = el["fromBinding"];
170
+ if (fromBinding && !ids.has(fromBinding["elementId"])) {
171
+ el["fromBinding"] = void 0;
172
+ }
173
+ const toBinding = el["toBinding"];
174
+ if (toBinding && !ids.has(toBinding["elementId"])) {
175
+ el["toBinding"] = void 0;
176
+ }
177
+ }
178
+ }
153
179
  function migrateElement(obj) {
154
180
  if (obj["type"] === "arrow" && typeof obj["bend"] !== "number") {
155
181
  obj["bend"] = 0;
@@ -161,6 +187,9 @@ function migrateElement(obj) {
161
187
  }
162
188
  }
163
189
  }
190
+ if (obj["type"] === "shape" && typeof obj["shape"] !== "string") {
191
+ obj["shape"] = "rectangle";
192
+ }
164
193
  }
165
194
 
166
195
  // src/core/auto-save.ts
@@ -743,6 +772,134 @@ function isNearLine(point, a, b, threshold) {
743
772
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
744
773
  }
745
774
 
775
+ // src/elements/arrow-binding.ts
776
+ var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape"]);
777
+ function isBindable(element) {
778
+ return BINDABLE_TYPES.has(element.type);
779
+ }
780
+ function getElementCenter(element) {
781
+ if (!("size" in element)) {
782
+ throw new Error(`getElementCenter: element type "${element.type}" has no size`);
783
+ }
784
+ return {
785
+ x: element.position.x + element.size.w / 2,
786
+ y: element.position.y + element.size.h / 2
787
+ };
788
+ }
789
+ function getElementBounds(element) {
790
+ if (!("size" in element)) return null;
791
+ return {
792
+ x: element.position.x,
793
+ y: element.position.y,
794
+ w: element.size.w,
795
+ h: element.size.h
796
+ };
797
+ }
798
+ function getEdgeIntersection(bounds, outsidePoint) {
799
+ const cx = bounds.x + bounds.w / 2;
800
+ const cy = bounds.y + bounds.h / 2;
801
+ const dx = outsidePoint.x - cx;
802
+ const dy = outsidePoint.y - cy;
803
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
804
+ const halfW = bounds.w / 2;
805
+ const halfH = bounds.h / 2;
806
+ const scaleX = dx !== 0 ? halfW / Math.abs(dx) : Infinity;
807
+ const scaleY = dy !== 0 ? halfH / Math.abs(dy) : Infinity;
808
+ const scale = Math.min(scaleX, scaleY);
809
+ return {
810
+ x: cx + dx * scale,
811
+ y: cy + dy * scale
812
+ };
813
+ }
814
+ function findBindTarget(point, store, threshold, excludeId) {
815
+ let closest = null;
816
+ let closestDist = Infinity;
817
+ for (const el of store.getAll()) {
818
+ if (!isBindable(el)) continue;
819
+ if (excludeId && el.id === excludeId) continue;
820
+ const bounds = getElementBounds(el);
821
+ if (!bounds) continue;
822
+ const dist = distToBounds(point, bounds);
823
+ if (dist <= threshold && dist < closestDist) {
824
+ closest = el;
825
+ closestDist = dist;
826
+ }
827
+ }
828
+ return closest;
829
+ }
830
+ function distToBounds(point, bounds) {
831
+ const clampedX = Math.max(bounds.x, Math.min(point.x, bounds.x + bounds.w));
832
+ const clampedY = Math.max(bounds.y, Math.min(point.y, bounds.y + bounds.h));
833
+ return Math.hypot(point.x - clampedX, point.y - clampedY);
834
+ }
835
+ function findBoundArrows(elementId, store) {
836
+ return store.getElementsByType("arrow").filter((a) => a.fromBinding?.elementId === elementId || a.toBinding?.elementId === elementId);
837
+ }
838
+ function updateBoundArrow(arrow, store) {
839
+ if (!arrow.fromBinding && !arrow.toBinding) return null;
840
+ const updates = {};
841
+ if (arrow.fromBinding) {
842
+ const el = store.getById(arrow.fromBinding.elementId);
843
+ if (el) {
844
+ const center = getElementCenter(el);
845
+ updates.from = center;
846
+ updates.position = center;
847
+ }
848
+ }
849
+ if (arrow.toBinding) {
850
+ const el = store.getById(arrow.toBinding.elementId);
851
+ if (el) {
852
+ updates.to = getElementCenter(el);
853
+ }
854
+ }
855
+ return Object.keys(updates).length > 0 ? updates : null;
856
+ }
857
+ function clearStaleBindings(arrow, store) {
858
+ const updates = {};
859
+ let hasUpdates = false;
860
+ if (arrow.fromBinding && !store.getById(arrow.fromBinding.elementId)) {
861
+ updates.fromBinding = void 0;
862
+ hasUpdates = true;
863
+ }
864
+ if (arrow.toBinding && !store.getById(arrow.toBinding.elementId)) {
865
+ updates.toBinding = void 0;
866
+ hasUpdates = true;
867
+ }
868
+ return hasUpdates ? updates : null;
869
+ }
870
+ function unbindArrow(arrow, store) {
871
+ const updates = {};
872
+ if (arrow.fromBinding) {
873
+ const el = store.getById(arrow.fromBinding.elementId);
874
+ const bounds = el ? getElementBounds(el) : null;
875
+ if (bounds) {
876
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
877
+ const rayTarget = {
878
+ x: arrow.from.x + Math.cos(angle) * 1e3,
879
+ y: arrow.from.y + Math.sin(angle) * 1e3
880
+ };
881
+ const edge = getEdgeIntersection(bounds, rayTarget);
882
+ updates.from = edge;
883
+ updates.position = edge;
884
+ }
885
+ updates.fromBinding = void 0;
886
+ }
887
+ if (arrow.toBinding) {
888
+ const el = store.getById(arrow.toBinding.elementId);
889
+ const bounds = el ? getElementBounds(el) : null;
890
+ if (bounds) {
891
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
892
+ const rayTarget = {
893
+ x: arrow.to.x - Math.cos(angle) * 1e3,
894
+ y: arrow.to.y - Math.sin(angle) * 1e3
895
+ };
896
+ updates.to = getEdgeIntersection(bounds, rayTarget);
897
+ }
898
+ updates.toBinding = void 0;
899
+ }
900
+ return updates;
901
+ }
902
+
746
903
  // src/elements/stroke-smoothing.ts
747
904
  var MIN_PRESSURE_SCALE = 0.2;
748
905
  function pressureToWidth(pressure, baseWidth) {
@@ -821,6 +978,10 @@ var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html", "text"
821
978
  var ARROWHEAD_LENGTH = 12;
822
979
  var ARROWHEAD_ANGLE = Math.PI / 6;
823
980
  var ElementRenderer = class {
981
+ store = null;
982
+ setStore(store) {
983
+ this.store = store;
984
+ }
824
985
  isDomElement(element) {
825
986
  return DOM_ELEMENT_TYPES.has(element.type);
826
987
  }
@@ -832,6 +993,9 @@ var ElementRenderer = class {
832
993
  case "arrow":
833
994
  this.renderArrow(ctx, element);
834
995
  break;
996
+ case "shape":
997
+ this.renderShape(ctx, element);
998
+ break;
835
999
  }
836
1000
  }
837
1001
  renderStroke(ctx, stroke) {
@@ -854,38 +1018,119 @@ var ElementRenderer = class {
854
1018
  ctx.restore();
855
1019
  }
856
1020
  renderArrow(ctx, arrow) {
1021
+ const { visualFrom, visualTo } = this.getVisualEndpoints(arrow);
857
1022
  ctx.save();
858
1023
  ctx.strokeStyle = arrow.color;
859
1024
  ctx.lineWidth = arrow.width;
860
1025
  ctx.lineCap = "round";
1026
+ if (arrow.fromBinding || arrow.toBinding) {
1027
+ ctx.setLineDash([8, 4]);
1028
+ }
861
1029
  ctx.beginPath();
862
- ctx.moveTo(arrow.from.x, arrow.from.y);
1030
+ ctx.moveTo(visualFrom.x, visualFrom.y);
863
1031
  if (arrow.bend !== 0) {
864
1032
  const cp = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
865
- ctx.quadraticCurveTo(cp.x, cp.y, arrow.to.x, arrow.to.y);
1033
+ ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
866
1034
  } else {
867
- ctx.lineTo(arrow.to.x, arrow.to.y);
1035
+ ctx.lineTo(visualTo.x, visualTo.y);
868
1036
  }
869
1037
  ctx.stroke();
870
- this.renderArrowhead(ctx, arrow);
1038
+ this.renderArrowhead(ctx, arrow, visualTo);
871
1039
  ctx.restore();
872
1040
  }
873
- renderArrowhead(ctx, arrow) {
1041
+ renderArrowhead(ctx, arrow, tip) {
874
1042
  const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
875
1043
  ctx.beginPath();
876
- ctx.moveTo(arrow.to.x, arrow.to.y);
1044
+ ctx.moveTo(tip.x, tip.y);
877
1045
  ctx.lineTo(
878
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
879
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
1046
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
1047
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
880
1048
  );
881
1049
  ctx.lineTo(
882
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
883
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
1050
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
1051
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
884
1052
  );
885
1053
  ctx.closePath();
886
1054
  ctx.fillStyle = arrow.color;
887
1055
  ctx.fill();
888
1056
  }
1057
+ getVisualEndpoints(arrow) {
1058
+ let visualFrom = arrow.from;
1059
+ let visualTo = arrow.to;
1060
+ if (!this.store) return { visualFrom, visualTo };
1061
+ if (arrow.fromBinding) {
1062
+ const el = this.store.getById(arrow.fromBinding.elementId);
1063
+ if (el) {
1064
+ const bounds = getElementBounds(el);
1065
+ if (bounds) {
1066
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
1067
+ const rayTarget = {
1068
+ x: arrow.from.x + Math.cos(tangentAngle) * 1e3,
1069
+ y: arrow.from.y + Math.sin(tangentAngle) * 1e3
1070
+ };
1071
+ visualFrom = getEdgeIntersection(bounds, rayTarget);
1072
+ }
1073
+ }
1074
+ }
1075
+ if (arrow.toBinding) {
1076
+ const el = this.store.getById(arrow.toBinding.elementId);
1077
+ if (el) {
1078
+ const bounds = getElementBounds(el);
1079
+ if (bounds) {
1080
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
1081
+ const rayTarget = {
1082
+ x: arrow.to.x - Math.cos(tangentAngle) * 1e3,
1083
+ y: arrow.to.y - Math.sin(tangentAngle) * 1e3
1084
+ };
1085
+ visualTo = getEdgeIntersection(bounds, rayTarget);
1086
+ }
1087
+ }
1088
+ }
1089
+ return { visualFrom, visualTo };
1090
+ }
1091
+ renderShape(ctx, shape) {
1092
+ ctx.save();
1093
+ if (shape.fillColor !== "none") {
1094
+ ctx.fillStyle = shape.fillColor;
1095
+ this.fillShapePath(ctx, shape);
1096
+ }
1097
+ if (shape.strokeWidth > 0) {
1098
+ ctx.strokeStyle = shape.strokeColor;
1099
+ ctx.lineWidth = shape.strokeWidth;
1100
+ this.strokeShapePath(ctx, shape);
1101
+ }
1102
+ ctx.restore();
1103
+ }
1104
+ fillShapePath(ctx, shape) {
1105
+ switch (shape.shape) {
1106
+ case "rectangle":
1107
+ ctx.fillRect(shape.position.x, shape.position.y, shape.size.w, shape.size.h);
1108
+ break;
1109
+ case "ellipse": {
1110
+ const cx = shape.position.x + shape.size.w / 2;
1111
+ const cy = shape.position.y + shape.size.h / 2;
1112
+ ctx.beginPath();
1113
+ ctx.ellipse(cx, cy, shape.size.w / 2, shape.size.h / 2, 0, 0, Math.PI * 2);
1114
+ ctx.fill();
1115
+ break;
1116
+ }
1117
+ }
1118
+ }
1119
+ strokeShapePath(ctx, shape) {
1120
+ switch (shape.shape) {
1121
+ case "rectangle":
1122
+ ctx.strokeRect(shape.position.x, shape.position.y, shape.size.w, shape.size.h);
1123
+ break;
1124
+ case "ellipse": {
1125
+ const cx = shape.position.x + shape.size.w / 2;
1126
+ const cy = shape.position.y + shape.size.h / 2;
1127
+ ctx.beginPath();
1128
+ ctx.ellipse(cx, cy, shape.size.w / 2, shape.size.h / 2, 0, 0, Math.PI * 2);
1129
+ ctx.stroke();
1130
+ break;
1131
+ }
1132
+ }
1133
+ }
889
1134
  };
890
1135
 
891
1136
  // src/elements/note-editor.ts
@@ -1256,7 +1501,7 @@ function createNote(input) {
1256
1501
  };
1257
1502
  }
1258
1503
  function createArrow(input) {
1259
- return {
1504
+ const result = {
1260
1505
  id: createId("arrow"),
1261
1506
  type: "arrow",
1262
1507
  position: input.position ?? { x: 0, y: 0 },
@@ -1268,6 +1513,9 @@ function createArrow(input) {
1268
1513
  color: input.color ?? "#000000",
1269
1514
  width: input.width ?? 2
1270
1515
  };
1516
+ if (input.fromBinding) result.fromBinding = input.fromBinding;
1517
+ if (input.toBinding) result.toBinding = input.toBinding;
1518
+ return result;
1271
1519
  }
1272
1520
  function createImage(input) {
1273
1521
  return {
@@ -1290,6 +1538,20 @@ function createHtmlElement(input) {
1290
1538
  size: input.size
1291
1539
  };
1292
1540
  }
1541
+ function createShape(input) {
1542
+ return {
1543
+ id: createId("shape"),
1544
+ type: "shape",
1545
+ position: input.position,
1546
+ zIndex: input.zIndex ?? 0,
1547
+ locked: input.locked ?? false,
1548
+ shape: input.shape ?? "rectangle",
1549
+ size: input.size,
1550
+ strokeColor: input.strokeColor ?? "#000000",
1551
+ strokeWidth: input.strokeWidth ?? 2,
1552
+ fillColor: input.fillColor ?? "none"
1553
+ };
1554
+ }
1293
1555
  function createText(input) {
1294
1556
  return {
1295
1557
  id: createId("text"),
@@ -1314,6 +1576,7 @@ var Viewport = class {
1314
1576
  this.store = new ElementStore();
1315
1577
  this.toolManager = new ToolManager();
1316
1578
  this.renderer = new ElementRenderer();
1579
+ this.renderer.setStore(this.store);
1317
1580
  this.noteEditor = new NoteEditor();
1318
1581
  this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
1319
1582
  this.history = new HistoryStack();
@@ -1346,7 +1609,10 @@ var Viewport = class {
1346
1609
  });
1347
1610
  this.unsubStore = [
1348
1611
  this.store.on("add", () => this.requestRender()),
1349
- this.store.on("remove", (el) => this.removeDomNode(el.id)),
1612
+ this.store.on("remove", (el) => {
1613
+ this.unbindArrowsFrom(el);
1614
+ this.removeDomNode(el.id);
1615
+ }),
1350
1616
  this.store.on("update", () => this.requestRender()),
1351
1617
  this.store.on("clear", () => this.clearDomNodes())
1352
1618
  ];
@@ -1721,6 +1987,40 @@ var Viewport = class {
1721
1987
  }
1722
1988
  }
1723
1989
  }
1990
+ unbindArrowsFrom(removedElement) {
1991
+ const boundArrows = findBoundArrows(removedElement.id, this.store);
1992
+ const bounds = getElementBounds(removedElement);
1993
+ for (const arrow of boundArrows) {
1994
+ const updates = {};
1995
+ if (arrow.fromBinding?.elementId === removedElement.id) {
1996
+ updates.fromBinding = void 0;
1997
+ if (bounds) {
1998
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
1999
+ const rayTarget = {
2000
+ x: arrow.from.x + Math.cos(angle) * 1e3,
2001
+ y: arrow.from.y + Math.sin(angle) * 1e3
2002
+ };
2003
+ const edge = getEdgeIntersection(bounds, rayTarget);
2004
+ updates.from = edge;
2005
+ updates.position = edge;
2006
+ }
2007
+ }
2008
+ if (arrow.toBinding?.elementId === removedElement.id) {
2009
+ updates.toBinding = void 0;
2010
+ if (bounds) {
2011
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
2012
+ const rayTarget = {
2013
+ x: arrow.to.x - Math.cos(angle) * 1e3,
2014
+ y: arrow.to.y - Math.sin(angle) * 1e3
2015
+ };
2016
+ updates.to = getEdgeIntersection(bounds, rayTarget);
2017
+ }
2018
+ }
2019
+ if (Object.keys(updates).length > 0) {
2020
+ this.store.update(arrow.id, updates);
2021
+ }
2022
+ }
2023
+ }
1724
2024
  removeDomNode(id) {
1725
2025
  this.htmlContent.delete(id);
1726
2026
  const node = this.domNodes.get(id);
@@ -1949,6 +2249,7 @@ var EraserTool = class {
1949
2249
  };
1950
2250
 
1951
2251
  // src/tools/arrow-handles.ts
2252
+ var BIND_THRESHOLD = 20;
1952
2253
  var HANDLE_RADIUS = 5;
1953
2254
  var HANDLE_HIT_PADDING = 4;
1954
2255
  var ARROW_HANDLE_CURSORS = {
@@ -1989,18 +2290,44 @@ function hitTestArrowHandles(world, selectedIds, ctx) {
1989
2290
  function applyArrowHandleDrag(handle, elementId, world, ctx) {
1990
2291
  const el = ctx.store.getById(elementId);
1991
2292
  if (!el || el.type !== "arrow") return;
2293
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
1992
2294
  switch (handle) {
1993
- case "start":
1994
- ctx.store.update(elementId, {
1995
- from: { x: world.x, y: world.y },
1996
- position: { x: world.x, y: world.y }
1997
- });
2295
+ case "start": {
2296
+ const excludeId = el.toBinding?.elementId;
2297
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2298
+ if (target) {
2299
+ const center = getElementCenter(target);
2300
+ ctx.store.update(elementId, {
2301
+ from: center,
2302
+ position: center,
2303
+ fromBinding: { elementId: target.id }
2304
+ });
2305
+ } else {
2306
+ ctx.store.update(elementId, {
2307
+ from: { x: world.x, y: world.y },
2308
+ position: { x: world.x, y: world.y },
2309
+ fromBinding: void 0
2310
+ });
2311
+ }
1998
2312
  break;
1999
- case "end":
2000
- ctx.store.update(elementId, {
2001
- to: { x: world.x, y: world.y }
2002
- });
2313
+ }
2314
+ case "end": {
2315
+ const excludeId = el.fromBinding?.elementId;
2316
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2317
+ if (target) {
2318
+ const center = getElementCenter(target);
2319
+ ctx.store.update(elementId, {
2320
+ to: center,
2321
+ toBinding: { elementId: target.id }
2322
+ });
2323
+ } else {
2324
+ ctx.store.update(elementId, {
2325
+ to: { x: world.x, y: world.y },
2326
+ toBinding: void 0
2327
+ });
2328
+ }
2003
2329
  break;
2330
+ }
2004
2331
  case "mid": {
2005
2332
  const bend = getBendFromPoint(el.from, el.to, world);
2006
2333
  ctx.store.update(elementId, { bend });
@@ -2009,6 +2336,16 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
2009
2336
  }
2010
2337
  ctx.requestRender();
2011
2338
  }
2339
+ function getArrowHandleDragTarget(handle, elementId, world, ctx) {
2340
+ if (handle === "mid") return null;
2341
+ const el = ctx.store.getById(elementId);
2342
+ if (!el || el.type !== "arrow") return null;
2343
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
2344
+ const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
2345
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2346
+ if (!target) return null;
2347
+ return getElementBounds(target);
2348
+ }
2012
2349
  function renderArrowHandles(canvasCtx, arrow, zoom) {
2013
2350
  const radius = HANDLE_RADIUS / zoom;
2014
2351
  const handles = getArrowHandlePositions(arrow);
@@ -2119,6 +2456,9 @@ var SelectTool = class {
2119
2456
  const el = ctx.store.getById(id);
2120
2457
  if (!el || el.locked) continue;
2121
2458
  if (el.type === "arrow") {
2459
+ if (el.fromBinding || el.toBinding) {
2460
+ continue;
2461
+ }
2122
2462
  ctx.store.update(id, {
2123
2463
  position: { x: el.position.x + dx, y: el.position.y + dy },
2124
2464
  from: { x: el.from.x + dx, y: el.from.y + dy },
@@ -2130,6 +2470,23 @@ var SelectTool = class {
2130
2470
  });
2131
2471
  }
2132
2472
  }
2473
+ const movedNonArrowIds = /* @__PURE__ */ new Set();
2474
+ for (const id of this._selectedIds) {
2475
+ const el = ctx.store.getById(id);
2476
+ if (el && el.type !== "arrow") movedNonArrowIds.add(id);
2477
+ }
2478
+ if (movedNonArrowIds.size > 0) {
2479
+ const updatedArrows = /* @__PURE__ */ new Set();
2480
+ for (const id of movedNonArrowIds) {
2481
+ const boundArrows = findBoundArrows(id, ctx.store);
2482
+ for (const ba of boundArrows) {
2483
+ if (updatedArrows.has(ba.id)) continue;
2484
+ updatedArrows.add(ba.id);
2485
+ const updates = updateBoundArrow(ba, ctx.store);
2486
+ if (updates) ctx.store.update(ba.id, updates);
2487
+ }
2488
+ }
2489
+ }
2133
2490
  ctx.requestRender();
2134
2491
  return;
2135
2492
  }
@@ -2158,6 +2515,22 @@ var SelectTool = class {
2158
2515
  renderOverlay(canvasCtx) {
2159
2516
  this.renderMarquee(canvasCtx);
2160
2517
  this.renderSelectionBoxes(canvasCtx);
2518
+ if (this.mode.type === "arrow-handle" && this.ctx) {
2519
+ const target = getArrowHandleDragTarget(
2520
+ this.mode.handle,
2521
+ this.mode.elementId,
2522
+ this.currentWorld,
2523
+ this.ctx
2524
+ );
2525
+ if (target) {
2526
+ canvasCtx.save();
2527
+ canvasCtx.strokeStyle = "#2196F3";
2528
+ canvasCtx.lineWidth = 2 / this.ctx.camera.zoom;
2529
+ canvasCtx.setLineDash([]);
2530
+ canvasCtx.strokeRect(target.x, target.y, target.w, target.h);
2531
+ canvasCtx.restore();
2532
+ }
2533
+ }
2161
2534
  }
2162
2535
  updateHoverCursor(world, ctx) {
2163
2536
  const arrowHit = hitTestArrowHandles(world, this._selectedIds, ctx);
@@ -2216,6 +2589,11 @@ var SelectTool = class {
2216
2589
  position: { x, y },
2217
2590
  size: { w, h }
2218
2591
  });
2592
+ const boundArrows = findBoundArrows(this.mode.elementId, ctx.store);
2593
+ for (const ba of boundArrows) {
2594
+ const updates = updateBoundArrow(ba, ctx.store);
2595
+ if (updates) ctx.store.update(ba.id, updates);
2596
+ }
2219
2597
  ctx.requestRender();
2220
2598
  }
2221
2599
  hitTestResizeHandle(world, ctx) {
@@ -2270,6 +2648,7 @@ var SelectTool = class {
2270
2648
  if (!el) continue;
2271
2649
  if (el.type === "arrow") {
2272
2650
  renderArrowHandles(canvasCtx, el, zoom);
2651
+ this.renderBindingHighlights(canvasCtx, el, zoom);
2273
2652
  continue;
2274
2653
  }
2275
2654
  const bounds = this.getElementBounds(el);
@@ -2299,6 +2678,26 @@ var SelectTool = class {
2299
2678
  }
2300
2679
  canvasCtx.restore();
2301
2680
  }
2681
+ renderBindingHighlights(canvasCtx, arrow, zoom) {
2682
+ if (!this.ctx) return;
2683
+ if (!arrow.fromBinding && !arrow.toBinding) return;
2684
+ const pad = SELECTION_PAD / zoom;
2685
+ canvasCtx.save();
2686
+ canvasCtx.strokeStyle = "#2196F3";
2687
+ canvasCtx.lineWidth = 2 / zoom;
2688
+ canvasCtx.setLineDash([]);
2689
+ const drawn = /* @__PURE__ */ new Set();
2690
+ for (const binding of [arrow.fromBinding, arrow.toBinding]) {
2691
+ if (!binding || drawn.has(binding.elementId)) continue;
2692
+ drawn.add(binding.elementId);
2693
+ const target = this.ctx.store.getById(binding.elementId);
2694
+ if (!target) continue;
2695
+ const bounds = getElementBounds(target);
2696
+ if (!bounds) continue;
2697
+ canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
2698
+ }
2699
+ canvasCtx.restore();
2700
+ }
2302
2701
  getMarqueeRect() {
2303
2702
  if (this.mode.type !== "marquee") return null;
2304
2703
  const { start } = this.mode;
@@ -2372,6 +2771,7 @@ var SelectTool = class {
2372
2771
  };
2373
2772
 
2374
2773
  // src/tools/arrow-tool.ts
2774
+ var BIND_THRESHOLD2 = 20;
2375
2775
  var ArrowTool = class {
2376
2776
  name = "arrow";
2377
2777
  drawing = false;
@@ -2379,6 +2779,9 @@ var ArrowTool = class {
2379
2779
  end = { x: 0, y: 0 };
2380
2780
  color;
2381
2781
  width;
2782
+ fromBinding;
2783
+ fromTarget = null;
2784
+ toTarget = null;
2382
2785
  constructor(options = {}) {
2383
2786
  this.color = options.color ?? "#000000";
2384
2787
  this.width = options.width ?? 2;
@@ -2389,12 +2792,34 @@ var ArrowTool = class {
2389
2792
  }
2390
2793
  onPointerDown(state, ctx) {
2391
2794
  this.drawing = true;
2392
- this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2795
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2796
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2797
+ const target = findBindTarget(world, ctx.store, threshold);
2798
+ if (target) {
2799
+ this.start = getElementCenter(target);
2800
+ this.fromBinding = { elementId: target.id };
2801
+ this.fromTarget = target;
2802
+ } else {
2803
+ this.start = world;
2804
+ this.fromBinding = void 0;
2805
+ this.fromTarget = null;
2806
+ }
2393
2807
  this.end = { ...this.start };
2808
+ this.toTarget = null;
2394
2809
  }
2395
2810
  onPointerMove(state, ctx) {
2396
2811
  if (!this.drawing) return;
2397
- this.end = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2812
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2813
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2814
+ const excludeId = this.fromBinding?.elementId;
2815
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2816
+ if (target) {
2817
+ this.end = getElementCenter(target);
2818
+ this.toTarget = target;
2819
+ } else {
2820
+ this.end = world;
2821
+ this.toTarget = null;
2822
+ }
2398
2823
  ctx.requestRender();
2399
2824
  }
2400
2825
  onPointerUp(_state, ctx) {
@@ -2404,16 +2829,40 @@ var ArrowTool = class {
2404
2829
  const arrow = createArrow({
2405
2830
  from: this.start,
2406
2831
  to: this.end,
2832
+ position: this.start,
2407
2833
  color: this.color,
2408
- width: this.width
2834
+ width: this.width,
2835
+ fromBinding: this.fromBinding,
2836
+ toBinding: this.toTarget ? { elementId: this.toTarget.id } : void 0
2409
2837
  });
2410
2838
  ctx.store.add(arrow);
2839
+ this.fromTarget = null;
2840
+ this.toTarget = null;
2411
2841
  ctx.requestRender();
2842
+ ctx.switchTool?.("select");
2412
2843
  }
2413
2844
  renderOverlay(ctx) {
2414
2845
  if (!this.drawing) return;
2415
2846
  if (this.start.x === this.end.x && this.start.y === this.end.y) return;
2416
2847
  ctx.save();
2848
+ if (this.fromTarget) {
2849
+ const bounds = getElementBounds(this.fromTarget);
2850
+ if (bounds) {
2851
+ ctx.strokeStyle = "#2196F3";
2852
+ ctx.lineWidth = 2;
2853
+ ctx.setLineDash([]);
2854
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2855
+ }
2856
+ }
2857
+ if (this.toTarget) {
2858
+ const bounds = getElementBounds(this.toTarget);
2859
+ if (bounds) {
2860
+ ctx.strokeStyle = "#2196F3";
2861
+ ctx.lineWidth = 2;
2862
+ ctx.setLineDash([]);
2863
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2864
+ }
2865
+ }
2417
2866
  ctx.strokeStyle = this.color;
2418
2867
  ctx.lineWidth = this.width;
2419
2868
  ctx.lineCap = "round";
@@ -2544,8 +2993,123 @@ var ImageTool = class {
2544
2993
  }
2545
2994
  };
2546
2995
 
2996
+ // src/tools/shape-tool.ts
2997
+ var ShapeTool = class {
2998
+ name = "shape";
2999
+ drawing = false;
3000
+ start = { x: 0, y: 0 };
3001
+ end = { x: 0, y: 0 };
3002
+ shiftHeld = false;
3003
+ shape;
3004
+ strokeColor;
3005
+ strokeWidth;
3006
+ fillColor;
3007
+ constructor(options = {}) {
3008
+ this.shape = options.shape ?? "rectangle";
3009
+ this.strokeColor = options.strokeColor ?? "#000000";
3010
+ this.strokeWidth = options.strokeWidth ?? 2;
3011
+ this.fillColor = options.fillColor ?? "none";
3012
+ }
3013
+ setOptions(options) {
3014
+ if (options.shape !== void 0) this.shape = options.shape;
3015
+ if (options.strokeColor !== void 0) this.strokeColor = options.strokeColor;
3016
+ if (options.strokeWidth !== void 0) this.strokeWidth = options.strokeWidth;
3017
+ if (options.fillColor !== void 0) this.fillColor = options.fillColor;
3018
+ }
3019
+ onActivate(_ctx) {
3020
+ if (typeof window !== "undefined") {
3021
+ window.addEventListener("keydown", this.onKeyDown);
3022
+ window.addEventListener("keyup", this.onKeyUp);
3023
+ }
3024
+ }
3025
+ onDeactivate(_ctx) {
3026
+ this.shiftHeld = false;
3027
+ if (typeof window !== "undefined") {
3028
+ window.removeEventListener("keydown", this.onKeyDown);
3029
+ window.removeEventListener("keyup", this.onKeyUp);
3030
+ }
3031
+ }
3032
+ onPointerDown(state, ctx) {
3033
+ this.drawing = true;
3034
+ this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
3035
+ this.end = { ...this.start };
3036
+ }
3037
+ onPointerMove(state, ctx) {
3038
+ if (!this.drawing) return;
3039
+ this.end = ctx.camera.screenToWorld({ x: state.x, y: state.y });
3040
+ ctx.requestRender();
3041
+ }
3042
+ onPointerUp(_state, ctx) {
3043
+ if (!this.drawing) return;
3044
+ this.drawing = false;
3045
+ const { position, size } = this.computeRect();
3046
+ if (size.w === 0 || size.h === 0) return;
3047
+ const shape = createShape({
3048
+ position,
3049
+ size,
3050
+ shape: this.shape,
3051
+ strokeColor: this.strokeColor,
3052
+ strokeWidth: this.strokeWidth,
3053
+ fillColor: this.fillColor
3054
+ });
3055
+ ctx.store.add(shape);
3056
+ ctx.requestRender();
3057
+ ctx.switchTool?.("select");
3058
+ }
3059
+ renderOverlay(ctx) {
3060
+ if (!this.drawing) return;
3061
+ const { position, size } = this.computeRect();
3062
+ if (size.w === 0 && size.h === 0) return;
3063
+ ctx.save();
3064
+ ctx.globalAlpha = 0.5;
3065
+ ctx.strokeStyle = this.strokeColor;
3066
+ ctx.lineWidth = this.strokeWidth;
3067
+ if (this.fillColor !== "none") {
3068
+ ctx.fillStyle = this.fillColor;
3069
+ }
3070
+ switch (this.shape) {
3071
+ case "rectangle":
3072
+ if (this.fillColor !== "none") {
3073
+ ctx.fillRect(position.x, position.y, size.w, size.h);
3074
+ }
3075
+ ctx.strokeRect(position.x, position.y, size.w, size.h);
3076
+ break;
3077
+ case "ellipse": {
3078
+ const cx = position.x + size.w / 2;
3079
+ const cy = position.y + size.h / 2;
3080
+ ctx.beginPath();
3081
+ ctx.ellipse(cx, cy, size.w / 2, size.h / 2, 0, 0, Math.PI * 2);
3082
+ if (this.fillColor !== "none") ctx.fill();
3083
+ ctx.stroke();
3084
+ break;
3085
+ }
3086
+ }
3087
+ ctx.restore();
3088
+ }
3089
+ computeRect() {
3090
+ let x = Math.min(this.start.x, this.end.x);
3091
+ let y = Math.min(this.start.y, this.end.y);
3092
+ let w = Math.abs(this.end.x - this.start.x);
3093
+ let h = Math.abs(this.end.y - this.start.y);
3094
+ if (this.shiftHeld) {
3095
+ const side = Math.max(w, h);
3096
+ w = side;
3097
+ h = side;
3098
+ x = this.end.x >= this.start.x ? this.start.x : this.start.x - side;
3099
+ y = this.end.y >= this.start.y ? this.start.y : this.start.y - side;
3100
+ }
3101
+ return { position: { x, y }, size: { w, h } };
3102
+ }
3103
+ onKeyDown = (e) => {
3104
+ if (e.key === "Shift") this.shiftHeld = true;
3105
+ };
3106
+ onKeyUp = (e) => {
3107
+ if (e.key === "Shift") this.shiftHeld = false;
3108
+ };
3109
+ };
3110
+
2547
3111
  // src/index.ts
2548
- var VERSION = "0.3.0";
3112
+ var VERSION = "0.4.0";
2549
3113
  // Annotate the CommonJS export names for ESM import in node:
2550
3114
  0 && (module.exports = {
2551
3115
  AddElementCommand,
@@ -2568,25 +3132,36 @@ var VERSION = "0.3.0";
2568
3132
  PencilTool,
2569
3133
  RemoveElementCommand,
2570
3134
  SelectTool,
3135
+ ShapeTool,
2571
3136
  TextTool,
2572
3137
  ToolManager,
2573
3138
  UpdateElementCommand,
2574
3139
  VERSION,
2575
3140
  Viewport,
3141
+ clearStaleBindings,
2576
3142
  createArrow,
2577
3143
  createHtmlElement,
2578
3144
  createId,
2579
3145
  createImage,
2580
3146
  createNote,
3147
+ createShape,
2581
3148
  createStroke,
2582
3149
  createText,
2583
3150
  exportState,
3151
+ findBindTarget,
3152
+ findBoundArrows,
2584
3153
  getArrowBounds,
2585
3154
  getArrowControlPoint,
2586
3155
  getArrowMidpoint,
2587
3156
  getArrowTangentAngle,
2588
3157
  getBendFromPoint,
3158
+ getEdgeIntersection,
3159
+ getElementBounds,
3160
+ getElementCenter,
3161
+ isBindable,
2589
3162
  isNearBezier,
2590
- parseState
3163
+ parseState,
3164
+ unbindArrow,
3165
+ updateBoundArrow
2591
3166
  });
2592
3167
  //# sourceMappingURL=index.cjs.map