@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.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", "text"]);
73
+ var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text", "shape"]);
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;
@@ -96,6 +111,9 @@ function migrateElement(obj) {
96
111
  }
97
112
  }
98
113
  }
114
+ if (obj["type"] === "shape" && typeof obj["shape"] !== "string") {
115
+ obj["shape"] = "rectangle";
116
+ }
99
117
  }
100
118
 
101
119
  // src/core/auto-save.ts
@@ -678,6 +696,134 @@ function isNearLine(point, a, b, threshold) {
678
696
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
679
697
  }
680
698
 
699
+ // src/elements/arrow-binding.ts
700
+ var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape"]);
701
+ function isBindable(element) {
702
+ return BINDABLE_TYPES.has(element.type);
703
+ }
704
+ function getElementCenter(element) {
705
+ if (!("size" in element)) {
706
+ throw new Error(`getElementCenter: element type "${element.type}" has no size`);
707
+ }
708
+ return {
709
+ x: element.position.x + element.size.w / 2,
710
+ y: element.position.y + element.size.h / 2
711
+ };
712
+ }
713
+ function getElementBounds(element) {
714
+ if (!("size" in element)) return null;
715
+ return {
716
+ x: element.position.x,
717
+ y: element.position.y,
718
+ w: element.size.w,
719
+ h: element.size.h
720
+ };
721
+ }
722
+ function getEdgeIntersection(bounds, outsidePoint) {
723
+ const cx = bounds.x + bounds.w / 2;
724
+ const cy = bounds.y + bounds.h / 2;
725
+ const dx = outsidePoint.x - cx;
726
+ const dy = outsidePoint.y - cy;
727
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
728
+ const halfW = bounds.w / 2;
729
+ const halfH = bounds.h / 2;
730
+ const scaleX = dx !== 0 ? halfW / Math.abs(dx) : Infinity;
731
+ const scaleY = dy !== 0 ? halfH / Math.abs(dy) : Infinity;
732
+ const scale = Math.min(scaleX, scaleY);
733
+ return {
734
+ x: cx + dx * scale,
735
+ y: cy + dy * scale
736
+ };
737
+ }
738
+ function findBindTarget(point, store, threshold, excludeId) {
739
+ let closest = null;
740
+ let closestDist = Infinity;
741
+ for (const el of store.getAll()) {
742
+ if (!isBindable(el)) continue;
743
+ if (excludeId && el.id === excludeId) continue;
744
+ const bounds = getElementBounds(el);
745
+ if (!bounds) continue;
746
+ const dist = distToBounds(point, bounds);
747
+ if (dist <= threshold && dist < closestDist) {
748
+ closest = el;
749
+ closestDist = dist;
750
+ }
751
+ }
752
+ return closest;
753
+ }
754
+ function distToBounds(point, bounds) {
755
+ const clampedX = Math.max(bounds.x, Math.min(point.x, bounds.x + bounds.w));
756
+ const clampedY = Math.max(bounds.y, Math.min(point.y, bounds.y + bounds.h));
757
+ return Math.hypot(point.x - clampedX, point.y - clampedY);
758
+ }
759
+ function findBoundArrows(elementId, store) {
760
+ return store.getElementsByType("arrow").filter((a) => a.fromBinding?.elementId === elementId || a.toBinding?.elementId === elementId);
761
+ }
762
+ function updateBoundArrow(arrow, store) {
763
+ if (!arrow.fromBinding && !arrow.toBinding) return null;
764
+ const updates = {};
765
+ if (arrow.fromBinding) {
766
+ const el = store.getById(arrow.fromBinding.elementId);
767
+ if (el) {
768
+ const center = getElementCenter(el);
769
+ updates.from = center;
770
+ updates.position = center;
771
+ }
772
+ }
773
+ if (arrow.toBinding) {
774
+ const el = store.getById(arrow.toBinding.elementId);
775
+ if (el) {
776
+ updates.to = getElementCenter(el);
777
+ }
778
+ }
779
+ return Object.keys(updates).length > 0 ? updates : null;
780
+ }
781
+ function clearStaleBindings(arrow, store) {
782
+ const updates = {};
783
+ let hasUpdates = false;
784
+ if (arrow.fromBinding && !store.getById(arrow.fromBinding.elementId)) {
785
+ updates.fromBinding = void 0;
786
+ hasUpdates = true;
787
+ }
788
+ if (arrow.toBinding && !store.getById(arrow.toBinding.elementId)) {
789
+ updates.toBinding = void 0;
790
+ hasUpdates = true;
791
+ }
792
+ return hasUpdates ? updates : null;
793
+ }
794
+ function unbindArrow(arrow, store) {
795
+ const updates = {};
796
+ if (arrow.fromBinding) {
797
+ const el = store.getById(arrow.fromBinding.elementId);
798
+ const bounds = el ? getElementBounds(el) : null;
799
+ if (bounds) {
800
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
801
+ const rayTarget = {
802
+ x: arrow.from.x + Math.cos(angle) * 1e3,
803
+ y: arrow.from.y + Math.sin(angle) * 1e3
804
+ };
805
+ const edge = getEdgeIntersection(bounds, rayTarget);
806
+ updates.from = edge;
807
+ updates.position = edge;
808
+ }
809
+ updates.fromBinding = void 0;
810
+ }
811
+ if (arrow.toBinding) {
812
+ const el = store.getById(arrow.toBinding.elementId);
813
+ const bounds = el ? getElementBounds(el) : null;
814
+ if (bounds) {
815
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
816
+ const rayTarget = {
817
+ x: arrow.to.x - Math.cos(angle) * 1e3,
818
+ y: arrow.to.y - Math.sin(angle) * 1e3
819
+ };
820
+ updates.to = getEdgeIntersection(bounds, rayTarget);
821
+ }
822
+ updates.toBinding = void 0;
823
+ }
824
+ return updates;
825
+ }
826
+
681
827
  // src/elements/stroke-smoothing.ts
