@fieldnotes/core 0.2.3 → 0.3.1

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,24 +40,35 @@ __export(index_exports, {
40
40
  PencilTool: () => PencilTool,
41
41
  RemoveElementCommand: () => RemoveElementCommand,
42
42
  SelectTool: () => SelectTool,
43
+ TextTool: () => TextTool,
43
44
  ToolManager: () => ToolManager,
44
45
  UpdateElementCommand: () => UpdateElementCommand,
45
46
  VERSION: () => VERSION,
46
47
  Viewport: () => Viewport,
48
+ clearStaleBindings: () => clearStaleBindings,
47
49
  createArrow: () => createArrow,
48
50
  createHtmlElement: () => createHtmlElement,
49
51
  createId: () => createId,
50
52
  createImage: () => createImage,
51
53
  createNote: () => createNote,
52
54
  createStroke: () => createStroke,
55
+ createText: () => createText,
53
56
  exportState: () => exportState,
57
+ findBindTarget: () => findBindTarget,
58
+ findBoundArrows: () => findBoundArrows,
54
59
  getArrowBounds: () => getArrowBounds,
55
60
  getArrowControlPoint: () => getArrowControlPoint,
56
61
  getArrowMidpoint: () => getArrowMidpoint,
57
62
  getArrowTangentAngle: () => getArrowTangentAngle,
58
63
  getBendFromPoint: () => getBendFromPoint,
64
+ getEdgeIntersection: () => getEdgeIntersection,
65
+ getElementBounds: () => getElementBounds,
66
+ getElementCenter: () => getElementCenter,
67
+ isBindable: () => isBindable,
59
68
  isNearBezier: () => isNearBezier,
60
- parseState: () => parseState
69
+ parseState: () => parseState,
70
+ unbindArrow: () => unbindArrow,
71
+ updateBoundArrow: () => updateBoundArrow
61
72
  });
62
73
  module.exports = __toCommonJS(index_exports);
63
74
 
@@ -131,8 +142,9 @@ function validateState(data) {
131
142
  validateElement(el);
132
143
  migrateElement(el);
133
144
  }
145
+ cleanBindings(obj["elements"]);
134
146
  }
