@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.js CHANGED
@@ -68,8 +68,9 @@ function validateState(data) {
68
68
  validateElement(el);
69
69
  migrateElement(el);
70
70
  }
71
+ cleanBindings(obj["elements"]);
71
72
  }
72
- var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html"]);
73
+ var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text"]);
73
74
  function validateElement(el) {
74
75
  if (!el || typeof el !== "object") {
75
76
  throw new Error("Invalid element: expected an object");
@@ -85,6 +86,20 @@ function validateElement(el) {
85
86
  throw new Error("Invalid element: missing zIndex");
86
87
  }
87
88
  }
89
+ function cleanBindings(elements) {
90
+ const ids = new Set(elements.map((el) => el["id"]));
91
+ for (const el of elements) {
92
+ if (el["type"] !== "arrow") continue;
93
+ const fromBinding = el["fromBinding"];
94
+ if (fromBinding && !ids.has(fromBinding["elementId"])) {
95
+ el["fromBinding"] = void 0;
96
+ }
97
+ const toBinding = el["toBinding"];
98
+ if (toBinding && !ids.has(toBinding["elementId"])) {
99
+ el["toBinding"] = void 0;
100
+ }
101
+ }
102
+ }
88
103
  function migrateElement(obj) {
89
104
  if (obj["type"] === "arrow" && typeof obj["bend"] !== "number") {
90
105
  obj["bend"] = 0;
@@ -678,6 +693,134 @@ function isNearLine(point, a, b, threshold) {
678
693
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
679
694
  }
680
695
 
696
+ // src/elements/arrow-binding.ts
697
+ var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html"]);
698
+ function isBindable(element) {
699
+ return BINDABLE_TYPES.has(element.type);
700
+ }
701
+ function getElementCenter(element) {
702
+ if (!("size" in element)) {
703
+ throw new Error(`getElementCenter: element type "${element.type}" has no size`);
704
+ }
705
+ return {
706
+ x: element.position.x + element.size.w / 2,
707
+ y: element.position.y + element.size.h / 2
708
+ };
709
+ }
710
+ function getElementBounds(element) {
711
+ if (!("size" in element)) return null;
712
+ return {
713
+ x: element.position.x,
714
+ y: element.position.y,
715
+ w: element.size.w,
716
+ h: element.size.h
717
+ };
718
+ }
719
+ function getEdgeIntersection(bounds, outsidePoint) {
720
+ const cx = bounds.x + bounds.w / 2;
721
+ const cy = bounds.y + bounds.h / 2;
722
+ const dx = outsidePoint.x - cx;
723
+ const dy = outsidePoint.y - cy;
724
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
725
+ const halfW = bounds.w / 2;
726
+ const halfH = bounds.h / 2;
727
+ const scaleX = dx !== 0 ? halfW / Math.abs(dx) : Infinity;
728
+ const scaleY = dy !== 0 ? halfH / Math.abs(dy) : Infinity;
729
+ const scale = Math.min(scaleX, scaleY);
730
+ return {
731
+ x: cx + dx * scale,
732
+ y: cy + dy * scale
733
+ };
734
+ }
735
+ function findBindTarget(point, store, threshold, excludeId) {
736
+ let closest = null;
737
+ let closestDist = Infinity;
738
+ for (const el of store.getAll()) {
739
+ if (!isBindable(el)) continue;
740
+ if (excludeId && el.id === excludeId) continue;
741
+ const bounds = getElementBounds(el);
742
+ if (!bounds) continue;
743
+ const dist = distToBounds(point, bounds);
744
+ if (dist <= threshold && dist < closestDist) {
745
+ closest = el;
746
+ closestDist = dist;
747
+ }
748
+ }
749
+ return closest;
750
+ }
751
+ function distToBounds(point, bounds) {
752
+ const clampedX = Math.max(bounds.x, Math.min(point.x, bounds.x + bounds.w));
753
+ const clampedY = Math.max(bounds.y, Math.min(point.y, bounds.y + bounds.h));
754
+ return Math.hypot(point.x - clampedX, point.y - clampedY);
755
+ }
756
+ function findBoundArrows(elementId, store) {
757
+ return store.getElementsByType("arrow").filter((a) => a.fromBinding?.elementId === elementId || a.toBinding?.elementId === elementId);
758
+ }
759
+ function updateBoundArrow(arrow, store) {
760
+ if (!arrow.fromBinding && !arrow.toBinding) return null;
761
+ const updates = {};
762
+ if (arrow.fromBinding) {
763
+ const el = store.getById(arrow.fromBinding.elementId);
764
+ if (el) {
765
+ const center = getElementCenter(el);
766
+ updates.from = center;
767
+ updates.position = center;
768
+ }
769
+ }
770
+ if (arrow.toBinding) {
771
+ const el = store.getById(arrow.toBinding.elementId);
772
+ if (el) {
773
+ updates.to = getElementCenter(el);
774
+ }
775
+ }
776
+ return Object.keys(updates).length > 0 ? updates : null;
777
+ }
778
+ function clearStaleBindings(arrow, store) {
779
+ const updates = {};
780
+ let hasUpdates = false;
781
+ if (arrow.fromBinding && !store.getById(arrow.fromBinding.elementId)) {
782
+ updates.fromBinding = void 0;
783
+ hasUpdates = true;
784
+ }
785
+ if (arrow.toBinding && !store.getById(arrow.toBinding.elementId)) {
786
+ updates.toBinding = void 0;
787
+ hasUpdates = true;
788
+ }
789
+ return hasUpdates ? updates : null;
790
+ }
791
+ function unbindArrow(arrow, store) {
792
+ const updates = {};
793
+ if (arrow.fromBinding) {
794
+ const el = store.getById(arrow.fromBinding.elementId);
795
+ const bounds = el ? getElementBounds(el) : null;
796
+ if (bounds) {
797
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
798
+ const rayTarget = {
799
+ x: arrow.from.x + Math.cos(angle) * 1e3,
800
+ y: arrow.from.y + Math.sin(angle) * 1e3
801
+ };
802
+ const edge = getEdgeIntersection(bounds, rayTarget);
803
+ updates.from = edge;
804
+ updates.position = edge;
805
+ }
806
+ updates.fromBinding = void 0;
807
+ }
808
+ if (arrow.toBinding) {
809
+ const el = store.getById(arrow.toBinding.elementId);
810
+ const bounds = el ? getElementBounds(el) : null;
811
+ if (bounds) {
812
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
813
+ const rayTarget = {
814
+ x: arrow.to.x - Math.cos(angle) * 1e3,
815
+ y: arrow.to.y - Math.sin(angle) * 1e3
816
+ };
817
+ updates.to = getEdgeIntersection(bounds, rayTarget);
818
+ }
819
+ updates.toBinding = void 0;
820
+ }
821
+ return updates;
822
+ }
823
+
681
824
  // src/elements/stroke-smoothing.ts
682
825
  var MIN_PRESSURE_SCALE = 0.2;
683
826
  function pressureToWidth(pressure, baseWidth) {
@@ -752,10 +895,14 @@ function smoothToSegments(points) {
752
895
  }
753
896
 
754
897
  // src/elements/element-renderer.ts
755
- var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html"]);
898
+ var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html", "text"]);
756
899
  var ARROWHEAD_LENGTH = 12;
757
900
  var ARROWHEAD_ANGLE = Math.PI / 6;
758
901
  var ElementRenderer = class {
902
+ store = null;
903
+ setStore(store) {
904
+ this.store = store;
905
+ }
759
906
  isDomElement(element) {
760
907
  return DOM_ELEMENT_TYPES.has(element.type);
761
908
  }
@@ -789,38 +936,76 @@ var ElementRenderer = class {
789
936
  ctx.restore();
790
937
  }
791
938
  renderArrow(ctx, arrow) {
939
+ const { visualFrom, visualTo } = this.getVisualEndpoints(arrow);
792
940
  ctx.save();
793
941
  ctx.strokeStyle = arrow.color;
794
942
  ctx.lineWidth = arrow.width;
795
943
  ctx.lineCap = "round";
944
+ if (arrow.fromBinding || arrow.toBinding) {
945
+ ctx.setLineDash([8, 4]);
946
+ }
796
947
  ctx.beginPath();
797
- ctx.moveTo(arrow.from.x, arrow.from.y);
948
+ ctx.moveTo(visualFrom.x, visualFrom.y);
798
949
  if (arrow.bend !== 0) {
799
950
  const cp = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
800
- ctx.quadraticCurveTo(cp.x, cp.y, arrow.to.x, arrow.to.y);
951
+ ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
801
952
  } else {
802
- ctx.lineTo(arrow.to.x, arrow.to.y);
953
+ ctx.lineTo(visualTo.x, visualTo.y);
803
954
  }
804
955
  ctx.stroke();
805
- this.renderArrowhead(ctx, arrow);
956
+ this.renderArrowhead(ctx, arrow, visualTo);
806
957
  ctx.restore();
807
958
  }
808
- renderArrowhead(ctx, arrow) {
959
+ renderArrowhead(ctx, arrow, tip) {
809
960
  const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
810
961
  ctx.beginPath();
811
- ctx.moveTo(arrow.to.x, arrow.to.y);
962
+ ctx.moveTo(tip.x, tip.y);
812
963
  ctx.lineTo(
813
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
814
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
964
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
965
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
815
966
  );
816
967
  ctx.lineTo(
817
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
818
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
968
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
969
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
819
970
  );
820
971
  ctx.closePath();
821
972
  ctx.fillStyle = arrow.color;
822
973
  ctx.fill();
823
974
  }
975
+ getVisualEndpoints(arrow) {
976
+ let visualFrom = arrow.from;
977
+ let visualTo = arrow.to;
978
+ if (!this.store) return { visualFrom, visualTo };
979
+ if (arrow.fromBinding) {
980
+ const el = this.store.getById(arrow.fromBinding.elementId);
981
+ if (el) {
982
+ const bounds = getElementBounds(el);
983
+ if (bounds) {
984
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
985
+ const rayTarget = {
986
+ x: arrow.from.x + Math.cos(tangentAngle) * 1e3,
987
+ y: arrow.from.y + Math.sin(tangentAngle) * 1e3
988
+ };
989
+ visualFrom = getEdgeIntersection(bounds, rayTarget);
990
+ }
991
+ }
992
+ }
993
+ if (arrow.toBinding) {
994
+ const el = this.store.getById(arrow.toBinding.elementId);
995
+ if (el) {
996
+ const bounds = getElementBounds(el);
997
+ if (bounds) {
998
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
999
+ const rayTarget = {
1000
+ x: arrow.to.x - Math.cos(tangentAngle) * 1e3,
1001
+ y: arrow.to.y - Math.sin(tangentAngle) * 1e3
1002
+ };
1003
+ visualTo = getEdgeIntersection(bounds, rayTarget);
1004
+ }
1005
+ }
1006
+ }
1007
+ return { visualFrom, visualTo };
1008
+ }
824
1009
  };
825
1010
 
826
1011
  // src/elements/note-editor.ts
@@ -831,12 +1016,16 @@ var NoteEditor = class {
831
1016
  keyHandler = null;
832
1017
  pointerHandler = null;
833
1018
  pendingEditId = null;
1019
+ onStopCallback = null;
834
1020
  get isEditing() {
835
1021
  return this.editingId !== null;
836
1022
  }
837
1023
  get editingElementId() {
838
1024
  return this.editingId;
839
1025
  }
1026
+ setOnStop(callback) {
1027
+ this.onStopCallback = callback;
1028
+ }
840
1029
  startEditing(node, elementId, store) {
841
1030
  if (this.editingId === elementId) return;
842
1031
  if (this.editingId) {
@@ -868,6 +1057,9 @@ var NoteEditor = class {
868
1057
  if (this.pointerHandler) {
869
1058
  this.editingNode.removeEventListener("pointerdown", this.pointerHandler);
870
1059
  }
1060
+ if (this.editingId && this.onStopCallback) {
1061
+ this.onStopCallback(this.editingId);
1062
+ }
871
1063
  this.editingId = null;
872
1064
  this.editingNode = null;
873
1065
  this.blurHandler = null;
@@ -1184,7 +1376,7 @@ function createNote(input) {
1184
1376
  };
1185
1377
  }
1186
1378
  function createArrow(input) {
1187
- return {
1379
+ const result = {
1188
1380
  id: createId("arrow"),
1189
1381
  type: "arrow",
1190
1382
  position: input.position ?? { x: 0, y: 0 },
@@ -1196,6 +1388,9 @@ function createArrow(input) {
1196
1388
  color: input.color ?? "#000000",
1197
1389
  width: input.width ?? 2
1198
1390
  };
1391
+ if (input.fromBinding) result.fromBinding = input.fromBinding;
1392
+ if (input.toBinding) result.toBinding = input.toBinding;
1393
+ return result;
1199
1394
  }
1200
1395
  function createImage(input) {
1201
1396
  return {
@@ -1218,6 +1413,20 @@ function createHtmlElement(input) {
1218
1413
  size: input.size
1219
1414
  };
1220
1415
  }
1416
+ function createText(input) {
1417
+ return {
1418
+ id: createId("text"),
1419
+ type: "text",
1420
+ position: input.position,
1421
+ zIndex: input.zIndex ?? 0,
1422
+ locked: input.locked ?? false,
1423
+ size: input.size ?? { w: 200, h: 28 },
1424
+ text: input.text ?? "",
1425
+ fontSize: input.fontSize ?? 16,
1426
+ color: input.color ?? "#1a1a1a",
1427
+ textAlign: input.textAlign ?? "left"
1428
+ };
1429
+ }
1221
1430
 
1222
1431
  // src/canvas/viewport.ts
1223
1432
  var Viewport = class {
@@ -1228,7 +1437,9 @@ var Viewport = class {
1228
1437
  this.store = new ElementStore();
1229
1438
  this.toolManager = new ToolManager();
1230
1439
  this.renderer = new ElementRenderer();
1440
+ this.renderer.setStore(this.store);
1231
1441
  this.noteEditor = new NoteEditor();
1442
+ this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
1232
1443
  this.history = new HistoryStack();
1233
1444
  this.historyRecorder = new HistoryRecorder(this.store, this.history);
1234
1445
  this.wrapper = this.createWrapper();
@@ -1242,7 +1453,7 @@ var Viewport = class {
1242
1453
  store: this.store,
1243
1454
  requestRender: () => this.requestRender(),
1244
1455
  switchTool: (name) => this.toolManager.setTool(name, this.toolContext),
1245
- editElement: (id) => this.startEditingNote(id),
1456
+ editElement: (id) => this.startEditingElement(id),
1246
1457
  setCursor: (cursor) => {
1247
1458
  this.wrapper.style.cursor = cursor;
1248
1459
  }
@@ -1259,7 +1470,10 @@ var Viewport = class {
1259
1470
  });
1260
1471
  this.unsubStore = [
1261
1472
  this.store.on("add", () => this.requestRender()),
1262
- this.store.on("remove", (el) => this.removeDomNode(el.id)),
1473
+ this.store.on("remove", (el) => {
1474
+ this.unbindArrowsFrom(el);
1475
+ this.removeDomNode(el.id);
1476
+ }),
1263
1477
  this.store.on("update", () => this.requestRender()),
1264
1478
  this.store.on("clear", () => this.clearDomNodes())
1265
1479
  ];
@@ -1396,15 +1610,34 @@ var Viewport = class {
1396
1610
  ctx.restore();
1397
1611
  ctx.restore();
1398
1612
  }
1399
- startEditingNote(id) {
1613
+ startEditingElement(id) {
1400
1614
  const element = this.store.getById(id);
1401
- if (!element || element.type !== "note") return;
1615
+ if (!element || element.type !== "note" && element.type !== "text") return;
1402
1616
  this.render();
1403
1617
  const node = this.domNodes.get(id);
1404
1618
  if (node) {
1405
1619
  this.noteEditor.startEditing(node, id, this.store);
1406
1620
  }
1407
1621
  }
1622
+ onTextEditStop(elementId) {
1623
+ const element = this.store.getById(elementId);
1624
+ if (!element || element.type !== "text") return;
1625
+ if (!element.text || element.text.trim() === "") {
1626
+ this.historyRecorder.begin();
1627
+ this.store.remove(elementId);
1628
+ this.historyRecorder.commit();
1629
+ return;
1630
+ }
1631
+ const node = this.domNodes.get(elementId);
1632
+ if (node && "size" in element) {
1633
+ const measuredHeight = node.scrollHeight;
1634
+ if (measuredHeight !== element.size.h) {
1635
+ this.store.update(elementId, {
1636
+ size: { w: element.size.w, h: measuredHeight }
1637
+ });
1638
+ }
1639
+ }
1640
+ }
1408
1641
  onDblClick = (e) => {
1409
1642
  const el = document.elementFromPoint(e.clientX, e.clientY);
1410
1643
  const nodeEl = el?.closest("[data-element-id]");
@@ -1412,8 +1645,8 @@ var Viewport = class {
1412
1645
  const elementId = nodeEl.dataset["elementId"];
1413
1646
  if (elementId) {
1414
1647
  const element = this.store.getById(elementId);
1415
- if (element?.type === "note") {
1416
- this.startEditingNote(elementId);
1648
+ if (element?.type === "note" || element?.type === "text") {
1649
+ this.startEditingElement(elementId);
1417
1650
  return;
1418
1651
  }
1419
1652
  }
@@ -1537,7 +1770,7 @@ var Viewport = class {
1537
1770
  node.addEventListener("dblclick", (e) => {
1538
1771
  e.stopPropagation();
1539
1772
  const id = node.dataset["elementId"];
1540
- if (id) this.startEditingNote(id);
1773
+ if (id) this.startEditingElement(id);
1541
1774
  });
1542
1775
  }
1543
1776
  if (!this.noteEditor.isEditing || this.noteEditor.editingElementId !== element.id) {
@@ -1578,6 +1811,76 @@ var Viewport = class {
1578
1811
  node.appendChild(content);
1579
1812
  }
1580
1813
  }
1814
+ if (element.type === "text") {
1815
+ if (!node.dataset["initialized"]) {
1816
+ node.dataset["initialized"] = "true";
1817
+ Object.assign(node.style, {
1818
+ padding: "2px",
1819
+ fontSize: `${element.fontSize}px`,
1820
+ color: element.color,
1821
+ textAlign: element.textAlign,
1822
+ background: "none",
1823
+ border: "none",
1824
+ boxShadow: "none",
1825
+ overflow: "visible",
1826
+ cursor: "default",
1827
+ userSelect: "none",
1828
+ wordWrap: "break-word",
1829
+ whiteSpace: "pre-wrap",
1830
+ lineHeight: "1.4"
1831
+ });
1832
+ node.textContent = element.text || "";
1833
+ node.addEventListener("dblclick", (e) => {
1834
+ e.stopPropagation();
1835
+ const id = node.dataset["elementId"];
1836
+ if (id) this.startEditingElement(id);
1837
+ });
1838
+ }
1839
+ if (!this.noteEditor.isEditing || this.noteEditor.editingElementId !== element.id) {
1840
+ if (node.textContent !== element.text) {
1841
+ node.textContent = element.text || "";
1842
+ }
1843
+ Object.assign(node.style, {
1844
+ fontSize: `${element.fontSize}px`,
1845
+ color: element.color,
1846
+ textAlign: element.textAlign
1847
+ });
1848
+ }
1849
+ }
1850
+ }
1851
+ unbindArrowsFrom(removedElement) {
1852
+ const boundArrows = findBoundArrows(removedElement.id, this.store);
1853
+ const bounds = getElementBounds(removedElement);
1854
+ for (const arrow of boundArrows) {
1855
+ const updates = {};
1856
+ if (arrow.fromBinding?.elementId === removedElement.id) {
1857
+ updates.fromBinding = void 0;
1858
+ if (bounds) {
1859
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
1860
+ const rayTarget = {
1861
+ x: arrow.from.x + Math.cos(angle) * 1e3,
1862
+ y: arrow.from.y + Math.sin(angle) * 1e3
1863
+ };
1864
+ const edge = getEdgeIntersection(bounds, rayTarget);
1865
+ updates.from = edge;
1866
+ updates.position = edge;
1867
+ }
1868
+ }
1869
+ if (arrow.toBinding?.elementId === removedElement.id) {
1870
+ updates.toBinding = void 0;
1871
+ if (bounds) {
1872
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
1873
+ const rayTarget = {
1874
+ x: arrow.to.x - Math.cos(angle) * 1e3,
1875
+ y: arrow.to.y - Math.sin(angle) * 1e3
1876
+ };
1877
+ updates.to = getEdgeIntersection(bounds, rayTarget);
1878
+ }
1879
+ }
1880
+ if (Object.keys(updates).length > 0) {
1881
+ this.store.update(arrow.id, updates);
1882
+ }
1883
+ }
1581
1884
  }
1582
1885
  removeDomNode(id) {
1583
1886
  this.htmlContent.delete(id);
@@ -1807,6 +2110,7 @@ var EraserTool = class {
1807
2110
  };
1808
2111
 
1809
2112
  // src/tools/arrow-handles.ts
2113
+ var BIND_THRESHOLD = 20;
1810
2114
  var HANDLE_RADIUS = 5;
1811
2115
  var HANDLE_HIT_PADDING = 4;
1812
2116
  var ARROW_HANDLE_CURSORS = {
@@ -1847,18 +2151,44 @@ function hitTestArrowHandles(world, selectedIds, ctx) {
1847
2151
  function applyArrowHandleDrag(handle, elementId, world, ctx) {
1848
2152
  const el = ctx.store.getById(elementId);
1849
2153
  if (!el || el.type !== "arrow") return;
2154
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
1850
2155
  switch (handle) {
1851
- case "start":
1852
- ctx.store.update(elementId, {
1853
- from: { x: world.x, y: world.y },
1854
- position: { x: world.x, y: world.y }
1855
- });
2156
+ case "start": {
2157
+ const excludeId = el.toBinding?.elementId;
2158
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2159
+ if (target) {
2160
+ const center = getElementCenter(target);
2161
+ ctx.store.update(elementId, {
2162
+ from: center,
2163
+ position: center,
2164
+ fromBinding: { elementId: target.id }
2165
+ });
2166
+ } else {
2167
+ ctx.store.update(elementId, {
2168
+ from: { x: world.x, y: world.y },
2169
+ position: { x: world.x, y: world.y },
2170
+ fromBinding: void 0
2171
+ });
2172
+ }
1856
2173
  break;
1857
- case "end":
1858
- ctx.store.update(elementId, {
1859
- to: { x: world.x, y: world.y }
1860
- });
2174
+ }
2175
+ case "end": {
2176
+ const excludeId = el.fromBinding?.elementId;
2177
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2178
+ if (target) {
2179
+ const center = getElementCenter(target);
2180
+ ctx.store.update(elementId, {
2181
+ to: center,
2182
+ toBinding: { elementId: target.id }
2183
+ });
2184
+ } else {
2185
+ ctx.store.update(elementId, {
2186
+ to: { x: world.x, y: world.y },
2187
+ toBinding: void 0
2188
+ });
2189
+ }
1861
2190
  break;
2191
+ }
1862
2192
  case "mid": {
1863
2193
  const bend = getBendFromPoint(el.from, el.to, world);
1864
2194
  ctx.store.update(elementId, { bend });
@@ -1867,6 +2197,16 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
1867
2197
  }
1868
2198
  ctx.requestRender();
1869
2199
  }
2200
+ function getArrowHandleDragTarget(handle, elementId, world, ctx) {
2201
+ if (handle === "mid") return null;
2202
+ const el = ctx.store.getById(elementId);
2203
+ if (!el || el.type !== "arrow") return null;
2204
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
2205
+ const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
2206
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2207
+ if (!target) return null;
2208
+ return getElementBounds(target);
2209
+ }
1870
2210
  function renderArrowHandles(canvasCtx, arrow, zoom) {
1871
2211
  const radius = HANDLE_RADIUS / zoom;
1872
2212
  const handles = getArrowHandlePositions(arrow);
@@ -1977,6 +2317,9 @@ var SelectTool = class {
1977
2317
  const el = ctx.store.getById(id);
1978
2318
  if (!el || el.locked) continue;
1979
2319
  if (el.type === "arrow") {
2320
+ if (el.fromBinding || el.toBinding) {
2321
+ continue;
2322
+ }
1980
2323
  ctx.store.update(id, {
1981
2324
  position: { x: el.position.x + dx, y: el.position.y + dy },
1982
2325
  from: { x: el.from.x + dx, y: el.from.y + dy },
@@ -1988,6 +2331,23 @@ var SelectTool = class {
1988
2331
  });
1989
2332
  }
1990
2333
  }
2334
+ const movedNonArrowIds = /* @__PURE__ */ new Set();
2335
+ for (const id of this._selectedIds) {
2336
+ const el = ctx.store.getById(id);
2337
+ if (el && el.type !== "arrow") movedNonArrowIds.add(id);
2338
+ }
2339
+ if (movedNonArrowIds.size > 0) {
2340
+ const updatedArrows = /* @__PURE__ */ new Set();
2341
+ for (const id of movedNonArrowIds) {
2342
+ const boundArrows = findBoundArrows(id, ctx.store);
2343
+ for (const ba of boundArrows) {
2344
+ if (updatedArrows.has(ba.id)) continue;
2345
+ updatedArrows.add(ba.id);
2346
+ const updates = updateBoundArrow(ba, ctx.store);
2347
+ if (updates) ctx.store.update(ba.id, updates);
2348
+ }
2349
+ }
2350
+ }
1991
2351
  ctx.requestRender();
1992
2352
  return;
1993
2353
  }
@@ -2016,6 +2376,22 @@ var SelectTool = class {
2016
2376
  renderOverlay(canvasCtx) {
2017
2377
  this.renderMarquee(canvasCtx);
2018
2378
  this.renderSelectionBoxes(canvasCtx);
2379
+ if (this.mode.type === "arrow-handle" && this.ctx) {
2380
+ const target = getArrowHandleDragTarget(
2381
+ this.mode.handle,
2382
+ this.mode.elementId,
2383
+ this.currentWorld,
2384
+ this.ctx
2385
+ );
2386
+ if (target) {
2387
+ canvasCtx.save();
2388
+ canvasCtx.strokeStyle = "#2196F3";
2389
+ canvasCtx.lineWidth = 2 / this.ctx.camera.zoom;
2390
+ canvasCtx.setLineDash([]);
2391
+ canvasCtx.strokeRect(target.x, target.y, target.w, target.h);
2392
+ canvasCtx.restore();
2393
+ }
2394
+ }
2019
2395
  }
2020
2396
  updateHoverCursor(world, ctx) {
2021
2397
  const arrowHit = hitTestArrowHandles(world, this._selectedIds, ctx);
@@ -2074,6 +2450,11 @@ var SelectTool = class {
2074
2450
  position: { x, y },
2075
2451
  size: { w, h }
2076
2452
  });
2453
+ const boundArrows = findBoundArrows(this.mode.elementId, ctx.store);
2454
+ for (const ba of boundArrows) {
2455
+ const updates = updateBoundArrow(ba, ctx.store);
2456
+ if (updates) ctx.store.update(ba.id, updates);
2457
+ }
2077
2458
  ctx.requestRender();
2078
2459
  }
2079
2460
  hitTestResizeHandle(world, ctx) {
@@ -2128,6 +2509,7 @@ var SelectTool = class {
2128
2509
  if (!el) continue;
2129
2510
  if (el.type === "arrow") {
2130
2511
  renderArrowHandles(canvasCtx, el, zoom);
2512
+ this.renderBindingHighlights(canvasCtx, el, zoom);
2131
2513
  continue;
2132
2514
  }
2133
2515
  const bounds = this.getElementBounds(el);
@@ -2157,6 +2539,26 @@ var SelectTool = class {
2157
2539
  }
2158
2540
  canvasCtx.restore();
2159
2541
  }
2542
+ renderBindingHighlights(canvasCtx, arrow, zoom) {
2543
+ if (!this.ctx) return;
2544
+ if (!arrow.fromBinding && !arrow.toBinding) return;
2545
+ const pad = SELECTION_PAD / zoom;
2546
+ canvasCtx.save();
2547
+ canvasCtx.strokeStyle = "#2196F3";
2548
+ canvasCtx.lineWidth = 2 / zoom;
2549
+ canvasCtx.setLineDash([]);
2550
+ const drawn = /* @__PURE__ */ new Set();
2551
+ for (const binding of [arrow.fromBinding, arrow.toBinding]) {
2552
+ if (!binding || drawn.has(binding.elementId)) continue;
2553
+ drawn.add(binding.elementId);
2554
+ const target = this.ctx.store.getById(binding.elementId);
2555
+ if (!target) continue;
2556
+ const bounds = getElementBounds(target);
2557
+ if (!bounds) continue;
2558
+ canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
2559
+ }
2560
+ canvasCtx.restore();
2561
+ }
2160
2562
  getMarqueeRect() {
2161
2563
  if (this.mode.type !== "marquee") return null;
2162
2564
  const { start } = this.mode;
@@ -2230,6 +2632,7 @@ var SelectTool = class {
2230
2632
  };
2231
2633
 
2232
2634
  // src/tools/arrow-tool.ts
2635
+ var BIND_THRESHOLD2 = 20;
2233
2636
  var ArrowTool = class {
2234
2637
  name = "arrow";
2235
2638
  drawing = false;
@@ -2237,6 +2640,9 @@ var ArrowTool = class {
2237
2640
  end = { x: 0, y: 0 };
2238
2641
  color;
2239
2642
  width;
2643
+ fromBinding;
2644
+ fromTarget = null;
2645
+ toTarget = null;
2240
2646
  constructor(options = {}) {
2241
2647
  this.color = options.color ?? "#000000";
2242
2648
  this.width = options.width ?? 2;
@@ -2247,12 +2653,34 @@ var ArrowTool = class {
2247
2653
  }
2248
2654
  onPointerDown(state, ctx) {
2249
2655
  this.drawing = true;
2250
- this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2656
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2657
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2658
+ const target = findBindTarget(world, ctx.store, threshold);
2659
+ if (target) {
2660
+ this.start = getElementCenter(target);
2661
+ this.fromBinding = { elementId: target.id };
2662
+ this.fromTarget = target;
2663
+ } else {
2664
+ this.start = world;
2665
+ this.fromBinding = void 0;
2666
+ this.fromTarget = null;
2667
+ }
2251
2668
  this.end = { ...this.start };
2669
+ this.toTarget = null;
2252
2670
  }
2253
2671
  onPointerMove(state, ctx) {
2254
2672
  if (!this.drawing) return;
2255
- this.end = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2673
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2674
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2675
+ const excludeId = this.fromBinding?.elementId;
2676
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2677
+ if (target) {
2678
+ this.end = getElementCenter(target);
2679
+ this.toTarget = target;
2680
+ } else {
2681
+ this.end = world;
2682
+ this.toTarget = null;
2683
+ }
2256
2684
  ctx.requestRender();
2257
2685
  }
2258
2686
  onPointerUp(_state, ctx) {
@@ -2262,16 +2690,40 @@ var ArrowTool = class {
2262
2690
  const arrow = createArrow({
2263
2691
  from: this.start,
2264
2692
  to: this.end,
2693
+ position: this.start,
2265
2694
  color: this.color,
2266
- width: this.width
2695
+ width: this.width,
2696
+ fromBinding: this.fromBinding,
2697
+ toBinding: this.toTarget ? { elementId: this.toTarget.id } : void 0
2267
2698
  });
2268
2699
  ctx.store.add(arrow);
2700
+ this.fromTarget = null;
2701
+ this.toTarget = null;
2269
2702
  ctx.requestRender();
2703
+ ctx.switchTool?.("select");
2270
2704
  }
2271
2705
  renderOverlay(ctx) {
2272
2706
  if (!this.drawing) return;
2273
2707
  if (this.start.x === this.end.x && this.start.y === this.end.y) return;
2274
2708
  ctx.save();
2709
+ if (this.fromTarget) {
2710
+ const bounds = getElementBounds(this.fromTarget);
2711
+ if (bounds) {
2712
+ ctx.strokeStyle = "#2196F3";
2713
+ ctx.lineWidth = 2;
2714
+ ctx.setLineDash([]);
2715
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2716
+ }
2717
+ }
2718
+ if (this.toTarget) {
2719
+ const bounds = getElementBounds(this.toTarget);
2720
+ if (bounds) {
2721
+ ctx.strokeStyle = "#2196F3";
2722
+ ctx.lineWidth = 2;
2723
+ ctx.setLineDash([]);
2724
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2725
+ }
2726
+ }
2275
2727
  ctx.strokeStyle = this.color;
2276
2728
  ctx.lineWidth = this.width;
2277
2729
  ctx.lineCap = "round";
@@ -2331,6 +2783,47 @@ var NoteTool = class {
2331
2783
  }
2332
2784
  };
2333
2785
 
2786
+ // src/tools/text-tool.ts
2787
+ var TextTool = class {
2788
+ name = "text";
2789
+ fontSize;
2790
+ color;
2791
+ textAlign;
2792
+ constructor(options = {}) {
2793
+ this.fontSize = options.fontSize ?? 16;
2794
+ this.color = options.color ?? "#1a1a1a";
2795
+ this.textAlign = options.textAlign ?? "left";
2796
+ }
2797
+ setOptions(options) {
2798
+ if (options.fontSize !== void 0) this.fontSize = options.fontSize;
2799
+ if (options.color !== void 0) this.color = options.color;
2800
+ if (options.textAlign !== void 0) this.textAlign = options.textAlign;
2801
+ }
2802
+ onActivate(ctx) {
2803
+ ctx.setCursor?.("text");
2804
+ }
2805
+ onDeactivate(ctx) {
2806
+ ctx.setCursor?.("default");
2807
+ }
2808
+ onPointerDown(_state, _ctx) {
2809
+ }
2810
+ onPointerMove(_state, _ctx) {
2811
+ }
2812
+ onPointerUp(state, ctx) {
2813
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2814
+ const textEl = createText({
2815
+ position: world,
2816
+ fontSize: this.fontSize,
2817
+ color: this.color,
2818
+ textAlign: this.textAlign
2819
+ });
2820
+ ctx.store.add(textEl);
2821
+ ctx.requestRender();
2822
+ ctx.switchTool?.("select");
2823
+ ctx.editElement?.(textEl.id);
2824
+ }
2825
+ };
2826
+
2334
2827
  // src/tools/image-tool.ts
2335
2828
  var ImageTool = class {
2336
2829
  name = "image";
@@ -2362,7 +2855,7 @@ var ImageTool = class {
2362
2855
  };
2363
2856
 
2364
2857
  // src/index.ts
2365
- var VERSION = "0.2.3";
2858
+ var VERSION = "0.3.1";
2366
2859
  export {
2367
2860
  AddElementCommand,
2368
2861
  ArrowTool,
@@ -2384,23 +2877,34 @@ export {
2384
2877
  PencilTool,
2385
2878
  RemoveElementCommand,
2386
2879
  SelectTool,
2880
+ TextTool,
2387
2881
  ToolManager,
2388
2882
  UpdateElementCommand,
2389
2883
  VERSION,
2390
2884
  Viewport,
2885
+ clearStaleBindings,
2391
2886
  createArrow,
2392
2887
  createHtmlElement,
2393
2888
  createId,
2394
2889
  createImage,
2395
2890
  createNote,
2396
2891
  createStroke,
2892
+ createText,
2397
2893
  exportState,
2894
+ findBindTarget,
2895
+ findBoundArrows,
2398
2896
  getArrowBounds,
2399
2897
  getArrowControlPoint,
2400
2898
  getArrowMidpoint,
2401
2899
  getArrowTangentAngle,
2402
2900
  getBendFromPoint,
2901
+ getEdgeIntersection,
2902
+ getElementBounds,
2903
+ getElementCenter,
2904
+ isBindable,
2403
2905
  isNearBezier,
2404
- parseState
2906
+ parseState,
2907
+ unbindArrow,
2908
+ updateBoundArrow
2405
2909
  };
2406
2910
  //# sourceMappingURL=index.js.map