@fieldnotes/core 0.8.11 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  ImageTool: () => ImageTool,
38
38
  InputHandler: () => InputHandler,
39
39
  LayerManager: () => LayerManager,
40
+ MeasureTool: () => MeasureTool,
40
41
  NoteEditor: () => NoteEditor,
41
42
  NoteTool: () => NoteTool,
42
43
  PencilTool: () => PencilTool,
@@ -45,6 +46,7 @@ __export(index_exports, {
45
46
  RemoveLayerCommand: () => RemoveLayerCommand,
46
47
  SelectTool: () => SelectTool,
47
48
  ShapeTool: () => ShapeTool,
49
+ TemplateTool: () => TemplateTool,
48
50
  TextTool: () => TextTool,
49
51
  ToolManager: () => ToolManager,
50
52
  UpdateElementCommand: () => UpdateElementCommand,
@@ -61,7 +63,9 @@ __export(index_exports, {
61
63
  createNote: () => createNote,
62
64
  createShape: () => createShape,
63
65
  createStroke: () => createStroke,
66
+ createTemplate: () => createTemplate,
64
67
  createText: () => createText,
68
+ drawHexPath: () => drawHexPath,
65
69
  exportImage: () => exportImage,
66
70
  exportState: () => exportState,
67
71
  findBindTarget: () => findBindTarget,
@@ -74,10 +78,17 @@ __export(index_exports, {
74
78
  getEdgeIntersection: () => getEdgeIntersection,
75
79
  getElementBounds: () => getElementBounds,
76
80
  getElementCenter: () => getElementCenter,
81
+ getHexCellsInCone: () => getHexCellsInCone,
82
+ getHexCellsInLine: () => getHexCellsInLine,
83
+ getHexCellsInRadius: () => getHexCellsInRadius,
84
+ getHexCellsInSquare: () => getHexCellsInSquare,
85
+ getHexDistance: () => getHexDistance,
77
86
  isBindable: () => isBindable,
78
87
  isNearBezier: () => isNearBezier,
79
88
  parseState: () => parseState,
89
+ smartSnap: () => smartSnap,
80
90
  snapPoint: () => snapPoint,
91
+ snapToHexCenter: () => snapToHexCenter,
81
92
  unbindArrow: () => unbindArrow,
82
93
  updateBoundArrow: () => updateBoundArrow
83
94
  });
@@ -375,6 +386,30 @@ function snapPoint(point, gridSize) {
375
386
  y: Math.round(point.y / gridSize) * gridSize || 0
376
387
  };
377
388
  }
389
+ function snapToHexCenter(point, cellSize, orientation) {
390
+ if (orientation === "pointy") {
391
+ const hexW = Math.sqrt(3) * cellSize;
392
+ const rowH = 1.5 * cellSize;
393
+ const row = Math.round(point.y / rowH);
394
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
395
+ const col = Math.round((point.x - offsetX) / hexW);
396
+ return { x: col * hexW + offsetX || 0, y: row * rowH || 0 };
397
+ } else {
398
+ const hexH = Math.sqrt(3) * cellSize;
399
+ const colW = 1.5 * cellSize;
400
+ const col = Math.round(point.x / colW);
401
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
402
+ const row = Math.round((point.y - offsetY) / hexH);
403
+ return { x: col * colW || 0, y: row * hexH + offsetY || 0 };
404
+ }
405
+ }
406
+ function smartSnap(point, ctx) {
407
+ if (!ctx.snapToGrid || !ctx.gridSize) return point;
408
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
409
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
410
+ }
411
+ return snapPoint(point, ctx.gridSize);
412
+ }
378
413
 
379
414
  // src/core/auto-save.ts
380
415
  var DEFAULT_KEY = "fieldnotes-autosave";
@@ -1027,6 +1062,9 @@ function getElementBounds(element) {
1027
1062
  if (element.type === "arrow") {
1028
1063
  return getArrowBoundsAnalytical(element.from, element.to, element.bend);
1029
1064
  }
1065
+ if (element.type === "template") {
1066
+ return getTemplateBounds(element);
1067
+ }
1030
1068
  return null;
1031
1069
  }
1032
1070
  function getArrowBoundsAnalytical(from, to, bend) {
@@ -1067,6 +1105,62 @@ function getArrowBoundsAnalytical(from, to, bend) {
1067
1105
  }
1068
1106
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1069
1107
  }
1108
+ function getTemplateBounds(el) {
1109
+ const { x: cx, y: cy } = el.position;
1110
+ const r = el.radius;
1111
+ switch (el.templateShape) {
1112
+ case "circle":
1113
+ return { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r };
1114
+ case "square":
1115
+ return { x: cx - r / 2, y: cy - r / 2, w: r, h: r };
1116
+ case "cone": {
1117
+ const halfAngle = Math.atan(0.5);
1118
+ const tipX = cx;
1119
+ const tipY = cy;
1120
+ const leftX = cx + r * Math.cos(el.angle - halfAngle);
1121
+ const leftY = cy + r * Math.sin(el.angle - halfAngle);
1122
+ const rightX = cx + r * Math.cos(el.angle + halfAngle);
1123
+ const rightY = cy + r * Math.sin(el.angle + halfAngle);
1124
+ const farX = cx + r * Math.cos(el.angle);
1125
+ const farY = cy + r * Math.sin(el.angle);
1126
+ const xs = [tipX, leftX, rightX, farX];
1127
+ const ys = [tipY, leftY, rightY, farY];
1128
+ let minX = Infinity;
1129
+ let minY = Infinity;
1130
+ let maxX = -Infinity;
1131
+ let maxY = -Infinity;
1132
+ for (let i = 0; i < xs.length; i++) {
1133
+ const px = xs[i];
1134
+ const py = ys[i];
1135
+ if (px !== void 0 && px < minX) minX = px;
1136
+ if (px !== void 0 && px > maxX) maxX = px;
1137
+ if (py !== void 0 && py < minY) minY = py;
1138
+ if (py !== void 0 && py > maxY) maxY = py;
1139
+ }
1140
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1141
+ }
1142
+ case "line": {
1143
+ const halfW = r / 12;
1144
+ const cos = Math.cos(el.angle);
1145
+ const sin = Math.sin(el.angle);
1146
+ const perpX = -sin * halfW;
1147
+ const perpY = cos * halfW;
1148
+ const x0 = cx + perpX;
1149
+ const y0 = cy + perpY;
1150
+ const x1 = cx + r * cos + perpX;
1151
+ const y1 = cy + r * sin + perpY;
1152
+ const x2 = cx + r * cos - perpX;
1153
+ const y2 = cy + r * sin - perpY;
1154
+ const x3 = cx - perpX;
1155
+ const y3 = cy - perpY;
1156
+ const minX = Math.min(x0, x1, x2, x3);
1157
+ const minY = Math.min(y0, y1, y2, y3);
1158
+ const maxX = Math.max(x0, x1, x2, x3);
1159
+ const maxY = Math.max(y0, y1, y2, y3);
1160
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1161
+ }
1162
+ }
1163
+ }
1070
1164
  function boundsIntersect(a, b) {
1071
1165
  return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
1072
1166
  }
@@ -1587,6 +1681,190 @@ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
1587
1681
  }
1588
1682
  }