135
- var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html"]);
147
+ var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text"]);
136
148
  function validateElement(el) {
137
149
  if (!el || typeof el !== "object") {
138
150
  throw new Error("Invalid element: expected an object");
@@ -148,6 +160,20 @@ function validateElement(el) {
148
160
  throw new Error("Invalid element: missing zIndex");
149
161
  }
150
162
  }
163
+ function cleanBindings(elements) {
164
+ const ids = new Set(elements.map((el) => el["id"]));
165
+ for (const el of elements) {
166
+ if (el["type"] !== "arrow") continue;
167
+ const fromBinding = el["fromBinding"];
168
+ if (fromBinding && !ids.has(fromBinding["elementId"])) {
169
+ el["fromBinding"] = void 0;
170
+ }
171
+ const toBinding = el["toBinding"];
172
+ if (toBinding && !ids.has(toBinding["elementId"])) {
173
+ el["toBinding"] = void 0;
174
+ }
175
+ }
176
+ }
151
177
  function migrateElement(obj) {
152
178
  if (obj["type"] === "arrow" && typeof obj["bend"] !== "number") {
153
179
  obj["bend"] = 0;
@@ -741,6 +767,134 @@ function isNearLine(point, a, b, threshold) {
741
767
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
742
768
  }
743
769
 
770
+ // src/elements/arrow-binding.ts
771
+ var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html"]);
772
+ function isBindable(element) {
773
+ return BINDABLE_TYPES.has(element.type);
774
+ }
775
+ function getElementCenter(element) {
776
+ if (!("size" in element)) {
777
+ throw new Error(`getElementCenter: element type "${element.type}" has no size`);
778
+ }
779
+ return {
780
+ x: element.position.x + element.size.w / 2,
781
+ y: element.position.y + element.size.h / 2
782
+ };
783
+ }
784
+ function getElementBounds(element) {
785
+ if (!("size" in element)) return null;
786
+ return {
787
+ x: element.position.x,
788
+ y: element.position.y,
789
+ w: element.size.w,
790
+ h: element.size.h
791
+ };
792
+ }
793
+ function getEdgeIntersection(bounds, outsidePoint) {
794
+ const cx = bounds.x + bounds.w / 2;
795
+ const cy = bounds.y + bounds.h / 2;
796
+ const dx = outsidePoint.x - cx;
797
+ const dy = outsidePoint.y - cy;
798
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
799
+ const halfW = bounds.w / 2;
800
+ const halfH = bounds.h / 2;
801
+ const scaleX = dx !== 0 ? halfW / Math.abs(dx) : Infinity;
802
+ const scaleY = dy !== 0 ? halfH / Math.abs(dy) : Infinity;
803
+ const scale = Math.min(scaleX, scaleY);
804
+ return {
805
+ x: cx + dx * scale,
806
+ y: cy + dy * scale
807
+ };
808
+ }
809
+ function findBindTarget(point, store, threshold, excludeId) {
810
+ let closest = null;
811
+ let closestDist = Infinity;
812
+ for (const el of store.getAll()) {
813
+ if (!isBindable(el)) continue;
814
+ if (excludeId && el.id === excludeId) continue;
815
+ const bounds = getElementBounds(el);
816
+ if (!bounds) continue;
817
+ const dist = distToBounds(point, bounds);
818
+ if (dist <= threshold && dist < closestDist) {
819
+ closest = el;
820
+ closestDist = dist;
821
+ }
822
+ }
823
+ return closest;
824
+ }
825
+ function distToBounds(point, bounds) {
826
+ const clampedX = Math.max(bounds.x, Math.min(point.x, bounds.x + bounds.w));
827
+ const clampedY = Math.max(bounds.y, Math.min(point.y, bounds.y + bounds.h));
828
+ return Math.hypot(point.x - clampedX, point.y - clampedY);
829
+ }
830
+ function findBoundArrows(elementId, store) {
831
+ return store.getElementsByType("arrow").filter((a) => a.fromBinding?.elementId === elementId || a.toBinding?.elementId === elementId);
832
+ }
833
+ function updateBoundArrow(arrow, store) {
834
+ if (!arrow.fromBinding && !arrow.toBinding) return null;
835
+ const updates = {};
836
+ if (arrow.fromBinding) {
837
+ const el = store.getById(arrow.fromBinding.elementId);
838
+ if (el) {
839
+ const center = getElementCenter(el);
840
+ updates.from = center;
841
+ updates.position = center;
842
+ }
843
+ }
844
+ if (arrow.toBinding) {
845
+ const el = store.getById(arrow.toBinding.elementId);
846
+ if (el) {
847
+ updates.to = getElementCenter(el);
848
+ }
849
+ }
850
+ return Object.keys(updates).length > 0 ? updates : null;
851
+ }
852
+ function clearStaleBindings(arrow, store) {
853
+ const updates = {};
854
+ let hasUpdates = false;
855
+ if (arrow.fromBinding && !store.getById(arrow.fromBinding.elementId)) {
856
+ updates.fromBinding = void 0;
857
+ hasUpdates = true;
858
+ }
859
+ if (arrow.toBinding && !store.getById(arrow.toBinding.elementId)) {
860
+ updates.toBinding = void 0;
861
+ hasUpdates = true;
862
+ }
863
+ return hasUpdates ? updates : null;
864
+ }
865
+ function unbindArrow(arrow, store) {
866
+ const updates = {};
867
+ if (arrow.fromBinding) {
868
+ const el = store.getById(arrow.fromBinding.elementId);
869
+ const bounds = el ? getElementBounds(el) : null;
870
+ if (bounds) {
871
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
872
+ const rayTarget = {
873
+ x: arrow.from.x + Math.cos(angle) * 1e3,
874
+ y: arrow.from.y + Math.sin(angle) * 1e3
875
+ };
876
+ const edge = getEdgeIntersection(bounds, rayTarget);
877
+ updates.from = edge;
878
+ updates.position = edge;
879
+ }
880
+ updates.fromBinding = void 0;
881
+ }
882
+ if (arrow.toBinding) {
883
+ const el = store.getById(arrow.toBinding.elementId);
884
+ const bounds = el ? getElementBounds(el) : null;
885
+ if (bounds) {
886
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
887
+ const rayTarget = {
888
+ x: arrow.to.x - Math.cos(angle) * 1e3,
889
+ y: arrow.to.y - Math.sin(angle) * 1e3
890
+ };
891
+ updates.to = getEdgeIntersection(bounds, rayTarget);
892
+ }
893
+ updates.toBinding = void 0;
894
+ }
895
+ return updates;
896
+ }
897
+
744
898
  // src/elements/stroke-smoothing.ts
745
899
  var MIN_PRESSURE_SCALE = 0.2;
746
900
  function pressureToWidth(pressure, baseWidth) {
@@ -815,10 +969,14 @@ function smoothToSegments(points) {
815
969
  }
816
970
 
817
971
  // src/elements/element-renderer.ts
818
- var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html"]);
972
+ var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html", "text"]);
819
973
  var ARROWHEAD_LENGTH = 12;
820
974
  var ARROWHEAD_ANGLE = Math.PI / 6;
821
975
  var ElementRenderer = class {
976
+ store = null;
977
+ setStore(store) {
978
+ this.store = store;
979
+ }
822
980
  isDomElement(element) {
823
981
  return DOM_ELEMENT_TYPES.has(element.type);
824
982
  }
@@ -852,38 +1010,76 @@ var ElementRenderer = class {
852
1010
  ctx.restore();
853
1011
  }
854
1012
  renderArrow(ctx, arrow) {
1013
+ const { visualFrom, visualTo } = this.getVisualEndpoints(arrow);
855
1014
  ctx.save();
856
1015
  ctx.strokeStyle = arrow.color;
857
1016
  ctx.lineWidth = arrow.width;
858
1017
  ctx.lineCap = "round";
1018
+ if (arrow.fromBinding || arrow.toBinding) {
1019
+ ctx.setLineDash([8, 4]);
1020
+ }
859
1021
  ctx.beginPath();
860
- ctx.moveTo(arrow.from.x, arrow.from.y);
1022
+ ctx.moveTo(visualFrom.x, visualFrom.y);
861
1023
  if (arrow.bend !== 0) {
862
1024
  const cp = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
863
- ctx.quadraticCurveTo(cp.x, cp.y, arrow.to.x, arrow.to.y);
1025
+ ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
864
1026
  } else {
865
- ctx.lineTo(arrow.to.x, arrow.to.y);
1027
+ ctx.lineTo(visualTo.x, visualTo.y);
866
1028
  }
867
1029
  ctx.stroke();
868
- this.renderArrowhead(ctx, arrow);
1030
+ this.renderArrowhead(ctx, arrow, visualTo);
869
1031
  ctx.restore();
870
1032
  }
871
- renderArrowhead(ctx, arrow) {
1033
+ renderArrowhead(ctx, arrow, tip) {
872
1034
  const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
873
1035
  ctx.beginPath();
874
- ctx.moveTo(arrow.to.x, arrow.to.y);
1036
+ ctx.moveTo(tip.x, tip.y);
875
1037
  ctx.lineTo(
876
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
877
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
1038
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
1039
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
878
1040
  );
879
1041
  ctx.lineTo(
880
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
881
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
1042
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
1043
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
882
1044
  );
883
1045
  ctx.closePath();
884
1046
  ctx.fillStyle = arrow.color;
885
1047
  ctx.fill();
886
1048
  }
1049
+ getVisualEndpoints(arrow) {
1050
+ let visualFrom = arrow.from;
1051
+ let visualTo = arrow.to;
1052
+ if (!this.store) return { visualFrom, visualTo };
1053
+ if (arrow.fromBinding) {
1054
+ const el = this.store.getById(arrow.fromBinding.elementId);
1055
+ if (el) {
1056
+ const bounds = getElementBounds(el);
1057
+ if (bounds) {
1058
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
1059
+ const rayTarget = {
1060
+ x: arrow.from.x + Math.cos(tangentAngle) * 1e3,
1061
+ y: arrow.from.y + Math.sin(tangentAngle) * 1e3
1062
+ };
1063
+ visualFrom = getEdgeIntersection(bounds, rayTarget);
1064
+ }
1065
+ }
1066
+ }
1067
+ if (arrow.toBinding) {
1068
+ const el = this.store.getById(arrow.toBinding.elementId);
1069
+ if (el) {
1070
+ const bounds = getElementBounds(el);
1071
+ if (bounds) {
1072
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
1073
+ const rayTarget = {
1074
+ x: arrow.to.x - Math.cos(tangentAngle) * 1e3,
1075
+ y: arrow.to.y - Math.sin(tangentAngle) * 1e3
1076
+ };
1077
+ visualTo = getEdgeIntersection(bounds, rayTarget);
1078
+ }
1079
+ }
1080
+ }
1081
+ return { visualFrom, visualTo };
1082
+ }
887
1083
  };
888
1084
 
889
1085
  // src/elements/note-editor.ts
@@ -894,12 +1090,16 @@ var NoteEditor = class {
894
1090
  keyHandler = null;
895
1091
  pointerHandler = null;
896
1092
  pendingEditId = null;
1093
+ onStopCallback = null;
897
1094
  get isEditing() {
898
1095
  return this.editingId !== null;
899
1096
  }
900
1097
  get editingElementId() {
901
1098
  return this.editingId;
902
1099
  }
1100
+ setOnStop(callback) {
1101
+ this.onStopCallback = callback;
1102
+ }
903
1103
  startEditing(node, elementId, store) {
904
1104
  if (this.editingId === elementId) return;
905
1105
  if (this.editingId) {
@@ -931,6 +1131,9 @@ var NoteEditor = class {
931
1131
  if (this.pointerHandler) {
932
1132
  this.editingNode.removeEventListener("pointerdown", this.pointerHandler);
933
1133
  }
1134
+ if (this.editingId && this.onStopCallback) {
1135
+ this.onStopCallback(this.editingId);
1136
+ }
934
1137
  this.editingId = null;
935
1138
  this.editingNode = null;
936
1139
  this.blurHandler = null;
@@ -1247,7 +1450,7 @@ function createNote(input) {
1247
1450
  };
1248
1451
  }
1249
1452
  function createArrow(input) {
1250
- return {
1453
+ const result = {
1251
1454
  id: createId("arrow"),
1252
1455
  type: "arrow",
1253
1456
  position: input.position ?? { x: 0, y: 0 },
@@ -1259,6 +1462,9 @@ function createArrow(input) {
1259
1462
  color: input.color ?? "#000000",
1260
1463
  width: input.width ?? 2
1261
1464
  };
1465
+ if (input.fromBinding) result.fromBinding = input.fromBinding;
1466
+ if (input.toBinding) result.toBinding = input.toBinding;
1467
+ return result;
1262
1468
  }
1263
1469
  function createImage(input) {
1264
1470
  return {
@@ -1281,6 +1487,20 @@ function createHtmlElement(input) {
1281
1487
  size: input.size
1282
1488
  };
1283
1489
  }
1490
+ function createText(input) {
1491
+ return {
1492
+ id: createId("text"),
1493
+ type: "text",
1494
+ position: input.position,
1495
+ zIndex: input.zIndex ?? 0,
1496
+ locked: input.locked ?? false,
1497
+ size: input.size ?? { w: 200, h: 28 },
1498
+ text: input.text ?? "",
1499
+ fontSize: input.fontSize ?? 16,
1500
+ color: input.color ?? "#1a1a1a",
1501
+ textAlign: input.textAlign ?? "left"
1502
+ };
1503
+ }
1284
1504
 
1285
1505
  // src/canvas/viewport.ts
1286
1506
  var Viewport = class {
@@ -1291,7 +1511,9 @@ var Viewport = class {
1291
1511
  this.store = new ElementStore();
1292
1512
  this.toolManager = new ToolManager();
1293
1513
  this.renderer = new ElementRenderer();
1514
+ this.renderer.setStore(this.store);
1294
1515
  this.noteEditor = new NoteEditor();
1516
+ this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
1295
1517
  this.history = new HistoryStack();
1296
1518
  this.historyRecorder = new HistoryRecorder(this.store, this.history);
1297
1519
  this.wrapper = this.createWrapper();
@@ -1305,7 +1527,7 @@ var Viewport = class {
1305
1527
  store: this.store,
1306
1528
  requestRender: () => this.requestRender(),
1307
1529
  switchTool: (name) => this.toolManager.setTool(name, this.toolContext),
1308
- editElement: (id) => this.startEditingNote(id),
1530
+ editElement: (id) => this.startEditingElement(id),
1309
1531
  setCursor: (cursor) => {
1310
1532
  this.wrapper.style.cursor = cursor;
1311
1533
  }
@@ -1322,7 +1544,10 @@ var Viewport = class {
1322
1544
  });
1323
1545
  this.unsubStore = [
1324
1546
  this.store.on("add", () => this.requestRender()),
1325
- this.store.on("remove", (el) => this.removeDomNode(el.id)),
1547
+ this.store.on("remove", (el) => {
1548
+ this.unbindArrowsFrom(el);
1549
+ this.removeDomNode(el.id);
1550
+ }),
1326
1551
  this.store.on("update", () => this.requestRender()),
1327
1552
  this.store.on("clear", () => this.clearDomNodes())
1328
1553
  ];
@@ -1459,15 +1684,34 @@ var Viewport = class {
1459
1684
  ctx.restore();
1460
1685
  ctx.restore();
1461
1686
  }
1462
- startEditingNote(id) {
1687
+ startEditingElement(id) {
1463
1688
  const element = this.store.getById(id);
1464
- if (!element || element.type !== "note") return;
1689
+ if (!element || element.type !== "note" && element.type !== "text") return;
1465
1690
  this.render();
1466
1691
  const node = this.domNodes.get(id);
1467
1692
  if (node) {
1468
1693
  this.noteEditor.startEditing(node, id, this.store);
1469
1694
  }
1470
1695
  }
1696
+ onTextEditStop(elementId) {
1697
+ const element = this.store.getById(elementId);
1698
+ if (!element || element.type !== "text") return;
1699
+ if (!element.text || element.text.trim() === "") {
1700
+ this.historyRecorder.begin();
1701
+ this.store.remove(elementId);
1702
+ this.historyRecorder.commit();
1703
+ return;
1704
+ }
1705
+ const node = this.domNodes.get(elementId);
1706
+ if (node && "size" in element) {
1707
+ const measuredHeight = node.scrollHeight;
1708
+ if (measuredHeight !== element.size.h) {
1709
+ this.store.update(elementId, {
1710
+ size: { w: element.size.w, h: measuredHeight }
1711
+ });
1712
+ }
1713
+ }
1714
+ }
1471
1715
  onDblClick = (e) => {
1472
1716
  const el = document.elementFromPoint(e.clientX, e.clientY);
1473
1717
  const nodeEl = el?.closest("[data-element-id]");
@@ -1475,8 +1719,8 @@ var Viewport = class {
1475
1719
  const elementId = nodeEl.dataset["elementId"];
1476
1720
  if (elementId) {
1477
1721
  const element = this.store.getById(elementId);
1478
- if (element?.type === "note") {
1479
- this.startEditingNote(elementId);
1722
+ if (element?.type === "note" || element?.type === "text") {
1723
+ this.startEditingElement(elementId);
1480
1724
  return;
1481
1725
  }
1482
1726
  }
@@ -1600,7 +1844,7 @@ var Viewport = class {
1600
1844
  node.addEventListener("dblclick", (e) => {
1601
1845
  e.stopPropagation();
1602
1846
  const id = node.dataset["elementId"];
1603
- if (id) this.startEditingNote(id);
1847
+ if (id) this.startEditingElement(id);
1604
1848
  });
1605
1849
  }
1606
1850
  if (!this.noteEditor.isEditing || this.noteEditor.editingElementId !== element.id) {
@@ -1641,6 +1885,76 @@ var Viewport = class {
1641
1885
  node.appendChild(content);
1642
1886
  }
1643
1887
  }
1888
+ if (element.type === "text") {
1889
+ if (!node.dataset["initialized"]) {
1890
+ node.dataset["initialized"] = "true";
1891
+ Object.assign(node.style, {
1892
+ padding: "2px",
1893
+ fontSize: `${element.fontSize}px`,
1894
+ color: element.color,
1895
+ textAlign: element.textAlign,
1896
+ background: "none",
1897
+ border: "none",
1898
+ boxShadow: "none",
1899
+ overflow: "visible",
1900
+ cursor: "default",
1901
+ userSelect: "none",
1902
+ wordWrap: "break-word",
1903
+ whiteSpace: "pre-wrap",
1904
+ lineHeight: "1.4"
1905
+ });
1906
+ node.textContent = element.text || "";
1907
+ node.addEventListener("dblclick", (e) => {
1908
+ e.stopPropagation();
1909
+ const id = node.dataset["elementId"];
1910
+ if (id) this.startEditingElement(id);
1911
+ });
1912
+ }
1913
+ if (!this.noteEditor.isEditing || this.noteEditor.editingElementId !== element.id) {
1914
+ if (node.textContent !== element.text) {
1915
+ node.textContent = element.text || "";
1916
+ }
1917
+ Object.assign(node.style, {
1918
+ fontSize: `${element.fontSize}px`,
1919
+ color: element.color,
1920
+ textAlign: element.textAlign
1921
+ });
1922
+ }
1923
+ }
1924
+ }
1925
+ unbindArrowsFrom(removedElement) {
1926
+ const boundArrows = findBoundArrows(removedElement.id, this.store);
1927
+ const bounds = getElementBounds(removedElement);
1928
+ for (const arrow of boundArrows) {
1929
+ const updates = {};
1930
+ if (arrow.fromBinding?.elementId === removedElement.id) {
1931
+ updates.fromBinding = void 0;
1932
+ if (bounds) {
1933
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
1934
+ const rayTarget = {
1935
+ x: arrow.from.x + Math.cos(angle) * 1e3,
1936
+ y: arrow.from.y + Math.sin(angle) * 1e3
1937
+ };
1938
+ const edge = getEdgeIntersection(bounds, rayTarget);
1939
+ updates.from = edge;
1940
+ updates.position = edge;
1941
+ }
1942
+ }
1943
+ if (arrow.toBinding?.elementId === removedElement.id) {
1944
+ updates.toBinding = void 0;
1945
+ if (bounds) {
1946
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
1947
+ const rayTarget = {
1948
+ x: arrow.to.x - Math.cos(angle) * 1e3,
1949
+ y: arrow.to.y - Math.sin(angle) * 1e3
1950
+ };
1951
+ updates.to = getEdgeIntersection(bounds, rayTarget);
1952
+ }
1953
+ }
1954
+ if (Object.keys(updates).length > 0) {
1955
+ this.store.update(arrow.id, updates);
1956
+ }
1957
+ }
1644
1958
  }
1645
1959
  removeDomNode(id) {
1646
1960
  this.htmlContent.delete(id);
@@ -1870,6 +2184,7 @@ var EraserTool = class {
1870
2184
  };
1871
2185
 
1872
2186
  // src/tools/arrow-handles.ts
2187
+ var BIND_THRESHOLD = 20;
1873
2188
  var HANDLE_RADIUS = 5;
1874
2189
  var HANDLE_HIT_PADDING = 4;
1875
2190
  var ARROW_HANDLE_CURSORS = {
@@ -1910,18 +2225,44 @@ function hitTestArrowHandles(world, selectedIds, ctx) {
1910
2225
  function applyArrowHandleDrag(handle, elementId, world, ctx) {
1911
2226
  const el = ctx.store.getById(elementId);
1912
2227
  if (!el || el.type !== "arrow") return;
2228
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
1913
2229
  switch (handle) {
1914
- case "start":
1915
- ctx.store.update(elementId, {
1916
- from: { x: world.x, y: world.y },
1917
- position: { x: world.x, y: world.y }
1918
- });
2230
+ case "start": {
2231
+ const excludeId = el.toBinding?.elementId;
2232
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2233
+ if (target) {
2234
+ const center = getElementCenter(target);
2235
+ ctx.store.update(elementId, {
2236
+ from: center,
2237
+ position: center,
2238
+ fromBinding: { elementId: target.id }
2239
+ });
2240
+ } else {
2241
+ ctx.store.update(elementId, {
2242
+ from: { x: world.x, y: world.y },
2243
+ position: { x: world.x, y: world.y },
2244
+ fromBinding: void 0
2245
+ });
2246
+ }
1919
2247
  break;
1920
- case "end":
1921
- ctx.store.update(elementId, {
1922
- to: { x: world.x, y: world.y }
1923
- });
2248
+ }
2249
+ case "end": {
2250
+ const excludeId = el.fromBinding?.elementId;
2251
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2252
+ if (target) {
2253
+ const center = getElementCenter(target);
2254
+ ctx.store.update(elementId, {
2255
+ to: center,
2256
+ toBinding: { elementId: target.id }
2257
+ });
2258
+ } else {
2259
+ ctx.store.update(elementId, {
2260
+ to: { x: world.x, y: world.y },
2261
+ toBinding: void 0
2262
+ });
2263
+ }
1924
2264
  break;
2265
+ }
1925
2266
  case "mid": {
1926
2267
  const bend = getBendFromPoint(el.from, el.to, world);
1927
2268
  ctx.store.update(elementId, { bend });
@@ -1930,6 +2271,16 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
1930
2271
  }
1931
2272
  ctx.requestRender();
1932
2273
  }
2274
+ function getArrowHandleDragTarget(handle, elementId, world, ctx) {
2275
+ if (handle === "mid") return null;
2276
+ const el = ctx.store.getById(elementId);
2277
+ if (!el || el.type !== "arrow") return null;
2278
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
2279
+ const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
2280
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2281
+ if (!target) return null;
2282
+ return getElementBounds(target);
2283
+ }
1933
2284
  function renderArrowHandles(canvasCtx, arrow, zoom) {
1934
2285
  const radius = HANDLE_RADIUS / zoom;
1935
2286
  const handles = getArrowHandlePositions(arrow);
@@ -2040,6 +2391,9 @@ var SelectTool = class {
2040
2391
  const el = ctx.store.getById(id);
2041
2392
  if (!el || el.locked) continue;
2042
2393
  if (el.type === "arrow") {
2394
+ if (el.fromBinding || el.toBinding) {
2395
+ continue;
2396
+ }
2043
2397
  ctx.store.update(id, {
2044
2398
  position: { x: el.position.x + dx, y: el.position.y + dy },
2045
2399
  from: { x: el.from.x + dx, y: el.from.y + dy },
@@ -2051,6 +2405,23 @@ var SelectTool = class {
2051
2405
  });
2052
2406
  }
2053
2407
  }
2408
+ const movedNonArrowIds = /* @__PURE__ */ new Set();
2409
+ for (const id of this._selectedIds) {
2410
+ const el = ctx.store.getById(id);
2411
+ if (el && el.type !== "arrow") movedNonArrowIds.add(id);
2412
+ }
2413
+ if (movedNonArrowIds.size > 0) {
2414
+ const updatedArrows = /* @__PURE__ */ new Set();
2415
+ for (const id of movedNonArrowIds) {
2416
+ const boundArrows = findBoundArrows(id, ctx.store);
2417
+ for (const ba of boundArrows) {
2418
+ if (updatedArrows.has(ba.id)) continue;
2419
+ updatedArrows.add(ba.id);
2420
+ const updates = updateBoundArrow(ba, ctx.store);
2421
+ if (updates) ctx.store.update(ba.id, updates);
2422
+ }
2423
+ }
2424
+ }
2054
2425
  ctx.requestRender();
2055
2426
  return;
2056
2427
  }
@@ -2079,6 +2450,22 @@ var SelectTool = class {
2079
2450
  renderOverlay(canvasCtx) {
2080
2451
  this.renderMarquee(canvasCtx);
2081
2452
  this.renderSelectionBoxes(canvasCtx);
2453
+ if (this.mode.type === "arrow-handle" && this.ctx) {
2454
+ const target = getArrowHandleDragTarget(
2455
+ this.mode.handle,
2456
+ this.mode.elementId,
2457
+ this.currentWorld,
2458
+ this.ctx
2459
+ );
2460
+ if (target) {
2461
+ canvasCtx.save();
2462
+ canvasCtx.strokeStyle = "#2196F3";
2463
+ canvasCtx.lineWidth = 2 / this.ctx.camera.zoom;
2464
+ canvasCtx.setLineDash([]);
2465
+ canvasCtx.strokeRect(target.x, target.y, target.w, target.h);
2466
+ canvasCtx.restore();
2467
+ }
2468
+ }
2082
2469
  }
2083
2470
  updateHoverCursor(world, ctx) {
2084
2471
  const arrowHit = hitTestArrowHandles(world, this._selectedIds, ctx);
@@ -2137,6 +2524,11 @@ var SelectTool = class {
2137
2524
  position: { x, y },
2138
2525
  size: { w, h }
2139
2526
  });
2527
+ const boundArrows = findBoundArrows(this.mode.elementId, ctx.store);
2528
+ for (const ba of boundArrows) {
2529
+ const updates = updateBoundArrow(ba, ctx.store);
2530
+ if (updates) ctx.store.update(ba.id, updates);
2531
+ }
2140
2532
  ctx.requestRender();
2141
2533
  }
2142
2534
  hitTestResizeHandle(world, ctx) {
@@ -2191,6 +2583,7 @@ var SelectTool = class {
2191
2583
  if (!el) continue;
2192
2584
  if (el.type === "arrow") {
2193
2585
  renderArrowHandles(canvasCtx, el, zoom);
2586
+ this.renderBindingHighlights(canvasCtx, el, zoom);
2194
2587
  continue;
2195
2588
  }
2196
2589
  const bounds = this.getElementBounds(el);
@@ -2220,6 +2613,26 @@ var SelectTool = class {
2220
2613
  }
2221
2614
  canvasCtx.restore();
2222
2615
  }
2616
+ renderBindingHighlights(canvasCtx, arrow, zoom) {
2617
+ if (!this.ctx) return;
2618
+ if (!arrow.fromBinding && !arrow.toBinding) return;
2619
+ const pad = SELECTION_PAD / zoom;
2620
+ canvasCtx.save();
2621
+ canvasCtx.strokeStyle = "#2196F3";
2622
+ canvasCtx.lineWidth = 2 / zoom;
2623
+ canvasCtx.setLineDash([]);
2624
+ const drawn = /* @__PURE__ */ new Set();
2625
+ for (const binding of [arrow.fromBinding, arrow.toBinding]) {
2626
+ if (!binding || drawn.has(binding.elementId)) continue;
2627
+ drawn.add(binding.elementId);
2628
+ const target = this.ctx.store.getById(binding.elementId);
2629
+ if (!target) continue;
2630
+ const bounds = getElementBounds(target);
2631
+ if (!bounds) continue;
2632
+ canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
2633
+ }
2634
+ canvasCtx.restore();
2635
+ }
2223
2636
  getMarqueeRect() {
2224
2637
  if (this.mode.type !== "marquee") return null;
2225
2638
  const { start } = this.mode;
@@ -2293,6 +2706,7 @@ var SelectTool = class {
2293
2706
  };
2294
2707
 
2295
2708
  // src/tools/arrow-tool.ts
2709
+ var BIND_THRESHOLD2 = 20;
2296
2710
  var ArrowTool = class {
2297
2711
  name = "arrow";
2298
2712
  drawing = false;
@@ -2300,6 +2714,9 @@ var ArrowTool = class {
2300
2714
  end = { x: 0, y: 0 };
2301
2715
  color;
2302
2716
  width;
2717
+ fromBinding;
2718
+ fromTarget = null;
2719
+ toTarget = null;
2303
2720
  constructor(options = {}) {
2304
2721
  this.color = options.color ?? "#000000";
2305
2722
  this.width = options.width ?? 2;
@@ -2310,12 +2727,34 @@ var ArrowTool = class {
2310
2727
  }
2311
2728
  onPointerDown(state, ctx) {
2312
2729
  this.drawing = true;
2313
- this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2730
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2731
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2732
+ const target = findBindTarget(world, ctx.store, threshold);
2733
+ if (target) {
2734
+ this.start = getElementCenter(target);
2735
+ this.fromBinding = { elementId: target.id };
2736
+ this.fromTarget = target;
2737
+ } else {
2738
+ this.start = world;
2739
+ this.fromBinding = void 0;
2740
+ this.fromTarget = null;
2741
+ }
2314
2742
  this.end = { ...this.start };
2743
+ this.toTarget = null;
2315
2744
  }
2316
2745
  onPointerMove(state, ctx) {
2317
2746
  if (!this.drawing) return;
2318
- this.end = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2747
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2748
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2749
+ const excludeId = this.fromBinding?.elementId;
2750
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2751
+ if (target) {
2752
+ this.end = getElementCenter(target);
2753
+ this.toTarget = target;
2754
+ } else {
2755
+ this.end = world;
2756
+ this.toTarget = null;
2757
+ }
2319
2758
  ctx.requestRender();
2320
2759
  }
2321
2760
  onPointerUp(_state, ctx) {
@@ -2325,16 +2764,40 @@ var ArrowTool = class {
2325
2764
  const arrow = createArrow({
2326
2765
  from: this.start,
2327
2766
  to: this.end,
2767
+ position: this.start,
2328
2768
  color: this.color,
2329
- width: this.width
2769
+ width: this.width,
2770
+ fromBinding: this.fromBinding,
2771
+ toBinding: this.toTarget ? { elementId: this.toTarget.id } : void 0
2330
2772
  });
2331
2773
  ctx.store.add(arrow);
2774
+ this.fromTarget = null;
2775
+ this.toTarget = null;
2332
2776
  ctx.requestRender();
2777
+ ctx.switchTool?.("select");
2333
2778
  }
2334
2779
  renderOverlay(ctx) {
2335
2780
  if (!this.drawing) return;
2336
2781
  if (this.start.x === this.end.x && this.start.y === this.end.y) return;
2337
2782
  ctx.save();
2783
+ if (this.fromTarget) {
2784
+ const bounds = getElementBounds(this.fromTarget);
2785
+ if (bounds) {
2786
+ ctx.strokeStyle = "#2196F3";
2787
+ ctx.lineWidth = 2;
2788
+ ctx.setLineDash([]);
2789
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2790
+ }
2791
+ }
2792
+ if (this.toTarget) {
2793
+ const bounds = getElementBounds(this.toTarget);
2794
+ if (bounds) {
2795
+ ctx.strokeStyle = "#2196F3";
2796
+ ctx.lineWidth = 2;
2797
+ ctx.setLineDash([]);
2798
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2799
+ }
2800
+ }
2338
2801
  ctx.strokeStyle = this.color;
2339
2802
  ctx.lineWidth = this.width;
2340
2803
  ctx.lineCap = "round";
@@ -2394,6 +2857,47 @@ var NoteTool = class {
2394
2857
  }
2395
2858
  };
2396
2859
 
2860
+ // src/tools/text-tool.ts
2861
+ var TextTool = class {
2862
+ name = "text";
2863
+ fontSize;
2864
+ color;
2865
+ textAlign;
2866
+ constructor(options = {}) {
2867
+ this.fontSize = options.fontSize ?? 16;
2868
+ this.color = options.color ?? "#1a1a1a";
2869
+ this.textAlign = options.textAlign ?? "left";
2870
+ }
2871
+ setOptions(options) {
2872
+ if (options.fontSize !== void 0) this.fontSize = options.fontSize;
2873
+ if (options.color !== void 0) this.color = options.color;
2874
+ if (options.textAlign !== void 0) this.textAlign = options.textAlign;
2875
+ }
2876
+ onActivate(ctx) {
2877
+ ctx.setCursor?.("text");
2878
+ }
2879
+ onDeactivate(ctx) {
2880
+ ctx.setCursor?.("default");
2881
+ }
2882
+ onPointerDown(_state, _ctx) {
2883
+ }
2884
+ onPointerMove(_state, _ctx) {
2885
+ }
2886
+ onPointerUp(state, ctx) {
2887
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2888
+ const textEl = createText({
2889
+ position: world,
2890
+ fontSize: this.fontSize,
2891
+ color: this.color,
2892
+ textAlign: this.textAlign
2893
+ });
2894
+ ctx.store.add(textEl);
2895
+ ctx.requestRender();
2896
+ ctx.switchTool?.("select");
2897
+ ctx.editElement?.(textEl.id);
2898
+ }
2899
+ };
2900
+
2397
2901
  // src/tools/image-tool.ts
2398
2902
  var ImageTool = class {
2399
2903
  name = "image";
@@ -2425,7 +2929,7 @@ var ImageTool = class {
2425
2929
  };
2426
2930
 
2427
2931
  // src/index.ts
2428
- var VERSION = "0.2.3";
2932
+ var VERSION = "0.3.1";
2429
2933
  // Annotate the CommonJS export names for ESM import in node:
2430
2934
  0 && (module.exports = {
2431
2935
  AddElementCommand,
@@ -2448,23 +2952,34 @@ var VERSION = "0.2.3";
2448
2952
  PencilTool,
2449
2953
  RemoveElementCommand,
2450
2954
  SelectTool,
2955
+ TextTool,
2451
2956
  ToolManager,
2452
2957
  UpdateElementCommand,
2453
2958
  VERSION,
2454
2959
  Viewport,
2960
+ clearStaleBindings,
2455
2961
  createArrow,
2456
2962
  createHtmlElement,
2457
2963
  createId,
2458
2964
  createImage,
2459
2965
  createNote,
2460
2966
  createStroke,
2967
+ createText,
2461
2968
  exportState,
2969
+ findBindTarget,
2970
+ findBoundArrows,
2462
2971
  getArrowBounds,
2463
2972
  getArrowControlPoint,
2464
2973
  getArrowMidpoint,
2465
2974
  getArrowTangentAngle,
2466
2975
  getBendFromPoint,
2976
+ getEdgeIntersection,
2977
+ getElementBounds,
2978
+ getElementCenter,
2979
+ isBindable,
2467
2980
  isNearBezier,
2468
- parseState
2981
+ parseState,
2982
+ unbindArrow,
2983
+ updateBoundArrow
2469
2984
  });
2470
2985
  //# sourceMappingURL=index.cjs.map