682
828
  var MIN_PRESSURE_SCALE = 0.2;
683
829
  function pressureToWidth(pressure, baseWidth) {
@@ -756,6 +902,10 @@ var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html", "text"
756
902
  var ARROWHEAD_LENGTH = 12;
757
903
  var ARROWHEAD_ANGLE = Math.PI / 6;
758
904
  var ElementRenderer = class {
905
+ store = null;
906
+ setStore(store) {
907
+ this.store = store;
908
+ }
759
909
  isDomElement(element) {
760
910
  return DOM_ELEMENT_TYPES.has(element.type);
761
911
  }
@@ -767,6 +917,9 @@ var ElementRenderer = class {
767
917
  case "arrow":
768
918
  this.renderArrow(ctx, element);
769
919
  break;
920
+ case "shape":
921
+ this.renderShape(ctx, element);
922
+ break;
770
923
  }
771
924
  }
772
925
  renderStroke(ctx, stroke) {
@@ -789,38 +942,119 @@ var ElementRenderer = class {
789
942
  ctx.restore();
790
943
  }
791
944
  renderArrow(ctx, arrow) {
945
+ const { visualFrom, visualTo } = this.getVisualEndpoints(arrow);
792
946
  ctx.save();
793
947
  ctx.strokeStyle = arrow.color;
794
948
  ctx.lineWidth = arrow.width;
795
949
  ctx.lineCap = "round";
950
+ if (arrow.fromBinding || arrow.toBinding) {
951
+ ctx.setLineDash([8, 4]);
952
+ }
796
953
  ctx.beginPath();
797
- ctx.moveTo(arrow.from.x, arrow.from.y);
954
+ ctx.moveTo(visualFrom.x, visualFrom.y);
798
955
  if (arrow.bend !== 0) {
799
956
  const cp = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
800
- ctx.quadraticCurveTo(cp.x, cp.y, arrow.to.x, arrow.to.y);
957
+ ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
801
958
  } else {
802
- ctx.lineTo(arrow.to.x, arrow.to.y);
959
+ ctx.lineTo(visualTo.x, visualTo.y);
803
960
  }
804
961
  ctx.stroke();
805
- this.renderArrowhead(ctx, arrow);
962
+ this.renderArrowhead(ctx, arrow, visualTo);
806
963
  ctx.restore();
807
964
  }
808
- renderArrowhead(ctx, arrow) {
965
+ renderArrowhead(ctx, arrow, tip) {
809
966
  const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
810
967
  ctx.beginPath();
811
- ctx.moveTo(arrow.to.x, arrow.to.y);
968
+ ctx.moveTo(tip.x, tip.y);
812
969
  ctx.lineTo(
813
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
814
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
970
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
971
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
815
972
  );
816
973
  ctx.lineTo(
817
- arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
818
- arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
974
+ tip.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
975
+ tip.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
819
976
  );
820
977
  ctx.closePath();
821
978
  ctx.fillStyle = arrow.color;
822
979
  ctx.fill();
823
980
  }
981
+ getVisualEndpoints(arrow) {
982
+ let visualFrom = arrow.from;
983
+ let visualTo = arrow.to;
984
+ if (!this.store) return { visualFrom, visualTo };
985
+ if (arrow.fromBinding) {
986
+ const el = this.store.getById(arrow.fromBinding.elementId);
987
+ if (el) {
988
+ const bounds = getElementBounds(el);
989
+ if (bounds) {
990
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
991
+ const rayTarget = {
992
+ x: arrow.from.x + Math.cos(tangentAngle) * 1e3,
993
+ y: arrow.from.y + Math.sin(tangentAngle) * 1e3
994
+ };
995
+ visualFrom = getEdgeIntersection(bounds, rayTarget);
996
+ }
997
+ }
998
+ }
999
+ if (arrow.toBinding) {
1000
+ const el = this.store.getById(arrow.toBinding.elementId);
1001
+ if (el) {
1002
+ const bounds = getElementBounds(el);
1003
+ if (bounds) {
1004
+ const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
1005
+ const rayTarget = {
1006
+ x: arrow.to.x - Math.cos(tangentAngle) * 1e3,
1007
+ y: arrow.to.y - Math.sin(tangentAngle) * 1e3
1008
+ };
1009
+ visualTo = getEdgeIntersection(bounds, rayTarget);
1010
+ }
1011
+ }
1012
+ }
1013
+ return { visualFrom, visualTo };
1014
+ }
1015
+ renderShape(ctx, shape) {
1016
+ ctx.save();
1017
+ if (shape.fillColor !== "none") {
1018
+ ctx.fillStyle = shape.fillColor;
1019
+ this.fillShapePath(ctx, shape);
1020
+ }
1021
+ if (shape.strokeWidth > 0) {
1022
+ ctx.strokeStyle = shape.strokeColor;
1023
+ ctx.lineWidth = shape.strokeWidth;
1024
+ this.strokeShapePath(ctx, shape);
1025
+ }
1026
+ ctx.restore();
1027
+ }
1028
+ fillShapePath(ctx, shape) {
1029
+ switch (shape.shape) {
1030
+ case "rectangle":
1031
+ ctx.fillRect(shape.position.x, shape.position.y, shape.size.w, shape.size.h);
1032
+ break;
1033
+ case "ellipse": {
1034
+ const cx = shape.position.x + shape.size.w / 2;
1035
+ const cy = shape.position.y + shape.size.h / 2;
1036
+ ctx.beginPath();
1037
+ ctx.ellipse(cx, cy, shape.size.w / 2, shape.size.h / 2, 0, 0, Math.PI * 2);
1038
+ ctx.fill();
1039
+ break;
1040
+ }
1041
+ }
1042
+ }
1043
+ strokeShapePath(ctx, shape) {
1044
+ switch (shape.shape) {
1045
+ case "rectangle":
1046
+ ctx.strokeRect(shape.position.x, shape.position.y, shape.size.w, shape.size.h);
1047
+ break;
1048
+ case "ellipse": {
1049
+ const cx = shape.position.x + shape.size.w / 2;
1050
+ const cy = shape.position.y + shape.size.h / 2;
1051
+ ctx.beginPath();
1052
+ ctx.ellipse(cx, cy, shape.size.w / 2, shape.size.h / 2, 0, 0, Math.PI * 2);
1053
+ ctx.stroke();
1054
+ break;
1055
+ }
1056
+ }
1057
+ }
824
1058
  };
825
1059
 
826
1060
  // src/elements/note-editor.ts
@@ -1191,7 +1425,7 @@ function createNote(input) {
1191
1425
  };
1192
1426
  }
1193
1427
  function createArrow(input) {
1194
- return {
1428
+ const result = {
1195
1429
  id: createId("arrow"),
1196
1430
  type: "arrow",
1197
1431
  position: input.position ?? { x: 0, y: 0 },
@@ -1203,6 +1437,9 @@ function createArrow(input) {
1203
1437
  color: input.color ?? "#000000",
1204
1438
  width: input.width ?? 2
1205
1439
  };
1440
+ if (input.fromBinding) result.fromBinding = input.fromBinding;
1441
+ if (input.toBinding) result.toBinding = input.toBinding;
1442
+ return result;
1206
1443
  }
1207
1444
  function createImage(input) {
1208
1445
  return {
@@ -1225,6 +1462,20 @@ function createHtmlElement(input) {
1225
1462
  size: input.size
1226
1463
  };
1227
1464
  }
1465
+ function createShape(input) {
1466
+ return {
1467
+ id: createId("shape"),
1468
+ type: "shape",
1469
+ position: input.position,
1470
+ zIndex: input.zIndex ?? 0,
1471
+ locked: input.locked ?? false,
1472
+ shape: input.shape ?? "rectangle",
1473
+ size: input.size,
1474
+ strokeColor: input.strokeColor ?? "#000000",
1475
+ strokeWidth: input.strokeWidth ?? 2,
1476
+ fillColor: input.fillColor ?? "none"
1477
+ };
1478
+ }
1228
1479
  function createText(input) {
1229
1480
  return {
1230
1481
  id: createId("text"),
@@ -1249,6 +1500,7 @@ var Viewport = class {
1249
1500
  this.store = new ElementStore();
1250
1501
  this.toolManager = new ToolManager();
1251
1502
  this.renderer = new ElementRenderer();
1503
+ this.renderer.setStore(this.store);
1252
1504
  this.noteEditor = new NoteEditor();
1253
1505
  this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
1254
1506
  this.history = new HistoryStack();
@@ -1281,7 +1533,10 @@ var Viewport = class {
1281
1533
  });
1282
1534
  this.unsubStore = [
1283
1535
  this.store.on("add", () => this.requestRender()),
1284
- this.store.on("remove", (el) => this.removeDomNode(el.id)),
1536
+ this.store.on("remove", (el) => {
1537
+ this.unbindArrowsFrom(el);
1538
+ this.removeDomNode(el.id);
1539
+ }),
1285
1540
  this.store.on("update", () => this.requestRender()),
1286
1541
  this.store.on("clear", () => this.clearDomNodes())
1287
1542
  ];
@@ -1656,6 +1911,40 @@ var Viewport = class {
1656
1911
  }
1657
1912
  }
1658
1913
  }
1914
+ unbindArrowsFrom(removedElement) {
1915
+ const boundArrows = findBoundArrows(removedElement.id, this.store);
1916
+ const bounds = getElementBounds(removedElement);
1917
+ for (const arrow of boundArrows) {
1918
+ const updates = {};
1919
+ if (arrow.fromBinding?.elementId === removedElement.id) {
1920
+ updates.fromBinding = void 0;
1921
+ if (bounds) {
1922
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
1923
+ const rayTarget = {
1924
+ x: arrow.from.x + Math.cos(angle) * 1e3,
1925
+ y: arrow.from.y + Math.sin(angle) * 1e3
1926
+ };
1927
+ const edge = getEdgeIntersection(bounds, rayTarget);
1928
+ updates.from = edge;
1929
+ updates.position = edge;
1930
+ }
1931
+ }
1932
+ if (arrow.toBinding?.elementId === removedElement.id) {
1933
+ updates.toBinding = void 0;
1934
+ if (bounds) {
1935
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
1936
+ const rayTarget = {
1937
+ x: arrow.to.x - Math.cos(angle) * 1e3,
1938
+ y: arrow.to.y - Math.sin(angle) * 1e3
1939
+ };
1940
+ updates.to = getEdgeIntersection(bounds, rayTarget);
1941
+ }
1942
+ }
1943
+ if (Object.keys(updates).length > 0) {
1944
+ this.store.update(arrow.id, updates);
1945
+ }
1946
+ }
1947
+ }
1659
1948
  removeDomNode(id) {
1660
1949
  this.htmlContent.delete(id);
1661
1950
  const node = this.domNodes.get(id);
@@ -1884,6 +2173,7 @@ var EraserTool = class {
1884
2173
  };
1885
2174
 
1886
2175
  // src/tools/arrow-handles.ts
2176
+ var BIND_THRESHOLD = 20;
1887
2177
  var HANDLE_RADIUS = 5;
1888
2178
  var HANDLE_HIT_PADDING = 4;
1889
2179
  var ARROW_HANDLE_CURSORS = {
@@ -1924,18 +2214,44 @@ function hitTestArrowHandles(world, selectedIds, ctx) {
1924
2214
  function applyArrowHandleDrag(handle, elementId, world, ctx) {
1925
2215
  const el = ctx.store.getById(elementId);
1926
2216
  if (!el || el.type !== "arrow") return;
2217
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
1927
2218
  switch (handle) {
1928
- case "start":
1929
- ctx.store.update(elementId, {
1930
- from: { x: world.x, y: world.y },
1931
- position: { x: world.x, y: world.y }
1932
- });
2219
+ case "start": {
2220
+ const excludeId = el.toBinding?.elementId;
2221
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2222
+ if (target) {
2223
+ const center = getElementCenter(target);
2224
+ ctx.store.update(elementId, {
2225
+ from: center,
2226
+ position: center,
2227
+ fromBinding: { elementId: target.id }
2228
+ });
2229
+ } else {
2230
+ ctx.store.update(elementId, {
2231
+ from: { x: world.x, y: world.y },
2232
+ position: { x: world.x, y: world.y },
2233
+ fromBinding: void 0
2234
+ });
2235
+ }
1933
2236
  break;
1934
- case "end":
1935
- ctx.store.update(elementId, {
1936
- to: { x: world.x, y: world.y }
1937
- });
2237
+ }
2238
+ case "end": {
2239
+ const excludeId = el.fromBinding?.elementId;
2240
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2241
+ if (target) {
2242
+ const center = getElementCenter(target);
2243
+ ctx.store.update(elementId, {
2244
+ to: center,
2245
+ toBinding: { elementId: target.id }
2246
+ });
2247
+ } else {
2248
+ ctx.store.update(elementId, {
2249
+ to: { x: world.x, y: world.y },
2250
+ toBinding: void 0
2251
+ });
2252
+ }
1938
2253
  break;
2254
+ }
1939
2255
  case "mid": {
1940
2256
  const bend = getBendFromPoint(el.from, el.to, world);
1941
2257
  ctx.store.update(elementId, { bend });
@@ -1944,6 +2260,16 @@ function applyArrowHandleDrag(handle, elementId, world, ctx) {
1944
2260
  }
1945
2261
  ctx.requestRender();
1946
2262
  }
2263
+ function getArrowHandleDragTarget(handle, elementId, world, ctx) {
2264
+ if (handle === "mid") return null;
2265
+ const el = ctx.store.getById(elementId);
2266
+ if (!el || el.type !== "arrow") return null;
2267
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
2268
+ const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
2269
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2270
+ if (!target) return null;
2271
+ return getElementBounds(target);
2272
+ }
1947
2273
  function renderArrowHandles(canvasCtx, arrow, zoom) {
1948
2274
  const radius = HANDLE_RADIUS / zoom;
1949
2275
  const handles = getArrowHandlePositions(arrow);
@@ -2054,6 +2380,9 @@ var SelectTool = class {
2054
2380
  const el = ctx.store.getById(id);
2055
2381
  if (!el || el.locked) continue;
2056
2382
  if (el.type === "arrow") {
2383
+ if (el.fromBinding || el.toBinding) {
2384
+ continue;
2385
+ }
2057
2386
  ctx.store.update(id, {
2058
2387
  position: { x: el.position.x + dx, y: el.position.y + dy },
2059
2388
  from: { x: el.from.x + dx, y: el.from.y + dy },
@@ -2065,6 +2394,23 @@ var SelectTool = class {
2065
2394
  });
2066
2395
  }
2067
2396
  }
2397
+ const movedNonArrowIds = /* @__PURE__ */ new Set();
2398
+ for (const id of this._selectedIds) {
2399
+ const el = ctx.store.getById(id);
2400
+ if (el && el.type !== "arrow") movedNonArrowIds.add(id);
2401
+ }
2402
+ if (movedNonArrowIds.size > 0) {
2403
+ const updatedArrows = /* @__PURE__ */ new Set();
2404
+ for (const id of movedNonArrowIds) {
2405
+ const boundArrows = findBoundArrows(id, ctx.store);
2406
+ for (const ba of boundArrows) {
2407
+ if (updatedArrows.has(ba.id)) continue;
2408
+ updatedArrows.add(ba.id);
2409
+ const updates = updateBoundArrow(ba, ctx.store);
2410
+ if (updates) ctx.store.update(ba.id, updates);
2411
+ }
2412
+ }
2413
+ }
2068
2414
  ctx.requestRender();
2069
2415
  return;
2070
2416
  }
@@ -2093,6 +2439,22 @@ var SelectTool = class {
2093
2439
  renderOverlay(canvasCtx) {
2094
2440
  this.renderMarquee(canvasCtx);
2095
2441
  this.renderSelectionBoxes(canvasCtx);
2442
+ if (this.mode.type === "arrow-handle" && this.ctx) {
2443
+ const target = getArrowHandleDragTarget(
2444
+ this.mode.handle,
2445
+ this.mode.elementId,
2446
+ this.currentWorld,
2447
+ this.ctx
2448
+ );
2449
+ if (target) {
2450
+ canvasCtx.save();
2451
+ canvasCtx.strokeStyle = "#2196F3";
2452
+ canvasCtx.lineWidth = 2 / this.ctx.camera.zoom;
2453
+ canvasCtx.setLineDash([]);
2454
+ canvasCtx.strokeRect(target.x, target.y, target.w, target.h);
2455
+ canvasCtx.restore();
2456
+ }
2457
+ }
2096
2458
  }
2097
2459
  updateHoverCursor(world, ctx) {
2098
2460
  const arrowHit = hitTestArrowHandles(world, this._selectedIds, ctx);
@@ -2151,6 +2513,11 @@ var SelectTool = class {
2151
2513
  position: { x, y },
2152
2514
  size: { w, h }
2153
2515
  });
2516
+ const boundArrows = findBoundArrows(this.mode.elementId, ctx.store);
2517
+ for (const ba of boundArrows) {
2518
+ const updates = updateBoundArrow(ba, ctx.store);
2519
+ if (updates) ctx.store.update(ba.id, updates);
2520
+ }
2154
2521
  ctx.requestRender();
2155
2522
  }
2156
2523
  hitTestResizeHandle(world, ctx) {
@@ -2205,6 +2572,7 @@ var SelectTool = class {
2205
2572
  if (!el) continue;
2206
2573
  if (el.type === "arrow") {
2207
2574
  renderArrowHandles(canvasCtx, el, zoom);
2575
+ this.renderBindingHighlights(canvasCtx, el, zoom);
2208
2576
  continue;
2209
2577
  }
2210
2578
  const bounds = this.getElementBounds(el);
@@ -2234,6 +2602,26 @@ var SelectTool = class {
2234
2602
  }
2235
2603
  canvasCtx.restore();
2236
2604
  }
2605
+ renderBindingHighlights(canvasCtx, arrow, zoom) {
2606
+ if (!this.ctx) return;
2607
+ if (!arrow.fromBinding && !arrow.toBinding) return;
2608
+ const pad = SELECTION_PAD / zoom;
2609
+ canvasCtx.save();
2610
+ canvasCtx.strokeStyle = "#2196F3";
2611
+ canvasCtx.lineWidth = 2 / zoom;
2612
+ canvasCtx.setLineDash([]);
2613
+ const drawn = /* @__PURE__ */ new Set();
2614
+ for (const binding of [arrow.fromBinding, arrow.toBinding]) {
2615
+ if (!binding || drawn.has(binding.elementId)) continue;
2616
+ drawn.add(binding.elementId);
2617
+ const target = this.ctx.store.getById(binding.elementId);
2618
+ if (!target) continue;
2619
+ const bounds = getElementBounds(target);
2620
+ if (!bounds) continue;
2621
+ canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
2622
+ }
2623
+ canvasCtx.restore();
2624
+ }
2237
2625
  getMarqueeRect() {
2238
2626
  if (this.mode.type !== "marquee") return null;
2239
2627
  const { start } = this.mode;
@@ -2307,6 +2695,7 @@ var SelectTool = class {
2307
2695
  };
2308
2696
 
2309
2697
  // src/tools/arrow-tool.ts
2698
+ var BIND_THRESHOLD2 = 20;
2310
2699
  var ArrowTool = class {
2311
2700
  name = "arrow";
2312
2701
  drawing = false;
@@ -2314,6 +2703,9 @@ var ArrowTool = class {
2314
2703
  end = { x: 0, y: 0 };
2315
2704
  color;
2316
2705
  width;
2706
+ fromBinding;
2707
+ fromTarget = null;
2708
+ toTarget = null;
2317
2709
  constructor(options = {}) {
2318
2710
  this.color = options.color ?? "#000000";
2319
2711
  this.width = options.width ?? 2;
@@ -2324,12 +2716,34 @@ var ArrowTool = class {
2324
2716
  }
2325
2717
  onPointerDown(state, ctx) {
2326
2718
  this.drawing = true;
2327
- this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2719
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2720
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2721
+ const target = findBindTarget(world, ctx.store, threshold);
2722
+ if (target) {
2723
+ this.start = getElementCenter(target);
2724
+ this.fromBinding = { elementId: target.id };
2725
+ this.fromTarget = target;
2726
+ } else {
2727
+ this.start = world;
2728
+ this.fromBinding = void 0;
2729
+ this.fromTarget = null;
2730
+ }
2328
2731
  this.end = { ...this.start };
2732
+ this.toTarget = null;
2329
2733
  }
2330
2734
  onPointerMove(state, ctx) {
2331
2735
  if (!this.drawing) return;
2332
- this.end = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2736
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2737
+ const threshold = BIND_THRESHOLD2 / ctx.camera.zoom;
2738
+ const excludeId = this.fromBinding?.elementId;
2739
+ const target = findBindTarget(world, ctx.store, threshold, excludeId);
2740
+ if (target) {
2741
+ this.end = getElementCenter(target);
2742
+ this.toTarget = target;
2743
+ } else {
2744
+ this.end = world;
2745
+ this.toTarget = null;
2746
+ }
2333
2747
  ctx.requestRender();
2334
2748
  }
2335
2749
  onPointerUp(_state, ctx) {
@@ -2339,16 +2753,40 @@ var ArrowTool = class {
2339
2753
  const arrow = createArrow({
2340
2754
  from: this.start,
2341
2755
  to: this.end,
2756
+ position: this.start,
2342
2757
  color: this.color,
2343
- width: this.width
2758
+ width: this.width,
2759
+ fromBinding: this.fromBinding,
2760
+ toBinding: this.toTarget ? { elementId: this.toTarget.id } : void 0
2344
2761
  });
2345
2762
  ctx.store.add(arrow);
2763
+ this.fromTarget = null;
2764
+ this.toTarget = null;
2346
2765
  ctx.requestRender();
2766
+ ctx.switchTool?.("select");
2347
2767
  }
2348
2768
  renderOverlay(ctx) {
2349
2769
  if (!this.drawing) return;
2350
2770
  if (this.start.x === this.end.x && this.start.y === this.end.y) return;
2351
2771
  ctx.save();
2772
+ if (this.fromTarget) {
2773
+ const bounds = getElementBounds(this.fromTarget);
2774
+ if (bounds) {
2775
+ ctx.strokeStyle = "#2196F3";
2776
+ ctx.lineWidth = 2;
2777
+ ctx.setLineDash([]);
2778
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2779
+ }
2780
+ }
2781
+ if (this.toTarget) {
2782
+ const bounds = getElementBounds(this.toTarget);
2783
+ if (bounds) {
2784
+ ctx.strokeStyle = "#2196F3";
2785
+ ctx.lineWidth = 2;
2786
+ ctx.setLineDash([]);
2787
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
2788
+ }
2789
+ }
2352
2790
  ctx.strokeStyle = this.color;
2353
2791
  ctx.lineWidth = this.width;
2354
2792
  ctx.lineCap = "round";
@@ -2479,8 +2917,123 @@ var ImageTool = class {
2479
2917
  }
2480
2918
  };
2481
2919
 
2920
+ // src/tools/shape-tool.ts
2921
+ var ShapeTool = class {
2922
+ name = "shape";
2923
+ drawing = false;
2924
+ start = { x: 0, y: 0 };
2925
+ end = { x: 0, y: 0 };
2926
+ shiftHeld = false;
2927
+ shape;
2928
+ strokeColor;
2929
+ strokeWidth;
2930
+ fillColor;
2931
+ constructor(options = {}) {
2932
+ this.shape = options.shape ?? "rectangle";
2933
+ this.strokeColor = options.strokeColor ?? "#000000";
2934
+ this.strokeWidth = options.strokeWidth ?? 2;
2935
+ this.fillColor = options.fillColor ?? "none";
2936
+ }
2937
+ setOptions(options) {
2938
+ if (options.shape !== void 0) this.shape = options.shape;
2939
+ if (options.strokeColor !== void 0) this.strokeColor = options.strokeColor;
2940
+ if (options.strokeWidth !== void 0) this.strokeWidth = options.strokeWidth;
2941
+ if (options.fillColor !== void 0) this.fillColor = options.fillColor;
2942
+ }
2943
+ onActivate(_ctx) {
2944
+ if (typeof window !== "undefined") {
2945
+ window.addEventListener("keydown", this.onKeyDown);
2946
+ window.addEventListener("keyup", this.onKeyUp);
2947
+ }
2948
+ }
2949
+ onDeactivate(_ctx) {
2950
+ this.shiftHeld = false;
2951
+ if (typeof window !== "undefined") {
2952
+ window.removeEventListener("keydown", this.onKeyDown);
2953
+ window.removeEventListener("keyup", this.onKeyUp);
2954
+ }
2955
+ }
2956
+ onPointerDown(state, ctx) {
2957
+ this.drawing = true;
2958
+ this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2959
+ this.end = { ...this.start };
2960
+ }
2961
+ onPointerMove(state, ctx) {
2962
+ if (!this.drawing) return;
2963
+ this.end = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2964
+ ctx.requestRender();
2965
+ }
2966
+ onPointerUp(_state, ctx) {
2967
+ if (!this.drawing) return;
2968
+ this.drawing = false;
2969
+ const { position, size } = this.computeRect();
2970
+ if (size.w === 0 || size.h === 0) return;
2971
+ const shape = createShape({
2972
+ position,
2973
+ size,
2974
+ shape: this.shape,
2975
+ strokeColor: this.strokeColor,
2976
+ strokeWidth: this.strokeWidth,
2977
+ fillColor: this.fillColor
2978
+ });
2979
+ ctx.store.add(shape);
2980
+ ctx.requestRender();
2981
+ ctx.switchTool?.("select");
2982
+ }
2983
+ renderOverlay(ctx) {
2984
+ if (!this.drawing) return;
2985
+ const { position, size } = this.computeRect();
2986
+ if (size.w === 0 && size.h === 0) return;
2987
+ ctx.save();
2988
+ ctx.globalAlpha = 0.5;
2989
+ ctx.strokeStyle = this.strokeColor;
2990
+ ctx.lineWidth = this.strokeWidth;
2991
+ if (this.fillColor !== "none") {
2992
+ ctx.fillStyle = this.fillColor;
2993
+ }
2994
+ switch (this.shape) {
2995
+ case "rectangle":
2996
+ if (this.fillColor !== "none") {
2997
+ ctx.fillRect(position.x, position.y, size.w, size.h);
2998
+ }
2999
+ ctx.strokeRect(position.x, position.y, size.w, size.h);
3000
+ break;
3001
+ case "ellipse": {
3002
+ const cx = position.x + size.w / 2;
3003
+ const cy = position.y + size.h / 2;
3004
+ ctx.beginPath();
3005
+ ctx.ellipse(cx, cy, size.w / 2, size.h / 2, 0, 0, Math.PI * 2);
3006
+ if (this.fillColor !== "none") ctx.fill();
3007
+ ctx.stroke();
3008
+ break;
3009
+ }
3010
+ }
3011
+ ctx.restore();
3012
+ }
3013
+ computeRect() {
3014
+ let x = Math.min(this.start.x, this.end.x);
3015
+ let y = Math.min(this.start.y, this.end.y);
3016
+ let w = Math.abs(this.end.x - this.start.x);
3017
+ let h = Math.abs(this.end.y - this.start.y);
3018
+ if (this.shiftHeld) {
3019
+ const side = Math.max(w, h);
3020
+ w = side;
3021
+ h = side;
3022
+ x = this.end.x >= this.start.x ? this.start.x : this.start.x - side;
3023
+ y = this.end.y >= this.start.y ? this.start.y : this.start.y - side;
3024
+ }
3025
+ return { position: { x, y }, size: { w, h } };
3026
+ }
3027
+ onKeyDown = (e) => {
3028
+ if (e.key === "Shift") this.shiftHeld = true;
3029
+ };
3030
+ onKeyUp = (e) => {
3031
+ if (e.key === "Shift") this.shiftHeld = false;
3032
+ };
3033
+ };
3034
+
2482
3035
  // src/index.ts
2483
- var VERSION = "0.3.0";
3036
+ var VERSION = "0.4.0";
2484
3037
  export {
2485
3038
  AddElementCommand,
2486
3039
  ArrowTool,
@@ -2502,25 +3055,36 @@ export {
2502
3055
  PencilTool,
2503
3056
  RemoveElementCommand,
2504
3057
  SelectTool,
3058
+ ShapeTool,
2505
3059
  TextTool,
2506
3060
  ToolManager,
2507
3061
  UpdateElementCommand,
2508
3062
  VERSION,
2509
3063
  Viewport,
3064
+ clearStaleBindings,
2510
3065
  createArrow,
2511
3066
  createHtmlElement,
2512
3067
  createId,
2513
3068
  createImage,
2514
3069
  createNote,
3070
+ createShape,
2515
3071
  createStroke,
2516
3072
  createText,
2517
3073
  exportState,
3074
+ findBindTarget,
3075
+ findBoundArrows,
2518
3076
  getArrowBounds,
2519
3077
  getArrowControlPoint,
2520
3078
  getArrowMidpoint,
2521
3079
  getArrowTangentAngle,
2522
3080
  getBendFromPoint,
3081
+ getEdgeIntersection,
3082
+ getElementBounds,
3083
+ getElementCenter,
3084
+ isBindable,
2523
3085
  isNearBezier,
2524
- parseState
3086
+ parseState,
3087
+ unbindArrow,
3088
+ updateBoundArrow
2525
3089
  };
2526
3090
  //# sourceMappingURL=index.js.map