1589
1683
 
1684
+ // src/elements/hex-fill.ts
1685
+ function offsetToCube(col, row, orientation) {
1686
+ if (orientation === "pointy") {
1687
+ return { q: col - (row - (row & 1)) / 2, r: row };
1688
+ }
1689
+ return { q: col, r: row - (col - (col & 1)) / 2 };
1690
+ }
1691
+ function cubeToOffset(q, r, orientation) {
1692
+ if (orientation === "pointy") {
1693
+ return { col: q + (r - (r & 1)) / 2, row: r };
1694
+ }
1695
+ return { col: q, row: r + (q - (q & 1)) / 2 };
1696
+ }
1697
+ function offsetToPixel(col, row, cellSize, orientation) {
1698
+ if (orientation === "pointy") {
1699
+ const hexW = Math.sqrt(3) * cellSize;
1700
+ const rowH = 1.5 * cellSize;
1701
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1702
+ return { x: col * hexW + offsetX, y: row * rowH };
1703
+ }
1704
+ const hexH = Math.sqrt(3) * cellSize;
1705
+ const colW = 1.5 * cellSize;
1706
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1707
+ return { x: col * colW, y: row * hexH + offsetY };
1708
+ }
1709
+ function pixelToOffset(x, y, cellSize, orientation) {
1710
+ if (orientation === "pointy") {
1711
+ const hexW = Math.sqrt(3) * cellSize;
1712
+ const rowH = 1.5 * cellSize;
1713
+ const row = Math.round(y / rowH);
1714
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1715
+ return { col: Math.round((x - offsetX) / hexW), row };
1716
+ }
1717
+ const hexH = Math.sqrt(3) * cellSize;
1718
+ const colW = 1.5 * cellSize;
1719
+ const col = Math.round(x / colW);
1720
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1721
+ return { col, row: Math.round((y - offsetY) / hexH) };
1722
+ }
1723
+ function enumerateHexRing(centerQ, centerR, n, orientation, cellSize) {
1724
+ const cells = [];
1725
+ for (let dq = -n; dq <= n; dq++) {
1726
+ const rMin = Math.max(-n, -dq - n);
1727
+ const rMax = Math.min(n, -dq + n);
1728
+ for (let dr = rMin; dr <= rMax; dr++) {
1729
+ const absQ = centerQ + dq;
1730
+ const absR = centerR + dr;
1731
+ const off = cubeToOffset(absQ, absR, orientation);
1732
+ cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
1733
+ }
1734
+ }
1735
+ return cells;
1736
+ }
1737
+ function getHexDistance(a, b, cellSize, orientation) {
1738
+ const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
1739
+ const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
1740
+ const cubeA = offsetToCube(offA.col, offA.row, orientation);
1741
+ const cubeB = offsetToCube(offB.col, offB.row, orientation);
1742
+ const dq = cubeA.q - cubeB.q;
1743
+ const dr = cubeA.r - cubeB.r;
1744
+ const ds = -dq - dr;
1745
+ return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
1746
+ }
1747
+ function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
1748
+ const n = Math.round(radiusCells);
1749
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1750
+ const cube = offsetToCube(off.col, off.row, orientation);
1751
+ if (n <= 0) {
1752
+ return [offsetToPixel(off.col, off.row, cellSize, orientation)];
1753
+ }
1754
+ return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
1755
+ }
1756
+ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
1757
+ const n = Math.round(radiusCells);
1758
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1759
+ const cube = offsetToCube(off.col, off.row, orientation);
1760
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1761
+ if (n <= 0) return [centerPixel];
1762
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1763
+ const step = Math.PI / 3;
1764
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1765
+ const halfAngle = Math.PI / 6 + 1e-6;
1766
+ const cells = [centerPixel];
1767
+ for (let dq = -n; dq <= n; dq++) {
1768
+ const rMin = Math.max(-n, -dq - n);
1769
+ const rMax = Math.min(n, -dq + n);
1770
+ for (let dr = rMin; dr <= rMax; dr++) {
1771
+ if (dq === 0 && dr === 0) continue;
1772
+ const absQ = cube.q + dq;
1773
+ const absR = cube.r + dr;
1774
+ const pixel = offsetToPixel(
1775
+ cubeToOffset(absQ, absR, orientation).col,
1776
+ cubeToOffset(absQ, absR, orientation).row,
1777
+ cellSize,
1778
+ orientation
1779
+ );
1780
+ const dx = pixel.x - centerPixel.x;
1781
+ const dy = pixel.y - centerPixel.y;
1782
+ let diff = Math.atan2(dy, dx) - snappedAngle;
1783
+ if (diff > Math.PI) diff -= 2 * Math.PI;
1784
+ if (diff < -Math.PI) diff += 2 * Math.PI;
1785
+ if (Math.abs(diff) <= halfAngle) {
1786
+ cells.push(pixel);
1787
+ }
1788
+ }
1789
+ }
1790
+ return cells;
1791
+ }
1792
+ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
1793
+ const n = Math.round(radiusCells);
1794
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1795
+ const cube = offsetToCube(off.col, off.row, orientation);
1796
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1797
+ if (n <= 0) return [centerPixel];
1798
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1799
+ const step = Math.PI / 3;
1800
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1801
+ const cos = Math.cos(snappedAngle);
1802
+ const sin = Math.sin(snappedAngle);
1803
+ const snapUnit = Math.sqrt(3) * cellSize;
1804
+ const lineLength = n * snapUnit;
1805
+ const halfWidth = snapUnit * 0.5 + 1e-6;
1806
+ const cells = [];
1807
+ for (let dq = -n; dq <= n; dq++) {
1808
+ const rMin = Math.max(-n, -dq - n);
1809
+ const rMax = Math.min(n, -dq + n);
1810
+ for (let dr = rMin; dr <= rMax; dr++) {
1811
+ const absQ = cube.q + dq;
1812
+ const absR = cube.r + dr;
1813
+ const pixel = offsetToPixel(
1814
+ cubeToOffset(absQ, absR, orientation).col,
1815
+ cubeToOffset(absQ, absR, orientation).row,
1816
+ cellSize,
1817
+ orientation
1818
+ );
1819
+ const dx = pixel.x - centerPixel.x;
1820
+ const dy = pixel.y - centerPixel.y;
1821
+ const along = dx * cos + dy * sin;
1822
+ const perp = Math.abs(-dx * sin + dy * cos);
1823
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
1824
+ cells.push(pixel);
1825
+ }
1826
+ }
1827
+ }
1828
+ return cells;
1829
+ }
1830
+ function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
1831
+ const n = Math.round(radiusCells);
1832
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1833
+ const cube = offsetToCube(off.col, off.row, orientation);
1834
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1835
+ if (n <= 0) return [centerPixel];
1836
+ const snapUnit = Math.sqrt(3) * cellSize;
1837
+ const halfSide = n * snapUnit / 2;
1838
+ const cells = [];
1839
+ for (let dq = -n; dq <= n; dq++) {
1840
+ const rMin = Math.max(-n, -dq - n);
1841
+ const rMax = Math.min(n, -dq + n);
1842
+ for (let dr = rMin; dr <= rMax; dr++) {
1843
+ const absQ = cube.q + dq;
1844
+ const absR = cube.r + dr;
1845
+ const pixel = offsetToPixel(
1846
+ cubeToOffset(absQ, absR, orientation).col,
1847
+ cubeToOffset(absQ, absR, orientation).row,
1848
+ cellSize,
1849
+ orientation
1850
+ );
1851
+ if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
1852
+ cells.push(pixel);
1853
+ }
1854
+ }
1855
+ }
1856
+ return cells;
1857
+ }
1858
+ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
1859
+ const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1860
+ ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
1861
+ for (let i = 1; i < 6; i++) {
1862
+ const a = angleOffset + Math.PI / 3 * i;
1863
+ ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
1864
+ }
1865
+ ctx.closePath();
1866
+ }
1867
+
1590
1868
  // src/elements/element-renderer.ts
1591
1869
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
1592
1870
  var ARROWHEAD_LENGTH = 12;
@@ -1631,6 +1909,9 @@ var ElementRenderer = class {
1631
1909
  case "grid":
1632
1910
  this.renderGrid(ctx, element);
1633
1911
  break;
1912
+ case "template":
1913
+ this.renderTemplate(ctx, element);
1914
+ break;
1634
1915
  }
1635
1916
  }
1636
1917
  renderStroke(ctx, stroke) {
@@ -1818,6 +2099,147 @@ var ElementRenderer = class {
1818
2099
  );
1819
2100
  }
1820
2101
  }
2102
+ renderTemplate(ctx, template) {
2103
+ const grid = this.store?.getElementsByType("grid")[0];
2104
+ if (grid && grid.gridType === "hex") {
2105
+ this.renderHexTemplate(ctx, template, grid.cellSize, grid.hexOrientation);
2106
+ return;
2107
+ }
2108
+ this.renderGeometricTemplate(ctx, template);
2109
+ }
2110
+ renderGeometricTemplate(ctx, template) {
2111
+ const { x: cx, y: cy } = template.position;
2112
+ const r = template.radius;
2113
+ ctx.save();
2114
+ ctx.globalAlpha = template.opacity;
2115
+ ctx.fillStyle = template.fillColor;
2116
+ ctx.strokeStyle = template.strokeColor;
2117
+ ctx.lineWidth = template.strokeWidth;
2118
+ switch (template.templateShape) {
2119
+ case "circle":
2120
+ ctx.beginPath();
2121
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
2122
+ ctx.fill();
2123
+ ctx.stroke();
2124
+ if (template.radiusFeet != null && template.radiusFeet > 0) {
2125
+ this.renderRadiusMarker(ctx, cx, cy, r, template.radiusFeet);
2126
+ }
2127
+ break;
2128
+ case "square":
2129
+ ctx.fillRect(cx - r / 2, cy - r / 2, r, r);
2130
+ ctx.strokeRect(cx - r / 2, cy - r / 2, r, r);
2131
+ break;
2132
+ case "cone": {
2133
+ const halfAngle = Math.atan(0.5);
2134
+ ctx.beginPath();
2135
+ ctx.moveTo(cx, cy);
2136
+ ctx.arc(cx, cy, r, template.angle - halfAngle, template.angle + halfAngle);
2137
+ ctx.closePath();
2138
+ ctx.fill();
2139
+ ctx.stroke();
2140
+ break;
2141
+ }
2142
+ case "line": {
2143
+ const halfW = r / 12;
2144
+ const cos = Math.cos(template.angle);
2145
+ const sin = Math.sin(template.angle);
2146
+ const perpX = -sin * halfW;
2147
+ const perpY = cos * halfW;
2148
+ ctx.beginPath();
2149
+ ctx.moveTo(cx + perpX, cy + perpY);
2150
+ ctx.lineTo(cx + r * cos + perpX, cy + r * sin + perpY);
2151
+ ctx.lineTo(cx + r * cos - perpX, cy + r * sin - perpY);
2152
+ ctx.lineTo(cx - perpX, cy - perpY);
2153
+ ctx.closePath();
2154
+ ctx.fill();
2155
+ ctx.stroke();
2156
+ break;
2157
+ }
2158
+ }
2159
+ ctx.restore();
2160
+ }
2161
+ renderHexTemplate(ctx, template, cellSize, orientation) {
2162
+ const snapUnit = Math.sqrt(3) * cellSize;
2163
+ const radiusCells = template.radius / snapUnit;
2164
+ const center = template.position;
2165
+ let cells;
2166
+ switch (template.templateShape) {
2167
+ case "circle":
2168
+ cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
2169
+ break;
2170
+ case "cone":
2171
+ cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
2172
+ break;
2173
+ case "line":
2174
+ cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
2175
+ break;
2176
+ case "square":
2177
+ cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
2178
+ break;
2179
+ }
2180
+ ctx.save();
2181
+ ctx.globalAlpha = template.opacity;
2182
+ ctx.beginPath();
2183
+ for (const cell of cells) {
2184
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2185
+ }
2186
+ ctx.fillStyle = template.fillColor;
2187
+ ctx.fill();
2188
+ ctx.beginPath();
2189
+ for (const cell of cells) {
2190
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2191
+ }
2192
+ ctx.strokeStyle = template.strokeColor;
2193
+ ctx.lineWidth = template.strokeWidth;
2194
+ ctx.stroke();
2195
+ {
2196
+ ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
2197
+ ctx.beginPath();
2198
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
2199
+ ctx.fillStyle = template.strokeColor;
2200
+ ctx.fill();
2201
+ ctx.strokeStyle = template.strokeColor;
2202
+ ctx.lineWidth = template.strokeWidth;
2203
+ ctx.stroke();
2204
+ }
2205
+ if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
2206
+ const r = template.radius;
2207
+ this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
2208
+ }
2209
+ ctx.restore();
2210
+ }
2211
+ renderRadiusMarker(ctx, cx, cy, r, feet) {
2212
+ const markerColor = ctx.strokeStyle;
2213
+ ctx.save();
2214
+ ctx.globalAlpha = 1;
2215
+ ctx.beginPath();
2216
+ ctx.setLineDash([4, 4]);
2217
+ ctx.strokeStyle = markerColor;
2218
+ ctx.lineWidth = 1.5;
2219
+ ctx.moveTo(cx, cy);
2220
+ ctx.lineTo(cx + r, cy);
2221
+ ctx.stroke();
2222
+ ctx.setLineDash([]);
2223
+ const label = `${Math.round(feet)} ft`;
2224
+ const fontSize = Math.max(10, Math.min(14, r * 0.15));
2225
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
2226
+ ctx.textAlign = "center";
2227
+ ctx.textBaseline = "bottom";
2228
+ const textX = cx + r / 2;
2229
+ const textY = cy - 4;
2230
+ const metrics = ctx.measureText(label);
2231
+ const padX = 4;
2232
+ const padY = 2;
2233
+ const textW = metrics.width + padX * 2;
2234
+ const textH = fontSize + padY * 2;
2235
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
2236
+ ctx.beginPath();
2237
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
2238
+ ctx.fill();
2239
+ ctx.fillStyle = markerColor;
2240
+ ctx.fillText(label, textX, textY - padY);
2241
+ ctx.restore();
2242
+ }
1821
2243
  renderImage(ctx, image) {
1822
2244
  const img = this.getImage(image.src);
1823
2245
  if (!img) return;
@@ -2325,6 +2747,25 @@ function createText(input) {
2325
2747
  textAlign: input.textAlign ?? "left"
2326
2748
  };
2327
2749
  }
2750
+ function createTemplate(input) {
2751
+ return {
2752
+ id: createId("template"),
2753
+ type: "template",
2754
+ position: input.position,
2755
+ zIndex: input.zIndex ?? 0,
2756
+ locked: input.locked ?? false,
2757
+ layerId: input.layerId ?? "",
2758
+ templateShape: input.templateShape,
2759
+ radius: input.radius,
2760
+ angle: input.angle ?? 0,
2761
+ fillColor: input.fillColor ?? "rgba(255, 87, 34, 0.2)",
2762
+ strokeColor: input.strokeColor ?? "#FF5722",
2763
+ strokeWidth: input.strokeWidth ?? 2,
2764
+ opacity: input.opacity ?? 0.6,
2765
+ feetPerCell: input.feetPerCell,
2766
+ radiusFeet: input.radiusFeet
2767
+ };
2768
+ }
2328
2769
 
2329
2770
  // src/canvas/export-image.ts
2330
2771
  function getStrokeBounds(el) {
@@ -2361,6 +2802,11 @@ function getElementRect(el) {
2361
2802
  }
2362
2803
  case "grid":
2363
2804
  return null;
2805
+ case "template": {
2806
+ const bounds = getElementBounds(el);
2807
+ if (!bounds) return null;
2808
+ return bounds;
2809
+ }
2364
2810
  case "note":
2365
2811
  case "image":
2366
2812
  case "html":
@@ -3105,7 +3551,15 @@ var RenderLoop = class {
3105
3551
  ctx.save();
3106
3552
  ctx.scale(dpr, dpr);
3107
3553
  this.renderer.setCanvasSize(cssWidth, cssHeight);
3108
- this.background.render(ctx, this.camera);
3554
+ const hasGridElement = this.store.getElementsByType("grid").length > 0;
3555
+ if (hasGridElement) {
3556
+ ctx.save();
3557
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3558
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
3559
+ ctx.restore();
3560
+ } else {
3561
+ this.background.render(ctx, this.camera);
3562
+ }
3109
3563
  ctx.save();
3110
3564
  ctx.translate(this.camera.position.x, this.camera.position.y);
3111
3565
  ctx.scale(this.camera.zoom, this.camera.zoom);
@@ -3366,16 +3820,19 @@ var Viewport = class {
3366
3820
  });
3367
3821
  this.unsubStore = [
3368
3822
  this.store.on("add", (el) => {
3823
+ if (el.type === "grid") this.syncGridContext();
3369
3824
  this.renderLoop.markLayerDirty(el.layerId);
3370
3825
  this.requestRender();
3371
3826
  }),
3372
3827
  this.store.on("remove", (el) => {
3828
+ if (el.type === "grid") this.syncGridContext();
3373
3829
  this.unbindArrowsFrom(el);
3374
3830
  this.domNodeManager.removeDomNode(el.id);
3375
3831
  this.renderLoop.markLayerDirty(el.layerId);
3376
3832
  this.requestRender();
3377
3833
  }),
3378
3834
  this.store.on("update", ({ previous, current }) => {
3835
+ if (current.type === "grid") this.syncGridContext();
3379
3836
  this.renderLoop.markLayerDirty(current.layerId);
3380
3837
  if (previous.layerId !== current.layerId) {
3381
3838
  this.renderLoop.markLayerDirty(previous.layerId);
@@ -3385,6 +3842,7 @@ var Viewport = class {
3385
3842
  this.store.on("clear", () => {
3386
3843
  this.domNodeManager.clearDomNodes();
3387
3844
  this.renderLoop.markAllLayersDirty();
3845
+ this.syncGridContext();
3388
3846
  this.requestRender();
3389
3847
  })
3390
3848
  ];
@@ -3398,6 +3856,7 @@ var Viewport = class {
3398
3856
  this.observeResize();
3399
3857
  this.syncCanvasSize();
3400
3858
  this.renderLoop.start();
3859
+ this.syncGridContext();
3401
3860
  }
3402
3861
  camera;
3403
3862
  store;
@@ -3714,6 +4173,18 @@ var Viewport = class {
3714
4173
  this.renderLoop.setCanvasSize(rect.width * dpr, rect.height * dpr);
3715
4174
  this.requestRender();
3716
4175
  }
4176
+ syncGridContext() {
4177
+ const grid = this.store.getElementsByType("grid")[0];
4178
+ if (grid) {
4179
+ this.toolContext.gridSize = grid.cellSize;
4180
+ this.toolContext.gridType = grid.gridType;
4181
+ this.toolContext.hexOrientation = grid.hexOrientation;
4182
+ } else {
4183
+ this.toolContext.gridSize = this._gridSize;
4184
+ this.toolContext.gridType = void 0;
4185
+ this.toolContext.hexOrientation = void 0;
4186
+ }
4187
+ }
3717
4188
  observeResize() {
3718
4189
  if (typeof ResizeObserver === "undefined") return;
3719
4190
  this.resizeObserver = new ResizeObserver(() => this.syncCanvasSize());
@@ -4088,7 +4559,7 @@ var SelectTool = class {
4088
4559
  ctx.setCursor?.("default");
4089
4560
  }
4090
4561
  snap(point, ctx) {
4091
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
4562
+ return smartSnap(point, ctx);
4092
4563
  }
4093
4564
  onPointerDown(state, ctx) {
4094
4565
  this.ctx = ctx;
@@ -4105,6 +4576,12 @@ var SelectTool = class {
4105
4576
  ctx.requestRender();
4106
4577
  return;
4107
4578
  }
4579
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4580
+ if (templateResizeHit) {
4581
+ this.mode = { type: "resizing-template", elementId: templateResizeHit };
4582
+ ctx.requestRender();
4583
+ return;
4584
+ }
4108
4585
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4109
4586
  if (resizeHit) {
4110
4587
  const el = ctx.store.getById(resizeHit.elementId);
@@ -4139,6 +4616,11 @@ var SelectTool = class {
4139
4616
  applyArrowHandleDrag(this.mode.handle, this.mode.elementId, world, ctx);
4140
4617
  return;
4141
4618
  }
4619
+ if (this.mode.type === "resizing-template") {
4620
+ ctx.setCursor?.("nwse-resize");
4621
+ this.handleTemplateResize(world, ctx);
4622
+ return;
4623
+ }
4142
4624
  if (this.mode.type === "resizing") {
4143
4625
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
4144
4626
  this.handleResize(world, ctx);
@@ -4162,6 +4644,16 @@ var SelectTool = class {
4162
4644
  from: { x: el.from.x + dx, y: el.from.y + dy },
4163
4645
  to: { x: el.to.x + dx, y: el.to.y + dy }
4164
4646
  });
4647
+ } else if (ctx.gridType && "size" in el) {
4648
+ const centerX = el.position.x + el.size.w / 2 + dx;
4649
+ const centerY = el.position.y + el.size.h / 2 + dy;
4650
+ const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
4651
+ ctx.store.update(id, {
4652
+ position: {
4653
+ x: snappedCenter.x - el.size.w / 2,
4654
+ y: snappedCenter.y - el.size.h / 2
4655
+ }
4656
+ });
4165
4657
  } else {
4166
4658
  ctx.store.update(id, {
4167
4659
  position: { x: el.position.x + dx, y: el.position.y + dy }
@@ -4236,6 +4728,11 @@ var SelectTool = class {
4236
4728
  ctx.setCursor?.(getArrowHandleCursor(arrowHit.handle, false));
4237
4729
  return;
4238
4730
  }
4731
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4732
+ if (templateResizeHit) {
4733
+ ctx.setCursor?.("nwse-resize");
4734
+ return;
4735
+ }
4239
4736
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4240
4737
  if (resizeHit) {
4241
4738
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -4372,6 +4869,24 @@ var SelectTool = class {
4372
4869
  );
4373
4870
  }
4374
4871
  canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
4872
+ } else if (el.type === "template") {
4873
+ canvasCtx.setLineDash([]);
4874
+ canvasCtx.fillStyle = "#ffffff";
4875
+ const hx = bounds.x + bounds.w;
4876
+ const hy = bounds.y + bounds.h;
4877
+ canvasCtx.fillRect(
4878
+ hx - handleWorldSize / 2,
4879
+ hy - handleWorldSize / 2,
4880
+ handleWorldSize,
4881
+ handleWorldSize
4882
+ );
4883
+ canvasCtx.strokeRect(
4884
+ hx - handleWorldSize / 2,
4885
+ hy - handleWorldSize / 2,
4886
+ handleWorldSize,
4887
+ handleWorldSize
4888
+ );
4889
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
4375
4890
  }
4376
4891
  }
4377
4892
  canvasCtx.restore();
@@ -4396,6 +4911,43 @@ var SelectTool = class {
4396
4911
  }
4397
4912
  canvasCtx.restore();
4398
4913
  }
4914
+ hitTestTemplateResizeHandle(world, ctx) {
4915
+ if (this._selectedIds.length === 0) return null;
4916
+ const zoom = ctx.camera.zoom;
4917
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
4918
+ for (const id of this._selectedIds) {
4919
+ const el = ctx.store.getById(id);
4920
+ if (!el || el.type !== "template") continue;
4921
+ const bounds = getElementBounds(el);
4922
+ if (!bounds) continue;
4923
+ const hx = bounds.x + bounds.w;
4924
+ const hy = bounds.y + bounds.h;
4925
+ if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
4926
+ return id;
4927
+ }
4928
+ }
4929
+ return null;
4930
+ }
4931
+ handleTemplateResize(world, ctx) {
4932
+ if (this.mode.type !== "resizing-template") return;
4933
+ const el = ctx.store.getById(this.mode.elementId);
4934
+ if (!el || el.type !== "template" || el.locked) return;
4935
+ const dx = world.x - el.position.x;
4936
+ const dy = world.y - el.position.y;
4937
+ let newRadius = Math.sqrt(dx * dx + dy * dy);
4938
+ if (ctx.snapToGrid && ctx.gridSize && ctx.gridSize > 0) {
4939
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
4940
+ newRadius = Math.max(snapUnit, Math.round(newRadius / snapUnit) * snapUnit);
4941
+ }
4942
+ newRadius = Math.max(MIN_ELEMENT_SIZE, newRadius);
4943
+ const updates = { radius: newRadius };
4944
+ if (el.feetPerCell != null && ctx.gridSize && ctx.gridSize > 0) {
4945
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
4946
+ updates.radiusFeet = newRadius / snapUnit * el.feetPerCell;
4947
+ }
4948
+ ctx.store.update(this.mode.elementId, updates);
4949
+ ctx.requestRender();
4950
+ }
4399
4951
  getMarqueeRect() {
4400
4952
  if (this.mode.type !== "marquee") return null;
4401
4953
  const { start } = this.mode;
@@ -4452,6 +5004,11 @@ var SelectTool = class {
4452
5004
  if (el.type === "arrow") {
4453
5005
  return isNearBezier(point, el.from, el.to, el.bend, 10);
4454
5006
  }
5007
+ if (el.type === "template") {
5008
+ const bounds = getElementBounds(el);
5009
+ if (!bounds) return false;
5010
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
5011
+ }
4455
5012
  return false;
4456
5013
  }
4457
5014
  };
@@ -4509,7 +5066,7 @@ var ArrowTool = class {
4509
5066
  this.fromBinding = { elementId: target.id };
4510
5067
  this.fromTarget = target;
4511
5068
  } else {
4512
- this.start = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
5069
+ this.start = smartSnap(world, ctx);
4513
5070
  this.fromBinding = void 0;
4514
5071
  this.fromTarget = null;
4515
5072
  }
@@ -4527,7 +5084,7 @@ var ArrowTool = class {
4527
5084
  this.end = getElementCenter(target);
4528
5085
  this.toTarget = target;
4529
5086
  } else {
4530
- this.end = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
5087
+ this.end = smartSnap(world, ctx);
4531
5088
  this.toTarget = null;
4532
5089
  }
4533
5090
  ctx.requestRender();
@@ -4640,9 +5197,7 @@ var NoteTool = class {
4640
5197
  }
4641
5198
  onPointerUp(state, ctx) {
4642
5199
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4643
- if (ctx.snapToGrid && ctx.gridSize) {
4644
- world = snapPoint(world, ctx.gridSize);
4645
- }
5200
+ world = smartSnap(world, ctx);
4646
5201
  const note = createNote({
4647
5202
  position: world,
4648
5203
  size: { ...this.size },
@@ -4697,9 +5252,7 @@ var TextTool = class {
4697
5252
  }
4698
5253
  onPointerUp(state, ctx) {
4699
5254
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4700
- if (ctx.snapToGrid && ctx.gridSize) {
4701
- world = snapPoint(world, ctx.gridSize);
4702
- }
5255
+ world = smartSnap(world, ctx);
4703
5256
  const textEl = createText({
4704
5257
  position: world,
4705
5258
  fontSize: this.fontSize,
@@ -4732,8 +5285,12 @@ var ImageTool = class {
4732
5285
  onPointerUp(state, ctx) {
4733
5286
  if (!this.src) return;
4734
5287
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5288
+ const snapped = smartSnap(world, ctx);
4735
5289
  const image = createImage({
4736
- position: world,
5290
+ position: {
5291
+ x: snapped.x - this.size.w / 2,
5292
+ y: snapped.y - this.size.h / 2
5293
+ },
4737
5294
  size: { ...this.size },
4738
5295
  src: this.src
4739
5296
  });
@@ -4870,7 +5427,7 @@ var ShapeTool = class {
4870
5427
  for (const listener of this.optionListeners) listener();
4871
5428
  }
4872
5429
  snap(point, ctx) {
4873
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
5430
+ return smartSnap(point, ctx);
4874
5431
  }
4875
5432
  onKeyDown = (e) => {
4876
5433
  if (e.key === "Shift") this.shiftHeld = true;
@@ -4880,6 +5437,398 @@ var ShapeTool = class {
4880
5437
  };
4881
5438
  };
4882
5439
 
5440
+ // src/tools/measure-tool.ts
5441
+ var MeasureTool = class {
5442
+ name = "measure";
5443
+ start = null;
5444
+ end = null;
5445
+ gridSize = 1;
5446
+ gridType;
5447
+ hexOrientation;
5448
+ feetPerCell;
5449
+ optionListeners = /* @__PURE__ */ new Set();
5450
+ constructor(options = {}) {
5451
+ this.feetPerCell = options.feetPerCell ?? 5;
5452
+ }
5453
+ getOptions() {
5454
+ return { feetPerCell: this.feetPerCell };
5455
+ }
5456
+ setOptions(options) {
5457
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5458
+ this.notifyOptionsChange();
5459
+ }
5460
+ onOptionsChange(listener) {
5461
+ this.optionListeners.add(listener);
5462
+ return () => this.optionListeners.delete(listener);
5463
+ }
5464
+ onPointerDown(state, ctx) {
5465
+ this.gridSize = ctx.gridSize ?? 1;
5466
+ this.gridType = ctx.gridType;
5467
+ this.hexOrientation = ctx.hexOrientation;
5468
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5469
+ this.start = this.snapToGrid(world, ctx);
5470
+ this.end = { ...this.start };
5471
+ }
5472
+ onPointerMove(state, ctx) {
5473
+ if (!this.start) return;
5474
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5475
+ this.end = this.snapToGrid(world, ctx);
5476
+ ctx.requestRender();
5477
+ }
5478
+ onPointerUp(_state, ctx) {
5479
+ if (!this.start) return;
5480
+ this.start = null;
5481
+ this.end = null;
5482
+ ctx.requestRender();
5483
+ }
5484
+ onDeactivate(_ctx) {
5485
+ this.start = null;
5486
+ this.end = null;
5487
+ }
5488
+ getMeasurement() {
5489
+ if (!this.start || !this.end) return null;
5490
+ const dx = this.end.x - this.start.x;
5491
+ const dy = this.end.y - this.start.y;
5492
+ const worldDistance = Math.sqrt(dx * dx + dy * dy);
5493
+ let cells;
5494
+ if (this.gridType === "hex" && this.hexOrientation) {
5495
+ cells = getHexDistance(this.start, this.end, this.gridSize, this.hexOrientation);
5496
+ } else {
5497
+ const snapUnit = this.gridSize;
5498
+ cells = worldDistance / snapUnit;
5499
+ }
5500
+ const feet = cells * this.feetPerCell;
5501
+ return {
5502
+ start: { ...this.start },
5503
+ end: { ...this.end },
5504
+ worldDistance,
5505
+ cells,
5506
+ feet
5507
+ };
5508
+ }
5509
+ renderOverlay(ctx) {
5510
+ const m = this.getMeasurement();
5511
+ if (!m) return;
5512
+ ctx.save();
5513
+ ctx.strokeStyle = "#FF5722";
5514
+ ctx.setLineDash([8, 4]);
5515
+ ctx.lineWidth = 2;
5516
+ ctx.beginPath();
5517
+ ctx.moveTo(m.start.x, m.start.y);
5518
+ ctx.lineTo(m.end.x, m.end.y);
5519
+ ctx.stroke();
5520
+ ctx.setLineDash([]);
5521
+ ctx.fillStyle = "#FF5722";
5522
+ const dotRadius = 4;
5523
+ ctx.beginPath();
5524
+ ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
5525
+ ctx.fill();
5526
+ ctx.beginPath();
5527
+ ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
5528
+ ctx.fill();
5529
+ const label = `${Math.round(m.feet)} ft`;
5530
+ const midX = (m.start.x + m.end.x) / 2;
5531
+ const midY = (m.start.y + m.end.y) / 2;
5532
+ ctx.font = "14px sans-serif";
5533
+ const metrics = ctx.measureText(label);
5534
+ const padX = 6;
5535
+ const padY = 4;
5536
+ const textH = 14;
5537
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
5538
+ ctx.beginPath();
5539
+ ctx.roundRect(
5540
+ midX - metrics.width / 2 - padX,
5541
+ midY - textH / 2 - padY,
5542
+ metrics.width + padX * 2,
5543
+ textH + padY * 2,
5544
+ 4
5545
+ );
5546
+ ctx.fill();
5547
+ ctx.fillStyle = "#FFFFFF";
5548
+ ctx.textAlign = "center";
5549
+ ctx.textBaseline = "middle";
5550
+ ctx.fillText(label, midX, midY);
5551
+ ctx.restore();
5552
+ }
5553
+ snapToGrid(point, ctx) {
5554
+ if (!ctx.gridSize) return point;
5555
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
5556
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
5557
+ }
5558
+ if (ctx.gridType === "square") {
5559
+ return snapPoint(point, ctx.gridSize);
5560
+ }
5561
+ if (ctx.snapToGrid) {
5562
+ return snapPoint(point, ctx.gridSize);
5563
+ }
5564
+ return point;
5565
+ }
5566
+ notifyOptionsChange() {
5567
+ for (const listener of this.optionListeners) listener();
5568
+ }
5569
+ };
5570
+
5571
+ // src/tools/template-tool.ts
5572
+ var TemplateTool = class {
5573
+ name = "template";
5574
+ drawing = false;
5575
+ origin = { x: 0, y: 0 };
5576
+ current = { x: 0, y: 0 };
5577
+ gridSize = 1;
5578
+ gridType;
5579
+ hexOrientation;
5580
+ snapEnabled = false;
5581
+ templateShape;
5582
+ fillColor;
5583
+ strokeColor;
5584
+ strokeWidth;
5585
+ opacity;
5586
+ feetPerCell;
5587
+ optionListeners = /* @__PURE__ */ new Set();
5588
+ constructor(options = {}) {
5589
+ this.templateShape = options.templateShape ?? "circle";
5590
+ this.fillColor = options.fillColor ?? "rgba(255, 87, 34, 0.2)";
5591
+ this.strokeColor = options.strokeColor ?? "#FF5722";
5592
+ this.strokeWidth = options.strokeWidth ?? 2;
5593
+ this.opacity = options.opacity ?? 0.6;
5594
+ this.feetPerCell = options.feetPerCell ?? 5;
5595
+ }
5596
+ getOptions() {
5597
+ return {
5598
+ templateShape: this.templateShape,
5599
+ fillColor: this.fillColor,
5600
+ strokeColor: this.strokeColor,
5601
+ strokeWidth: this.strokeWidth,
5602
+ opacity: this.opacity,
5603
+ feetPerCell: this.feetPerCell
5604
+ };
5605
+ }
5606
+ setOptions(options) {
5607
+ if (options.templateShape !== void 0) this.templateShape = options.templateShape;
5608
+ if (options.fillColor !== void 0) this.fillColor = options.fillColor;
5609
+ if (options.strokeColor !== void 0) this.strokeColor = options.strokeColor;
5610
+ if (options.strokeWidth !== void 0) this.strokeWidth = options.strokeWidth;
5611
+ if (options.opacity !== void 0) this.opacity = options.opacity;
5612
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5613
+ this.notifyOptionsChange();
5614
+ }
5615
+ onOptionsChange(listener) {
5616
+ this.optionListeners.add(listener);
5617
+ return () => this.optionListeners.delete(listener);
5618
+ }
5619
+ onPointerDown(state, ctx) {
5620
+ this.drawing = true;
5621
+ this.gridSize = ctx.gridSize ?? 1;
5622
+ this.gridType = ctx.gridType;
5623
+ this.hexOrientation = ctx.hexOrientation;
5624
+ this.snapEnabled = !!ctx.gridType || (ctx.snapToGrid ?? false);
5625
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5626
+ this.origin = this.snapToGrid(world, ctx);
5627
+ this.current = { ...this.origin };
5628
+ }
5629
+ onPointerMove(state, ctx) {
5630
+ if (!this.drawing) return;
5631
+ this.current = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5632
+ ctx.requestRender();
5633
+ }
5634
+ onPointerUp(_state, ctx) {
5635
+ if (!this.drawing) return;
5636
+ this.drawing = false;
5637
+ const radius = this.computeRadius();
5638
+ if (radius <= 0) return;
5639
+ const angle = this.computeAngle();
5640
+ const gridSize = ctx.gridSize;
5641
+ const snapUnit = gridSize && gridSize > 0 ? ctx.gridType === "hex" ? Math.sqrt(3) * gridSize : gridSize : 0;
5642
+ const cells = snapUnit > 0 ? radius / snapUnit : 0;
5643
+ const radiusFeet = cells * this.feetPerCell;
5644
+ const element = createTemplate({
5645
+ position: { ...this.origin },
5646
+ templateShape: this.templateShape,
5647
+ radius,
5648
+ angle,
5649
+ fillColor: this.fillColor,
5650
+ strokeColor: this.strokeColor,
5651
+ strokeWidth: this.strokeWidth,
5652
+ opacity: this.opacity,
5653
+ feetPerCell: this.feetPerCell,
5654
+ radiusFeet: radiusFeet > 0 ? radiusFeet : void 0,
5655
+ layerId: ctx.activeLayerId ?? ""
5656
+ });
5657
+ ctx.store.add(element);
5658
+ ctx.requestRender();
5659
+ ctx.switchTool?.("select");
5660
+ }
5661
+ onDeactivate(_ctx) {
5662
+ this.drawing = false;
5663
+ this.origin = { x: 0, y: 0 };
5664
+ this.current = { x: 0, y: 0 };
5665
+ }
5666
+ renderOverlay(ctx) {
5667
+ if (!this.drawing) return;
5668
+ const radius = this.computeRadius();
5669
+ if (radius <= 0) return;
5670
+ if (this.gridType === "hex" && this.hexOrientation) {
5671
+ this.renderHexOverlay(ctx, radius);
5672
+ return;
5673
+ }
5674
+ this.renderGeometricOverlay(ctx, radius);
5675
+ }
5676
+ renderGeometricOverlay(ctx, radius) {
5677
+ const cx = this.origin.x;
5678
+ const cy = this.origin.y;
5679
+ const angle = this.computeAngle();
5680
+ ctx.save();
5681
+ ctx.globalAlpha = 0.4;
5682
+ ctx.fillStyle = this.fillColor;
5683
+ ctx.strokeStyle = this.strokeColor;
5684
+ ctx.lineWidth = this.strokeWidth;
5685
+ switch (this.templateShape) {
5686
+ case "circle":
5687
+ ctx.beginPath();
5688
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
5689
+ ctx.fill();
5690
+ ctx.stroke();
5691
+ break;
5692
+ case "square":
5693
+ ctx.fillRect(cx - radius / 2, cy - radius / 2, radius, radius);
5694
+ ctx.strokeRect(cx - radius / 2, cy - radius / 2, radius, radius);
5695
+ break;
5696
+ case "cone": {
5697
+ const halfAngle = Math.atan(0.5);
5698
+ ctx.beginPath();
5699
+ ctx.moveTo(cx, cy);
5700
+ ctx.arc(cx, cy, radius, angle - halfAngle, angle + halfAngle);
5701
+ ctx.closePath();
5702
+ ctx.fill();
5703
+ ctx.stroke();
5704
+ break;
5705
+ }
5706
+ case "line": {
5707
+ const halfW = radius / 12;
5708
+ const cos = Math.cos(angle);
5709
+ const sin = Math.sin(angle);
5710
+ const perpX = -sin * halfW;
5711
+ const perpY = cos * halfW;
5712
+ ctx.beginPath();
5713
+ ctx.moveTo(cx + perpX, cy + perpY);
5714
+ ctx.lineTo(cx + radius * cos + perpX, cy + radius * sin + perpY);
5715
+ ctx.lineTo(cx + radius * cos - perpX, cy + radius * sin - perpY);
5716
+ ctx.lineTo(cx - perpX, cy - perpY);
5717
+ ctx.closePath();
5718
+ ctx.fill();
5719
+ ctx.stroke();
5720
+ break;
5721
+ }
5722
+ }
5723
+ ctx.restore();
5724
+ }
5725
+ renderHexOverlay(ctx, radius) {
5726
+ const orientation = this.hexOrientation;
5727
+ if (!orientation) return;
5728
+ const cellSize = this.gridSize;
5729
+ const snapUnit = Math.sqrt(3) * cellSize;
5730
+ const radiusCells = radius / snapUnit;
5731
+ const angle = this.computeAngle();
5732
+ const center = this.origin;
5733
+ let hexCells;
5734
+ switch (this.templateShape) {
5735
+ case "circle":
5736
+ hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
5737
+ break;
5738
+ case "cone":
5739
+ hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
5740
+ break;
5741
+ case "line":
5742
+ hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
5743
+ break;
5744
+ case "square":
5745
+ hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
5746
+ break;
5747
+ }
5748
+ ctx.save();
5749
+ ctx.globalAlpha = 0.4;
5750
+ ctx.beginPath();
5751
+ for (const cell of hexCells) {
5752
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
5753
+ }
5754
+ ctx.fillStyle = this.fillColor;
5755
+ ctx.fill();
5756
+ ctx.beginPath();
5757
+ for (const cell of hexCells) {
5758
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
5759
+ }
5760
+ ctx.strokeStyle = this.strokeColor;
5761
+ ctx.lineWidth = this.strokeWidth;
5762
+ ctx.stroke();
5763
+ if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
5764
+ ctx.globalAlpha = 0.5;
5765
+ ctx.beginPath();
5766
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
5767
+ ctx.fillStyle = this.strokeColor;
5768
+ ctx.fill();
5769
+ ctx.strokeStyle = this.strokeColor;
5770
+ ctx.lineWidth = this.strokeWidth;
5771
+ ctx.stroke();
5772
+ }
5773
+ if (this.templateShape === "circle") {
5774
+ const feet = radiusCells * this.feetPerCell;
5775
+ if (feet > 0) {
5776
+ ctx.globalAlpha = 1;
5777
+ const label = `${Math.round(feet)} ft`;
5778
+ const fontSize = Math.max(10, Math.min(14, radius * 0.15));
5779
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
5780
+ ctx.textAlign = "center";
5781
+ ctx.textBaseline = "bottom";
5782
+ const textX = center.x;
5783
+ const textY = center.y - 4;
5784
+ const metrics = ctx.measureText(label);
5785
+ const padX = 4;
5786
+ const padY = 2;
5787
+ const textW = metrics.width + padX * 2;
5788
+ const textH = fontSize + padY * 2;
5789
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
5790
+ ctx.beginPath();
5791
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
5792
+ ctx.fill();
5793
+ ctx.fillStyle = this.strokeColor;
5794
+ ctx.fillText(label, textX, textY - padY);
5795
+ }
5796
+ }
5797
+ ctx.restore();
5798
+ }
5799
+ computeRadius() {
5800
+ const dx = this.current.x - this.origin.x;
5801
+ const dy = this.current.y - this.origin.y;
5802
+ const raw = Math.sqrt(dx * dx + dy * dy);
5803
+ if (this.snapEnabled && this.gridSize > 0) {
5804
+ const snapUnit = this.gridType === "hex" ? Math.sqrt(3) * this.gridSize : this.gridSize;
5805
+ return Math.max(snapUnit, Math.round(raw / snapUnit) * snapUnit);
5806
+ }
5807
+ return raw;
5808
+ }
5809
+ computeAngle() {
5810
+ const dx = this.current.x - this.origin.x;
5811
+ const dy = this.current.y - this.origin.y;
5812
+ return Math.atan2(dy, dx);
5813
+ }
5814
+ snapToGrid(point, ctx) {
5815
+ if (!ctx.gridSize) return point;
5816
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
5817
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
5818
+ }
5819
+ if (ctx.gridType === "square") {
5820
+ return snapPoint(point, ctx.gridSize);
5821
+ }
5822
+ if (ctx.snapToGrid) {
5823
+ return snapPoint(point, ctx.gridSize);
5824
+ }
5825
+ return point;
5826
+ }
5827
+ notifyOptionsChange() {
5828
+ for (const listener of this.optionListeners) listener();
5829
+ }
5830
+ };
5831
+
4883
5832
  // src/history/layer-commands.ts
4884
5833
  var CreateLayerCommand = class {
4885
5834
  constructor(manager, layer) {
@@ -4921,7 +5870,7 @@ var UpdateLayerCommand = class {
4921
5870
  };
4922
5871
 
4923
5872
  // src/index.ts
4924
- var VERSION = "0.8.11";
5873
+ var VERSION = "0.9.0";
4925
5874
  // Annotate the CommonJS export names for ESM import in node:
4926
5875
  0 && (module.exports = {
4927
5876
  AddElementCommand,
@@ -4941,6 +5890,7 @@ var VERSION = "0.8.11";
4941
5890
  ImageTool,
4942
5891
  InputHandler,
4943
5892
  LayerManager,
5893
+ MeasureTool,
4944
5894
  NoteEditor,
4945
5895
  NoteTool,
4946
5896
  PencilTool,
@@ -4949,6 +5899,7 @@ var VERSION = "0.8.11";
4949
5899
  RemoveLayerCommand,
4950
5900
  SelectTool,
4951
5901
  ShapeTool,
5902
+ TemplateTool,
4952
5903
  TextTool,
4953
5904
  ToolManager,
4954
5905
  UpdateElementCommand,
@@ -4965,7 +5916,9 @@ var VERSION = "0.8.11";
4965
5916
  createNote,
4966
5917
  createShape,
4967
5918
  createStroke,
5919
+ createTemplate,
4968
5920
  createText,
5921
+ drawHexPath,
4969
5922
  exportImage,
4970
5923
  exportState,
4971
5924
  findBindTarget,
@@ -4978,10 +5931,17 @@ var VERSION = "0.8.11";
4978
5931
  getEdgeIntersection,
4979
5932
  getElementBounds,
4980
5933
  getElementCenter,
5934
+ getHexCellsInCone,
5935
+ getHexCellsInLine,
5936
+ getHexCellsInRadius,
5937
+ getHexCellsInSquare,
5938
+ getHexDistance,
4981
5939
  isBindable,
4982
5940
  isNearBezier,
4983
5941
  parseState,
5942
+ smartSnap,
4984
5943
  snapPoint,
5944
+ snapToHexCenter,
4985
5945
  unbindArrow,
4986
5946
  updateBoundArrow
4987
5947
  });