@fieldnotes/core 0.8.11 → 0.10.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
@@ -169,6 +169,120 @@ var Quadtree = class {
169
169
  }
170
170
  };
171
171
 
172
+ // src/elements/note-sanitizer.ts
173
+ var BOLD_TAGS = /* @__PURE__ */ new Set(["b", "strong"]);
174
+ var ITALIC_TAGS = /* @__PURE__ */ new Set(["i", "em"]);
175
+ var UNDERLINE_TAGS = /* @__PURE__ */ new Set(["u"]);
176
+ var STRIKE_TAGS = /* @__PURE__ */ new Set(["s", "strike", "del"]);
177
+ var BLOCK_TAGS = /* @__PURE__ */ new Set(["div"]);
178
+ function parseStyledRuns(html, baseFontSize) {
179
+ if (!html) return [];
180
+ const doc = new DOMParser().parseFromString(html, "text/html");
181
+ const runs = [];
182
+ const baseStyle = {
183
+ bold: false,
184
+ italic: false,
185
+ underline: false,
186
+ strikethrough: false,
187
+ fontSize: baseFontSize
188
+ };
189
+ walkNodes(doc.body, baseStyle, runs);
190
+ return runs;
191
+ }
192
+ function walkNodes(node, style, runs) {
193
+ for (const child of Array.from(node.childNodes)) {
194
+ if (child.nodeType === Node.TEXT_NODE) {
195
+ const text = child.textContent ?? "";
196
+ if (text) {
197
+ runs.push({ text, ...style });
198
+ }
199
+ continue;
200
+ }
201
+ if (child.nodeType !== Node.ELEMENT_NODE) continue;
202
+ const el = child;
203
+ const tag = el.tagName.toLowerCase();
204
+ if (tag === "br") {
205
+ runs.push({ text: "\n", ...style });
206
+ continue;
207
+ }
208
+ if (BLOCK_TAGS.has(tag) && runs.length > 0) {
209
+ const lastRun = runs[runs.length - 1];
210
+ if (lastRun && !lastRun.text.endsWith("\n")) {
211
+ runs.push({ text: "\n", ...style });
212
+ }
213
+ }
214
+ const childStyle = { ...style };
215
+ if (BOLD_TAGS.has(tag)) childStyle.bold = true;
216
+ if (ITALIC_TAGS.has(tag)) childStyle.italic = true;
217
+ if (UNDERLINE_TAGS.has(tag)) childStyle.underline = true;
218
+ if (STRIKE_TAGS.has(tag)) childStyle.strikethrough = true;
219
+ if (tag === "span") {
220
+ const fontSize = el.style.fontSize;
221
+ if (fontSize) {
222
+ childStyle.fontSize = parseInt(fontSize, 10) || style.fontSize;
223
+ }
224
+ }
225
+ walkNodes(el, childStyle, runs);
226
+ }
227
+ }
228
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
229
+ "b",
230
+ "strong",
231
+ "i",
232
+ "em",
233
+ "u",
234
+ "s",
235
+ "strike",
236
+ "del",
237
+ "span",
238
+ "br",
239
+ "div"
240
+ ]);
241
+ function sanitizeNoteHtml(html) {
242
+ if (!html) return "";
243
+ const doc = new DOMParser().parseFromString(html, "text/html");
244
+ sanitizeNode(doc.body);
245
+ return doc.body.innerHTML;
246
+ }
247
+ function sanitizeNode(node) {
248
+ const children = Array.from(node.childNodes);
249
+ for (const child of children) {
250
+ if (child.nodeType === Node.TEXT_NODE) continue;
251
+ if (child.nodeType !== Node.ELEMENT_NODE) {
252
+ child.remove();
253
+ continue;
254
+ }
255
+ const el = child;
256
+ const tag = el.tagName.toLowerCase();
257
+ if (!ALLOWED_TAGS.has(tag)) {
258
+ const fragment = document.createDocumentFragment();
259
+ while (el.firstChild) {
260
+ fragment.appendChild(el.firstChild);
261
+ }
262
+ node.replaceChild(fragment, el);
263
+ sanitizeNode(node);
264
+ return;
265
+ }
266
+ sanitizeAttributes(el, tag);
267
+ sanitizeNode(el);
268
+ }
269
+ }
270
+ function sanitizeAttributes(el, tag) {
271
+ const attrs = Array.from(el.attributes);
272
+ for (const attr of attrs) {
273
+ if (tag === "span" && attr.name === "style") {
274
+ const fontSize = el.style.fontSize;
275
+ if (fontSize) {
276
+ el.setAttribute("style", `font-size: ${fontSize};`);
277
+ } else {
278
+ el.removeAttribute("style");
279
+ }
280
+ continue;
281
+ }
282
+ el.removeAttribute(attr.name);
283
+ }
284
+ }
285
+
172
286
  // src/core/state-serializer.ts
173
287
  var CURRENT_VERSION = 2;
174
288
  function exportState(elements, camera, layers = []) {
@@ -231,7 +345,17 @@ function validateState(data) {
231
345
  ];
232
346
  }
233
347
  }
234
- var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text", "shape", "grid"]);
348
+ var VALID_TYPES = /* @__PURE__ */ new Set([
349
+ "stroke",
350
+ "note",
351
+ "arrow",
352
+ "image",
353
+ "html",
354
+ "text",
355
+ "shape",
356
+ "grid",
357
+ "template"
358
+ ]);
235
359
  function validateElement(el) {
236
360
  if (!el || typeof el !== "object") {
237
361
  throw new Error("Invalid element: expected an object");
@@ -281,6 +405,9 @@ function migrateElement(obj) {
281
405
  if (obj["type"] === "note" && typeof obj["textColor"] !== "string") {
282
406
  obj["textColor"] = "#000000";
283
407
  }
408
+ if (obj["type"] === "note" && typeof obj["text"] === "string") {
409
+ obj["text"] = sanitizeNoteHtml(obj["text"]);
410
+ }
284
411
  }
285
412
 
286
413
  // src/core/snap.ts
@@ -290,6 +417,30 @@ function snapPoint(point, gridSize) {
290
417
  y: Math.round(point.y / gridSize) * gridSize || 0
291
418
  };
292
419
  }
420
+ function snapToHexCenter(point, cellSize, orientation) {
421
+ if (orientation === "pointy") {
422
+ const hexW = Math.sqrt(3) * cellSize;
423
+ const rowH = 1.5 * cellSize;
424
+ const row = Math.round(point.y / rowH);
425
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
426
+ const col = Math.round((point.x - offsetX) / hexW);
427
+ return { x: col * hexW + offsetX || 0, y: row * rowH || 0 };
428
+ } else {
429
+ const hexH = Math.sqrt(3) * cellSize;
430
+ const colW = 1.5 * cellSize;
431
+ const col = Math.round(point.x / colW);
432
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
433
+ const row = Math.round((point.y - offsetY) / hexH);
434
+ return { x: col * colW || 0, y: row * hexH + offsetY || 0 };
435
+ }
436
+ }
437
+ function smartSnap(point, ctx) {
438
+ if (!ctx.snapToGrid || !ctx.gridSize) return point;
439
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
440
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
441
+ }
442
+ return snapPoint(point, ctx.gridSize);
443
+ }
293
444
 
294
445
  // src/core/auto-save.ts
295
446
  var DEFAULT_KEY = "fieldnotes-autosave";
@@ -942,6 +1093,9 @@ function getElementBounds(element) {
942
1093
  if (element.type === "arrow") {
943
1094
  return getArrowBoundsAnalytical(element.from, element.to, element.bend);
944
1095
  }
1096
+ if (element.type === "template") {
1097
+ return getTemplateBounds(element);
1098
+ }
945
1099
  return null;
946
1100
  }
947
1101
  function getArrowBoundsAnalytical(from, to, bend) {
@@ -982,6 +1136,62 @@ function getArrowBoundsAnalytical(from, to, bend) {
982
1136
  }
983
1137
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
984
1138
  }
1139
+ function getTemplateBounds(el) {
1140
+ const { x: cx, y: cy } = el.position;
1141
+ const r = el.radius;
1142
+ switch (el.templateShape) {
1143
+ case "circle":
1144
+ return { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r };
1145
+ case "square":
1146
+ return { x: cx - r / 2, y: cy - r / 2, w: r, h: r };
1147
+ case "cone": {
1148
+ const halfAngle = Math.atan(0.5);
1149
+ const tipX = cx;
1150
+ const tipY = cy;
1151
+ const leftX = cx + r * Math.cos(el.angle - halfAngle);
1152
+ const leftY = cy + r * Math.sin(el.angle - halfAngle);
1153
+ const rightX = cx + r * Math.cos(el.angle + halfAngle);
1154
+ const rightY = cy + r * Math.sin(el.angle + halfAngle);
1155
+ const farX = cx + r * Math.cos(el.angle);
1156
+ const farY = cy + r * Math.sin(el.angle);
1157
+ const xs = [tipX, leftX, rightX, farX];
1158
+ const ys = [tipY, leftY, rightY, farY];
1159
+ let minX = Infinity;
1160
+ let minY = Infinity;
1161
+ let maxX = -Infinity;
1162
+ let maxY = -Infinity;
1163
+ for (let i = 0; i < xs.length; i++) {
1164
+ const px = xs[i];
1165
+ const py = ys[i];
1166
+ if (px !== void 0 && px < minX) minX = px;
1167
+ if (px !== void 0 && px > maxX) maxX = px;
1168
+ if (py !== void 0 && py < minY) minY = py;
1169
+ if (py !== void 0 && py > maxY) maxY = py;
1170
+ }
1171
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1172
+ }
1173
+ case "line": {
1174
+ const halfW = r / 12;
1175
+ const cos = Math.cos(el.angle);
1176
+ const sin = Math.sin(el.angle);
1177
+ const perpX = -sin * halfW;
1178
+ const perpY = cos * halfW;
1179
+ const x0 = cx + perpX;
1180
+ const y0 = cy + perpY;
1181
+ const x1 = cx + r * cos + perpX;
1182
+ const y1 = cy + r * sin + perpY;
1183
+ const x2 = cx + r * cos - perpX;
1184
+ const y2 = cy + r * sin - perpY;
1185
+ const x3 = cx - perpX;
1186
+ const y3 = cy - perpY;
1187
+ const minX = Math.min(x0, x1, x2, x3);
1188
+ const minY = Math.min(y0, y1, y2, y3);
1189
+ const maxX = Math.max(x0, x1, x2, x3);
1190
+ const maxY = Math.max(y0, y1, y2, y3);
1191
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1192
+ }
1193
+ }
1194
+ }
985
1195
  function boundsIntersect(a, b) {
986
1196
  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;
987
1197
  }
@@ -1502,6 +1712,190 @@ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
1502
1712
  }
1503
1713
  }
1504
1714
 
1715
+ // src/elements/hex-fill.ts
1716
+ function offsetToCube(col, row, orientation) {
1717
+ if (orientation === "pointy") {
1718
+ return { q: col - (row - (row & 1)) / 2, r: row };
1719
+ }
1720
+ return { q: col, r: row - (col - (col & 1)) / 2 };
1721
+ }
1722
+ function cubeToOffset(q, r, orientation) {
1723
+ if (orientation === "pointy") {
1724
+ return { col: q + (r - (r & 1)) / 2, row: r };
1725
+ }
1726
+ return { col: q, row: r + (q - (q & 1)) / 2 };
1727
+ }
1728
+ function offsetToPixel(col, row, cellSize, orientation) {
1729
+ if (orientation === "pointy") {
1730
+ const hexW = Math.sqrt(3) * cellSize;
1731
+ const rowH = 1.5 * cellSize;
1732
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1733
+ return { x: col * hexW + offsetX, y: row * rowH };
1734
+ }
1735
+ const hexH = Math.sqrt(3) * cellSize;
1736
+ const colW = 1.5 * cellSize;
1737
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1738
+ return { x: col * colW, y: row * hexH + offsetY };
1739
+ }
1740
+ function pixelToOffset(x, y, cellSize, orientation) {
1741
+ if (orientation === "pointy") {
1742
+ const hexW = Math.sqrt(3) * cellSize;
1743
+ const rowH = 1.5 * cellSize;
1744
+ const row = Math.round(y / rowH);
1745
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1746
+ return { col: Math.round((x - offsetX) / hexW), row };
1747
+ }
1748
+ const hexH = Math.sqrt(3) * cellSize;
1749
+ const colW = 1.5 * cellSize;
1750
+ const col = Math.round(x / colW);
1751
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1752
+ return { col, row: Math.round((y - offsetY) / hexH) };
1753
+ }
1754
+ function enumerateHexRing(centerQ, centerR, n, orientation, cellSize) {
1755
+ const cells = [];
1756
+ for (let dq = -n; dq <= n; dq++) {
1757
+ const rMin = Math.max(-n, -dq - n);
1758
+ const rMax = Math.min(n, -dq + n);
1759
+ for (let dr = rMin; dr <= rMax; dr++) {
1760
+ const absQ = centerQ + dq;
1761
+ const absR = centerR + dr;
1762
+ const off = cubeToOffset(absQ, absR, orientation);
1763
+ cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
1764
+ }
1765
+ }
1766
+ return cells;
1767
+ }
1768
+ function getHexDistance(a, b, cellSize, orientation) {
1769
+ const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
1770
+ const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
1771
+ const cubeA = offsetToCube(offA.col, offA.row, orientation);
1772
+ const cubeB = offsetToCube(offB.col, offB.row, orientation);
1773
+ const dq = cubeA.q - cubeB.q;
1774
+ const dr = cubeA.r - cubeB.r;
1775
+ const ds = -dq - dr;
1776
+ return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
1777
+ }
1778
+ function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
1779
+ const n = Math.round(radiusCells);
1780
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1781
+ const cube = offsetToCube(off.col, off.row, orientation);
1782
+ if (n <= 0) {
1783
+ return [offsetToPixel(off.col, off.row, cellSize, orientation)];
1784
+ }
1785
+ return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
1786
+ }
1787
+ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
1788
+ const n = Math.round(radiusCells);
1789
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1790
+ const cube = offsetToCube(off.col, off.row, orientation);
1791
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1792
+ if (n <= 0) return [centerPixel];
1793
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1794
+ const step = Math.PI / 3;
1795
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1796
+ const halfAngle = Math.PI / 6 + 1e-6;
1797
+ const cells = [centerPixel];
1798
+ for (let dq = -n; dq <= n; dq++) {
1799
+ const rMin = Math.max(-n, -dq - n);
1800
+ const rMax = Math.min(n, -dq + n);
1801
+ for (let dr = rMin; dr <= rMax; dr++) {
1802
+ if (dq === 0 && dr === 0) continue;
1803
+ const absQ = cube.q + dq;
1804
+ const absR = cube.r + dr;
1805
+ const pixel = offsetToPixel(
1806
+ cubeToOffset(absQ, absR, orientation).col,
1807
+ cubeToOffset(absQ, absR, orientation).row,
1808
+ cellSize,
1809
+ orientation
1810
+ );
1811
+ const dx = pixel.x - centerPixel.x;
1812
+ const dy = pixel.y - centerPixel.y;
1813
+ let diff = Math.atan2(dy, dx) - snappedAngle;
1814
+ if (diff > Math.PI) diff -= 2 * Math.PI;
1815
+ if (diff < -Math.PI) diff += 2 * Math.PI;
1816
+ if (Math.abs(diff) <= halfAngle) {
1817
+ cells.push(pixel);
1818
+ }
1819
+ }
1820
+ }
1821
+ return cells;
1822
+ }
1823
+ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
1824
+ const n = Math.round(radiusCells);
1825
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1826
+ const cube = offsetToCube(off.col, off.row, orientation);
1827
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1828
+ if (n <= 0) return [centerPixel];
1829
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1830
+ const step = Math.PI / 3;
1831
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1832
+ const cos = Math.cos(snappedAngle);
1833
+ const sin = Math.sin(snappedAngle);
1834
+ const snapUnit = Math.sqrt(3) * cellSize;
1835
+ const lineLength = n * snapUnit;
1836
+ const halfWidth = snapUnit * 0.5 + 1e-6;
1837
+ const cells = [];
1838
+ for (let dq = -n; dq <= n; dq++) {
1839
+ const rMin = Math.max(-n, -dq - n);
1840
+ const rMax = Math.min(n, -dq + n);
1841
+ for (let dr = rMin; dr <= rMax; dr++) {
1842
+ const absQ = cube.q + dq;
1843
+ const absR = cube.r + dr;
1844
+ const pixel = offsetToPixel(
1845
+ cubeToOffset(absQ, absR, orientation).col,
1846
+ cubeToOffset(absQ, absR, orientation).row,
1847
+ cellSize,
1848
+ orientation
1849
+ );
1850
+ const dx = pixel.x - centerPixel.x;
1851
+ const dy = pixel.y - centerPixel.y;
1852
+ const along = dx * cos + dy * sin;
1853
+ const perp = Math.abs(-dx * sin + dy * cos);
1854
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
1855
+ cells.push(pixel);
1856
+ }
1857
+ }
1858
+ }
1859
+ return cells;
1860
+ }
1861
+ function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
1862
+ const n = Math.round(radiusCells);
1863
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1864
+ const cube = offsetToCube(off.col, off.row, orientation);
1865
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1866
+ if (n <= 0) return [centerPixel];
1867
+ const snapUnit = Math.sqrt(3) * cellSize;
1868
+ const halfSide = n * snapUnit / 2;
1869
+ const cells = [];
1870
+ for (let dq = -n; dq <= n; dq++) {
1871
+ const rMin = Math.max(-n, -dq - n);
1872
+ const rMax = Math.min(n, -dq + n);
1873
+ for (let dr = rMin; dr <= rMax; dr++) {
1874
+ const absQ = cube.q + dq;
1875
+ const absR = cube.r + dr;
1876
+ const pixel = offsetToPixel(
1877
+ cubeToOffset(absQ, absR, orientation).col,
1878
+ cubeToOffset(absQ, absR, orientation).row,
1879
+ cellSize,
1880
+ orientation
1881
+ );
1882
+ if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
1883
+ cells.push(pixel);
1884
+ }
1885
+ }
1886
+ }
1887
+ return cells;
1888
+ }
1889
+ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
1890
+ const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1891
+ ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
1892
+ for (let i = 1; i < 6; i++) {
1893
+ const a = angleOffset + Math.PI / 3 * i;
1894
+ ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
1895
+ }
1896
+ ctx.closePath();
1897
+ }
1898
+
1505
1899
  // src/elements/element-renderer.ts
1506
1900
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
1507
1901
  var ARROWHEAD_LENGTH = 12;
@@ -1546,6 +1940,9 @@ var ElementRenderer = class {
1546
1940
  case "grid":
1547
1941
  this.renderGrid(ctx, element);
1548
1942
  break;
1943
+ case "template":
1944
+ this.renderTemplate(ctx, element);
1945
+ break;
1549
1946
  }
1550
1947
  }
1551
1948
  renderStroke(ctx, stroke) {
@@ -1733,6 +2130,147 @@ var ElementRenderer = class {
1733
2130
  );
1734
2131
  }
1735
2132
  }
2133
+ renderTemplate(ctx, template) {
2134
+ const grid = this.store?.getElementsByType("grid")[0];
2135
+ if (grid && grid.gridType === "hex") {
2136
+ this.renderHexTemplate(ctx, template, grid.cellSize, grid.hexOrientation);
2137
+ return;
2138
+ }
2139
+ this.renderGeometricTemplate(ctx, template);
2140
+ }
2141
+ renderGeometricTemplate(ctx, template) {
2142
+ const { x: cx, y: cy } = template.position;
2143
+ const r = template.radius;
2144
+ ctx.save();
2145
+ ctx.globalAlpha = template.opacity;
2146
+ ctx.fillStyle = template.fillColor;
2147
+ ctx.strokeStyle = template.strokeColor;
2148
+ ctx.lineWidth = template.strokeWidth;
2149
+ switch (template.templateShape) {
2150
+ case "circle":
2151
+ ctx.beginPath();
2152
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
2153
+ ctx.fill();
2154
+ ctx.stroke();
2155
+ if (template.radiusFeet != null && template.radiusFeet > 0) {
2156
+ this.renderRadiusMarker(ctx, cx, cy, r, template.radiusFeet);
2157
+ }
2158
+ break;
2159
+ case "square":
2160
+ ctx.fillRect(cx - r / 2, cy - r / 2, r, r);
2161
+ ctx.strokeRect(cx - r / 2, cy - r / 2, r, r);
2162
+ break;
2163
+ case "cone": {
2164
+ const halfAngle = Math.atan(0.5);
2165
+ ctx.beginPath();
2166
+ ctx.moveTo(cx, cy);
2167
+ ctx.arc(cx, cy, r, template.angle - halfAngle, template.angle + halfAngle);
2168
+ ctx.closePath();
2169
+ ctx.fill();
2170
+ ctx.stroke();
2171
+ break;
2172
+ }
2173
+ case "line": {
2174
+ const halfW = r / 12;
2175
+ const cos = Math.cos(template.angle);
2176
+ const sin = Math.sin(template.angle);
2177
+ const perpX = -sin * halfW;
2178
+ const perpY = cos * halfW;
2179
+ ctx.beginPath();
2180
+ ctx.moveTo(cx + perpX, cy + perpY);
2181
+ ctx.lineTo(cx + r * cos + perpX, cy + r * sin + perpY);
2182
+ ctx.lineTo(cx + r * cos - perpX, cy + r * sin - perpY);
2183
+ ctx.lineTo(cx - perpX, cy - perpY);
2184
+ ctx.closePath();
2185
+ ctx.fill();
2186
+ ctx.stroke();
2187
+ break;
2188
+ }
2189
+ }
2190
+ ctx.restore();
2191
+ }
2192
+ renderHexTemplate(ctx, template, cellSize, orientation) {
2193
+ const snapUnit = Math.sqrt(3) * cellSize;
2194
+ const radiusCells = template.radius / snapUnit;
2195
+ const center = template.position;
2196
+ let cells;
2197
+ switch (template.templateShape) {
2198
+ case "circle":
2199
+ cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
2200
+ break;
2201
+ case "cone":
2202
+ cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
2203
+ break;
2204
+ case "line":
2205
+ cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
2206
+ break;
2207
+ case "square":
2208
+ cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
2209
+ break;
2210
+ }
2211
+ ctx.save();
2212
+ ctx.globalAlpha = template.opacity;
2213
+ ctx.beginPath();
2214
+ for (const cell of cells) {
2215
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2216
+ }
2217
+ ctx.fillStyle = template.fillColor;
2218
+ ctx.fill();
2219
+ ctx.beginPath();
2220
+ for (const cell of cells) {
2221
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2222
+ }
2223
+ ctx.strokeStyle = template.strokeColor;
2224
+ ctx.lineWidth = template.strokeWidth;
2225
+ ctx.stroke();
2226
+ {
2227
+ ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
2228
+ ctx.beginPath();
2229
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
2230
+ ctx.fillStyle = template.strokeColor;
2231
+ ctx.fill();
2232
+ ctx.strokeStyle = template.strokeColor;
2233
+ ctx.lineWidth = template.strokeWidth;
2234
+ ctx.stroke();
2235
+ }
2236
+ if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
2237
+ const r = template.radius;
2238
+ this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
2239
+ }
2240
+ ctx.restore();
2241
+ }
2242
+ renderRadiusMarker(ctx, cx, cy, r, feet) {
2243
+ const markerColor = ctx.strokeStyle;
2244
+ ctx.save();
2245
+ ctx.globalAlpha = 1;
2246
+ ctx.beginPath();
2247
+ ctx.setLineDash([4, 4]);
2248
+ ctx.strokeStyle = markerColor;
2249
+ ctx.lineWidth = 1.5;
2250
+ ctx.moveTo(cx, cy);
2251
+ ctx.lineTo(cx + r, cy);
2252
+ ctx.stroke();
2253
+ ctx.setLineDash([]);
2254
+ const label = `${Math.round(feet)} ft`;
2255
+ const fontSize = Math.max(10, Math.min(14, r * 0.15));
2256
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
2257
+ ctx.textAlign = "center";
2258
+ ctx.textBaseline = "bottom";
2259
+ const textX = cx + r / 2;
2260
+ const textY = cy - 4;
2261
+ const metrics = ctx.measureText(label);
2262
+ const padX = 4;
2263
+ const padY = 2;
2264
+ const textW = metrics.width + padX * 2;
2265
+ const textH = fontSize + padY * 2;
2266
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
2267
+ ctx.beginPath();
2268
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
2269
+ ctx.fill();
2270
+ ctx.fillStyle = markerColor;
2271
+ ctx.fillText(label, textX, textY - padY);
2272
+ ctx.restore();
2273
+ }
1736
2274
  renderImage(ctx, image) {
1737
2275
  const img = this.getImage(image.src);
1738
2276
  if (!img) return;
@@ -1779,7 +2317,359 @@ var ElementRenderer = class {
1779
2317
  }
1780
2318
  };
1781
2319
 
2320
+ // src/elements/create-id.ts
2321
+ var counter = 0;
2322
+ function createId(prefix) {
2323
+ return `${prefix}_${Date.now().toString(36)}_${(counter++).toString(36)}`;
2324
+ }
2325
+
2326
+ // src/elements/element-factory.ts
2327
+ var DEFAULT_NOTE_FONT_SIZE = 18;
2328
+ function createStroke(input) {
2329
+ return {
2330
+ id: createId("stroke"),
2331
+ type: "stroke",
2332
+ position: input.position ?? { x: 0, y: 0 },
2333
+ zIndex: input.zIndex ?? 0,
2334
+ locked: input.locked ?? false,
2335
+ layerId: input.layerId ?? "",
2336
+ points: input.points,
2337
+ color: input.color ?? "#000000",
2338
+ width: input.width ?? 2,
2339
+ opacity: input.opacity ?? 1
2340
+ };
2341
+ }
2342
+ function createNote(input) {
2343
+ return {
2344
+ id: createId("note"),
2345
+ type: "note",
2346
+ position: input.position,
2347
+ zIndex: input.zIndex ?? 0,
2348
+ locked: input.locked ?? false,
2349
+ layerId: input.layerId ?? "",
2350
+ size: input.size ?? { w: 200, h: 100 },
2351
+ text: input.text ?? "",
2352
+ backgroundColor: input.backgroundColor ?? "#ffeb3b",
2353
+ textColor: input.textColor ?? "#000000",
2354
+ fontSize: input.fontSize ?? DEFAULT_NOTE_FONT_SIZE
2355
+ };
2356
+ }
2357
+ function createArrow(input) {
2358
+ const bend = input.bend ?? 0;
2359
+ const result = {
2360
+ id: createId("arrow"),
2361
+ type: "arrow",
2362
+ position: input.position ?? { x: 0, y: 0 },
2363
+ zIndex: input.zIndex ?? 0,
2364
+ locked: input.locked ?? false,
2365
+ layerId: input.layerId ?? "",
2366
+ from: input.from,
2367
+ to: input.to,
2368
+ bend,
2369
+ color: input.color ?? "#000000",
2370
+ width: input.width ?? 2,
2371
+ cachedControlPoint: getArrowControlPoint(input.from, input.to, bend)
2372
+ };
2373
+ if (input.fromBinding) result.fromBinding = input.fromBinding;
2374
+ if (input.toBinding) result.toBinding = input.toBinding;
2375
+ return result;
2376
+ }
2377
+ function createImage(input) {
2378
+ return {
2379
+ id: createId("image"),
2380
+ type: "image",
2381
+ position: input.position,
2382
+ zIndex: input.zIndex ?? 0,
2383
+ locked: input.locked ?? false,
2384
+ layerId: input.layerId ?? "",
2385
+ size: input.size,
2386
+ src: input.src
2387
+ };
2388
+ }
2389
+ function createHtmlElement(input) {
2390
+ const el = {
2391
+ id: createId("html"),
2392
+ type: "html",
2393
+ position: input.position,
2394
+ zIndex: input.zIndex ?? 0,
2395
+ locked: input.locked ?? false,
2396
+ layerId: input.layerId ?? "",
2397
+ size: input.size
2398
+ };
2399
+ if (input.domId) el.domId = input.domId;
2400
+ return el;
2401
+ }
2402
+ function createShape(input) {
2403
+ return {
2404
+ id: createId("shape"),
2405
+ type: "shape",
2406
+ position: input.position,
2407
+ zIndex: input.zIndex ?? 0,
2408
+ locked: input.locked ?? false,
2409
+ layerId: input.layerId ?? "",
2410
+ shape: input.shape ?? "rectangle",
2411
+ size: input.size,
2412
+ strokeColor: input.strokeColor ?? "#000000",
2413
+ strokeWidth: input.strokeWidth ?? 2,
2414
+ fillColor: input.fillColor ?? "none"
2415
+ };
2416
+ }
2417
+ function createGrid(input) {
2418
+ return {
2419
+ id: createId("grid"),
2420
+ type: "grid",
2421
+ position: input.position ?? { x: 0, y: 0 },
2422
+ zIndex: input.zIndex ?? 0,
2423
+ locked: input.locked ?? false,
2424
+ layerId: input.layerId ?? "",
2425
+ gridType: input.gridType ?? "square",
2426
+ hexOrientation: input.hexOrientation ?? "pointy",
2427
+ cellSize: input.cellSize ?? 40,
2428
+ strokeColor: input.strokeColor ?? "#000000",
2429
+ strokeWidth: input.strokeWidth ?? 1,
2430
+ opacity: input.opacity ?? 1
2431
+ };
2432
+ }
2433
+ function createText(input) {
2434
+ return {
2435
+ id: createId("text"),
2436
+ type: "text",
2437
+ position: input.position,
2438
+ zIndex: input.zIndex ?? 0,
2439
+ locked: input.locked ?? false,
2440
+ layerId: input.layerId ?? "",
2441
+ size: input.size ?? { w: 200, h: 28 },
2442
+ text: input.text ?? "",
2443
+ fontSize: input.fontSize ?? 16,
2444
+ color: input.color ?? "#1a1a1a",
2445
+ textAlign: input.textAlign ?? "left"
2446
+ };
2447
+ }
2448
+ function createTemplate(input) {
2449
+ return {
2450
+ id: createId("template"),
2451
+ type: "template",
2452
+ position: input.position,
2453
+ zIndex: input.zIndex ?? 0,
2454
+ locked: input.locked ?? false,
2455
+ layerId: input.layerId ?? "",
2456
+ templateShape: input.templateShape,
2457
+ radius: input.radius,
2458
+ angle: input.angle ?? 0,
2459
+ fillColor: input.fillColor ?? "rgba(255, 87, 34, 0.2)",
2460
+ strokeColor: input.strokeColor ?? "#FF5722",
2461
+ strokeWidth: input.strokeWidth ?? 2,
2462
+ opacity: input.opacity ?? 0.6,
2463
+ feetPerCell: input.feetPerCell,
2464
+ radiusFeet: input.radiusFeet
2465
+ };
2466
+ }
2467
+
2468
+ // src/elements/note-formatting.ts
2469
+ function toggleBold() {
2470
+ document.execCommand("bold");
2471
+ }
2472
+ function toggleItalic() {
2473
+ document.execCommand("italic");
2474
+ }
2475
+ function toggleUnderline() {
2476
+ document.execCommand("underline");
2477
+ }
2478
+ function toggleStrikethrough() {
2479
+ document.execCommand("strikeThrough");
2480
+ }
2481
+ function setFontSize(size) {
2482
+ const sel = window.getSelection();
2483
+ if (!sel || sel.rangeCount === 0) return;
2484
+ const range = sel.getRangeAt(0);
2485
+ if (range.collapsed) return;
2486
+ const span = document.createElement("span");
2487
+ span.style.fontSize = `${size}px`;
2488
+ try {
2489
+ range.surroundContents(span);
2490
+ } catch {
2491
+ span.appendChild(range.extractContents());
2492
+ range.insertNode(span);
2493
+ }
2494
+ }
2495
+ function getActiveFormats() {
2496
+ const query = (cmd) => {
2497
+ try {
2498
+ return document.queryCommandState(cmd);
2499
+ } catch {
2500
+ return false;
2501
+ }
2502
+ };
2503
+ return {
2504
+ bold: query("bold"),
2505
+ italic: query("italic"),
2506
+ underline: query("underline"),
2507
+ strikethrough: query("strikeThrough")
2508
+ };
2509
+ }
2510
+
2511
+ // src/elements/note-toolbar.ts
2512
+ var TOOLBAR_HEIGHT = 32;
2513
+ var TOOLBAR_GAP = 4;
2514
+ var FORMAT_BUTTONS = [
2515
+ { label: "B", format: "bold", command: "bold" },
2516
+ { label: "I", format: "italic", command: "italic" },
2517
+ { label: "U", format: "underline", command: "underline" },
2518
+ { label: "S", format: "strikethrough", command: "strikeThrough" }
2519
+ ];
2520
+ var DEFAULT_FONT_SIZE_PRESETS = [
2521
+ { label: "Small", size: 14 },
2522
+ { label: "Normal", size: 18 },
2523
+ { label: "Large", size: 24 },
2524
+ { label: "Heading", size: 32 }
2525
+ ];
2526
+ var NoteToolbar = class {
2527
+ el = null;
2528
+ anchor = null;
2529
+ selectionListener = null;
2530
+ fontSizePresets;
2531
+ constructor(fontSizePresets) {
2532
+ this.fontSizePresets = fontSizePresets ?? DEFAULT_FONT_SIZE_PRESETS;
2533
+ }
2534
+ show(anchor) {
2535
+ this.hide();
2536
+ this.anchor = anchor;
2537
+ this.el = this.createToolbarElement();
2538
+ document.body.appendChild(this.el);
2539
+ this.positionToolbar(anchor);
2540
+ this.selectionListener = () => this.updateActiveStates();
2541
+ document.addEventListener("selectionchange", this.selectionListener);
2542
+ }
2543
+ hide() {
2544
+ if (this.selectionListener) {
2545
+ document.removeEventListener("selectionchange", this.selectionListener);
2546
+ this.selectionListener = null;
2547
+ }
2548
+ if (this.el) {
2549
+ this.el.remove();
2550
+ this.el = null;
2551
+ }
2552
+ this.anchor = null;
2553
+ }
2554
+ getElement() {
2555
+ return this.el;
2556
+ }
2557
+ updatePosition(anchor) {
2558
+ if (this.el) {
2559
+ this.positionToolbar(anchor);
2560
+ }
2561
+ }
2562
+ createToolbarElement() {
2563
+ const toolbar = document.createElement("div");
2564
+ toolbar.dataset["noteToolbar"] = "";
2565
+ Object.assign(toolbar.style, {
2566
+ position: "fixed",
2567
+ display: "flex",
2568
+ alignItems: "center",
2569
+ gap: "2px",
2570
+ padding: "2px 4px",
2571
+ background: "#fff",
2572
+ border: "1px solid #ccc",
2573
+ borderRadius: "4px",
2574
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
2575
+ zIndex: "10000",
2576
+ height: `${TOOLBAR_HEIGHT}px`,
2577
+ userSelect: "none"
2578
+ });
2579
+ for (const btn of FORMAT_BUTTONS) {
2580
+ toolbar.appendChild(this.createFormatButton(btn));
2581
+ }
2582
+ toolbar.appendChild(this.createFontSizeSelect());
2583
+ return toolbar;
2584
+ }
2585
+ createFormatButton(config) {
2586
+ const btn = document.createElement("button");
2587
+ btn.dataset["format"] = config.format;
2588
+ btn.textContent = config.label;
2589
+ Object.assign(btn.style, {
2590
+ border: "1px solid transparent",
2591
+ borderRadius: "3px",
2592
+ background: "none",
2593
+ cursor: "pointer",
2594
+ padding: "2px 6px",
2595
+ fontSize: "13px",
2596
+ fontWeight: config.format === "bold" ? "bold" : "normal",
2597
+ fontStyle: config.format === "italic" ? "italic" : "normal",
2598
+ textDecoration: config.format === "underline" ? "underline" : config.format === "strikethrough" ? "line-through" : "none",
2599
+ minWidth: "24px",
2600
+ height: "24px",
2601
+ lineHeight: "24px"
2602
+ });
2603
+ btn.addEventListener("pointerdown", (e) => {
2604
+ e.preventDefault();
2605
+ document.execCommand(config.command);
2606
+ this.updateActiveStates();
2607
+ });
2608
+ return btn;
2609
+ }
2610
+ createFontSizeSelect() {
2611
+ const select = document.createElement("select");
2612
+ Object.assign(select.style, {
2613
+ border: "1px solid #ccc",
2614
+ borderRadius: "3px",
2615
+ background: "#fff",
2616
+ cursor: "pointer",
2617
+ padding: "2px",
2618
+ fontSize: "12px",
2619
+ height: "24px",
2620
+ marginLeft: "4px"
2621
+ });
2622
+ for (const preset of this.fontSizePresets) {
2623
+ const option = document.createElement("option");
2624
+ option.value = String(preset.size);
2625
+ option.textContent = preset.label;
2626
+ select.appendChild(option);
2627
+ }
2628
+ select.value = String(DEFAULT_NOTE_FONT_SIZE);
2629
+ select.addEventListener("pointerdown", (e) => {
2630
+ e.stopPropagation();
2631
+ });
2632
+ select.addEventListener("change", () => {
2633
+ setFontSize(Number(select.value));
2634
+ this.updateActiveStates();
2635
+ this.anchor?.focus();
2636
+ });
2637
+ return select;
2638
+ }
2639
+ positionToolbar(anchor) {
2640
+ if (!this.el) return;
2641
+ const rect = anchor.getBoundingClientRect();
2642
+ const toolbarWidth = this.el.offsetWidth || 200;
2643
+ let top = rect.top - TOOLBAR_HEIGHT - TOOLBAR_GAP;
2644
+ if (top < 0) {
2645
+ top = rect.bottom + TOOLBAR_GAP;
2646
+ }
2647
+ let left = rect.left + (rect.width - toolbarWidth) / 2;
2648
+ left = Math.max(4, left);
2649
+ Object.assign(this.el.style, {
2650
+ top: `${top}px`,
2651
+ left: `${left}px`
2652
+ });
2653
+ }
2654
+ updateActiveStates() {
2655
+ if (!this.el) return;
2656
+ const active = getActiveFormats();
2657
+ for (const config of FORMAT_BUTTONS) {
2658
+ const btn = this.el.querySelector(`[data-format="${config.format}"]`);
2659
+ if (!btn) continue;
2660
+ const isActive = active[config.format] ?? false;
2661
+ btn.style.background = isActive ? "#e0e0e0" : "none";
2662
+ btn.style.borderColor = isActive ? "#bbb" : "transparent";
2663
+ }
2664
+ }
2665
+ };
2666
+
1782
2667
  // src/elements/note-editor.ts
2668
+ var FORMAT_SHORTCUTS = {
2669
+ b: toggleBold,
2670
+ i: toggleItalic,
2671
+ u: toggleUnderline
2672
+ };
1783
2673
  var NoteEditor = class {
1784
2674
  editingId = null;
1785
2675
  editingNode = null;
@@ -1788,6 +2678,10 @@ var NoteEditor = class {
1788
2678
  pointerHandler = null;
1789
2679
  pendingEditId = null;
1790
2680
  onStopCallback = null;
2681
+ toolbar;
2682
+ constructor(options) {
2683
+ this.toolbar = options?.toolbar === false ? null : new NoteToolbar(options?.fontSizePresets);
2684
+ }
1791
2685
  get isEditing() {
1792
2686
  return this.editingId !== null;
1793
2687
  }
@@ -1812,13 +2706,6 @@ var NoteEditor = class {
1812
2706
  stopEditing(store) {
1813
2707
  this.pendingEditId = null;
1814
2708
  if (!this.editingId || !this.editingNode) return;
1815
- const text = this.editingNode.textContent ?? "";
1816
- store.update(this.editingId, { text });
1817
- this.editingNode.contentEditable = "false";
1818
- Object.assign(this.editingNode.style, {
1819
- userSelect: "none",
1820
- cursor: "default"
1821
- });
1822
2709
  if (this.blurHandler) {
1823
2710
  this.editingNode.removeEventListener("blur", this.blurHandler);
1824
2711
  }
@@ -1828,6 +2715,14 @@ var NoteEditor = class {
1828
2715
  if (this.pointerHandler) {
1829
2716
  this.editingNode.removeEventListener("pointerdown", this.pointerHandler);
1830
2717
  }
2718
+ const text = sanitizeNoteHtml(this.editingNode.innerHTML);
2719
+ store.update(this.editingId, { text });
2720
+ this.editingNode.contentEditable = "false";
2721
+ Object.assign(this.editingNode.style, {
2722
+ userSelect: "none",
2723
+ cursor: "default"
2724
+ });
2725
+ this.toolbar?.hide();
1831
2726
  if (this.editingId && this.onStopCallback) {
1832
2727
  this.onStopCallback(this.editingId);
1833
2728
  }
@@ -1843,6 +2738,11 @@ var NoteEditor = class {
1843
2738
  this.stopEditing(store);
1844
2739
  }
1845
2740
  }
2741
+ updateToolbarPosition() {
2742
+ if (this.editingNode) {
2743
+ this.toolbar?.updatePosition(this.editingNode);
2744
+ }
2745
+ }
1846
2746
  activateEditing(node, elementId, store) {
1847
2747
  this.editingId = elementId;
1848
2748
  this.editingNode = node;
@@ -1861,8 +2761,21 @@ var NoteEditor = class {
1861
2761
  selection.removeAllRanges();
1862
2762
  selection.addRange(range);
1863
2763
  }
1864
- this.blurHandler = () => this.stopEditing(store);
2764
+ this.toolbar?.show(node);
2765
+ this.blurHandler = (e) => {
2766
+ const related = e.relatedTarget;
2767
+ if (related && this.toolbar?.getElement()?.contains(related)) return;
2768
+ this.stopEditing(store);
2769
+ };
1865
2770
  this.keyHandler = (e) => {
2771
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
2772
+ const action = FORMAT_SHORTCUTS[e.key.toLowerCase()];
2773
+ if (action) {
2774
+ e.preventDefault();
2775
+ action();
2776
+ return;
2777
+ }
2778
+ }
1866
2779
  if (e.key === "Escape") {
1867
2780
  node.blur();
1868
2781
  }
@@ -2114,131 +3027,86 @@ var HistoryRecorder = class {
2114
3027
  }
2115
3028
  };
2116
3029
 
2117
- // src/elements/create-id.ts
2118
- var counter = 0;
2119
- function createId(prefix) {
2120
- return `${prefix}_${Date.now().toString(36)}_${(counter++).toString(36)}`;
2121
- }
2122
-
2123
- // src/elements/element-factory.ts
2124
- function createStroke(input) {
2125
- return {
2126
- id: createId("stroke"),
2127
- type: "stroke",
2128
- position: input.position ?? { x: 0, y: 0 },
2129
- zIndex: input.zIndex ?? 0,
2130
- locked: input.locked ?? false,
2131
- layerId: input.layerId ?? "",
2132
- points: input.points,
2133
- color: input.color ?? "#000000",
2134
- width: input.width ?? 2,
2135
- opacity: input.opacity ?? 1
2136
- };
2137
- }
2138
- function createNote(input) {
2139
- return {
2140
- id: createId("note"),
2141
- type: "note",
2142
- position: input.position,
2143
- zIndex: input.zIndex ?? 0,
2144
- locked: input.locked ?? false,
2145
- layerId: input.layerId ?? "",
2146
- size: input.size ?? { w: 200, h: 100 },
2147
- text: input.text ?? "",
2148
- backgroundColor: input.backgroundColor ?? "#ffeb3b",
2149
- textColor: input.textColor ?? "#000000"
2150
- };
2151
- }
2152
- function createArrow(input) {
2153
- const bend = input.bend ?? 0;
2154
- const result = {
2155
- id: createId("arrow"),
2156
- type: "arrow",
2157
- position: input.position ?? { x: 0, y: 0 },
2158
- zIndex: input.zIndex ?? 0,
2159
- locked: input.locked ?? false,
2160
- layerId: input.layerId ?? "",
2161
- from: input.from,
2162
- to: input.to,
2163
- bend,
2164
- color: input.color ?? "#000000",
2165
- width: input.width ?? 2,
2166
- cachedControlPoint: getArrowControlPoint(input.from, input.to, bend)
2167
- };
2168
- if (input.fromBinding) result.fromBinding = input.fromBinding;
2169
- if (input.toBinding) result.toBinding = input.toBinding;
2170
- return result;
2171
- }
2172
- function createImage(input) {
2173
- return {
2174
- id: createId("image"),
2175
- type: "image",
2176
- position: input.position,
2177
- zIndex: input.zIndex ?? 0,
2178
- locked: input.locked ?? false,
2179
- layerId: input.layerId ?? "",
2180
- size: input.size,
2181
- src: input.src
2182
- };
2183
- }
2184
- function createHtmlElement(input) {
2185
- const el = {
2186
- id: createId("html"),
2187
- type: "html",
2188
- position: input.position,
2189
- zIndex: input.zIndex ?? 0,
2190
- locked: input.locked ?? false,
2191
- layerId: input.layerId ?? "",
2192
- size: input.size
2193
- };
2194
- if (input.domId) el.domId = input.domId;
2195
- return el;
2196
- }
2197
- function createShape(input) {
2198
- return {
2199
- id: createId("shape"),
2200
- type: "shape",
2201
- position: input.position,
2202
- zIndex: input.zIndex ?? 0,
2203
- locked: input.locked ?? false,
2204
- layerId: input.layerId ?? "",
2205
- shape: input.shape ?? "rectangle",
2206
- size: input.size,
2207
- strokeColor: input.strokeColor ?? "#000000",
2208
- strokeWidth: input.strokeWidth ?? 2,
2209
- fillColor: input.fillColor ?? "none"
2210
- };
2211
- }
2212
- function createGrid(input) {
2213
- return {
2214
- id: createId("grid"),
2215
- type: "grid",
2216
- position: input.position ?? { x: 0, y: 0 },
2217
- zIndex: input.zIndex ?? 0,
2218
- locked: input.locked ?? false,
2219
- layerId: input.layerId ?? "",
2220
- gridType: input.gridType ?? "square",
2221
- hexOrientation: input.hexOrientation ?? "pointy",
2222
- cellSize: input.cellSize ?? 40,
2223
- strokeColor: input.strokeColor ?? "#000000",
2224
- strokeWidth: input.strokeWidth ?? 1,
2225
- opacity: input.opacity ?? 1
2226
- };
2227
- }
2228
- function createText(input) {
2229
- return {
2230
- id: createId("text"),
2231
- type: "text",
2232
- position: input.position,
2233
- zIndex: input.zIndex ?? 0,
2234
- locked: input.locked ?? false,
2235
- layerId: input.layerId ?? "",
2236
- size: input.size ?? { w: 200, h: 28 },
2237
- text: input.text ?? "",
2238
- fontSize: input.fontSize ?? 16,
2239
- color: input.color ?? "#1a1a1a",
2240
- textAlign: input.textAlign ?? "left"
2241
- };
3030
+ // src/canvas/note-canvas-renderer.ts
3031
+ function renderNoteOnCanvas(ctx, note) {
3032
+ const { x, y } = note.position;
3033
+ const { w, h } = note.size;
3034
+ const r = 4;
3035
+ const pad = 8;
3036
+ const baseFontSize = note.fontSize ?? DEFAULT_NOTE_FONT_SIZE;
3037
+ ctx.save();
3038
+ ctx.fillStyle = note.backgroundColor;
3039
+ ctx.beginPath();
3040
+ ctx.moveTo(x + r, y);
3041
+ ctx.lineTo(x + w - r, y);
3042
+ ctx.arcTo(x + w, y, x + w, y + r, r);
3043
+ ctx.lineTo(x + w, y + h - r);
3044
+ ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
3045
+ ctx.lineTo(x + r, y + h);
3046
+ ctx.arcTo(x, y + h, x, y + h - r, r);
3047
+ ctx.lineTo(x, y + r);
3048
+ ctx.arcTo(x, y, x + r, y, r);
3049
+ ctx.closePath();
3050
+ ctx.fill();
3051
+ if (note.text) {
3052
+ ctx.fillStyle = note.textColor;
3053
+ const runs = parseStyledRuns(note.text, baseFontSize);
3054
+ renderStyledRuns(ctx, runs, x + pad, y + pad, w - pad * 2);
3055
+ }
3056
+ ctx.restore();
3057
+ }
3058
+ function buildFontString(run) {
3059
+ const style = run.italic ? "italic" : "normal";
3060
+ const weight = run.bold ? "bold" : "normal";
3061
+ return `${style} ${weight} ${run.fontSize}px system-ui, sans-serif`;
3062
+ }
3063
+ function renderStyledRuns(ctx, runs, startX, startY, maxWidth) {
3064
+ ctx.textBaseline = "top";
3065
+ let cursorX = startX;
3066
+ let cursorY = startY;
3067
+ let lineHeight = 0;
3068
+ for (const run of runs) {
3069
+ ctx.font = buildFontString(run);
3070
+ const runLineHeight = run.fontSize * 1.3;
3071
+ lineHeight = Math.max(lineHeight, runLineHeight);
3072
+ const words = run.text.split(/(\n| )/);
3073
+ for (const word of words) {
3074
+ if (word === "\n") {
3075
+ cursorX = startX;
3076
+ cursorY += lineHeight;
3077
+ lineHeight = runLineHeight;
3078
+ continue;
3079
+ }
3080
+ if (word === " ") {
3081
+ const spaceWidth = ctx.measureText(" ").width;
3082
+ if (cursorX + spaceWidth > startX + maxWidth && cursorX > startX) {
3083
+ cursorX = startX;
3084
+ cursorY += lineHeight;
3085
+ lineHeight = runLineHeight;
3086
+ } else {
3087
+ cursorX += spaceWidth;
3088
+ }
3089
+ continue;
3090
+ }
3091
+ if (!word) continue;
3092
+ const metrics = ctx.measureText(word);
3093
+ if (cursorX + metrics.width > startX + maxWidth && cursorX > startX) {
3094
+ cursorX = startX;
3095
+ cursorY += lineHeight;
3096
+ lineHeight = runLineHeight;
3097
+ }
3098
+ ctx.fillText(word, cursorX, cursorY);
3099
+ if (run.underline) {
3100
+ const underY = cursorY + run.fontSize + 1;
3101
+ ctx.fillRect(cursorX, underY, metrics.width, 1);
3102
+ }
3103
+ if (run.strikethrough) {
3104
+ const strikeY = cursorY + run.fontSize * 0.55;
3105
+ ctx.fillRect(cursorX, strikeY, metrics.width, 1);
3106
+ }
3107
+ cursorX += metrics.width;
3108
+ }
3109
+ }
2242
3110
  }
2243
3111
 
2244
3112
  // src/canvas/export-image.ts
@@ -2276,6 +3144,11 @@ function getElementRect(el) {
2276
3144
  }
2277
3145
  case "grid":
2278
3146
  return null;
3147
+ case "template": {
3148
+ const bounds = getElementBounds(el);
3149
+ if (!bounds) return null;
3150
+ return bounds;
3151
+ }
2279
3152
  case "note":
2280
3153
  case "image":
2281
3154
  case "html":
@@ -2312,33 +3185,6 @@ function computeBounds(elements, padding) {
2312
3185
  h: maxY - minY + padding * 2
2313
3186
  };
2314
3187
  }
2315
- function renderNoteOnCanvas(ctx, note) {
2316
- const { x, y } = note.position;
2317
- const { w, h } = note.size;
2318
- const r = 4;
2319
- const pad = 8;
2320
- ctx.save();
2321
- ctx.fillStyle = note.backgroundColor;
2322
- ctx.beginPath();
2323
- ctx.moveTo(x + r, y);
2324
- ctx.lineTo(x + w - r, y);
2325
- ctx.arcTo(x + w, y, x + w, y + r, r);
2326
- ctx.lineTo(x + w, y + h - r);
2327
- ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
2328
- ctx.lineTo(x + r, y + h);
2329
- ctx.arcTo(x, y + h, x, y + h - r, r);
2330
- ctx.lineTo(x, y + r);
2331
- ctx.arcTo(x, y, x + r, y, r);
2332
- ctx.closePath();
2333
- ctx.fill();
2334
- if (note.text) {
2335
- ctx.fillStyle = note.textColor;
2336
- ctx.font = "14px system-ui, sans-serif";
2337
- ctx.textBaseline = "top";
2338
- wrapText(ctx, note.text, x + pad, y + pad, w - pad * 2, 18);
2339
- }
2340
- ctx.restore();
2341
- }
2342
3188
  function renderTextOnCanvas(ctx, text) {
2343
3189
  if (!text.text) return;
2344
3190
  ctx.save();
@@ -2363,25 +3209,6 @@ function renderTextOnCanvas(ctx, text) {
2363
3209
  }
2364
3210
  ctx.restore();
2365
3211
  }
2366
- function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
2367
- const words = text.split(" ");
2368
- let line = "";
2369
- let offsetY = 0;
2370
- for (const word of words) {
2371
- const testLine = line ? `${line} ${word}` : word;
2372
- const metrics = ctx.measureText(testLine);
2373
- if (metrics.width > maxWidth && line) {
2374
- ctx.fillText(line, x, y + offsetY);
2375
- line = word;
2376
- offsetY += lineHeight;
2377
- } else {
2378
- line = testLine;
2379
- }
2380
- }
2381
- if (line) {
2382
- ctx.fillText(line, x, y + offsetY);
2383
- }
2384
- }
2385
3212
  function renderGridForBounds(ctx, grid, bounds) {
2386
3213
  const visibleBounds = {
2387
3214
  minX: bounds.x,
@@ -2780,13 +3607,13 @@ var DomNodeManager = class {
2780
3607
  padding: "8px",
2781
3608
  borderRadius: "4px",
2782
3609
  boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
2783
- fontSize: "14px",
3610
+ fontSize: `${element.fontSize ?? DEFAULT_NOTE_FONT_SIZE}px`,
2784
3611
  overflow: "hidden",
2785
3612
  cursor: "default",
2786
3613
  userSelect: "none",
2787
3614
  wordWrap: "break-word"
2788
3615
  });
2789
- node.textContent = element.text || "";
3616
+ node.innerHTML = element.text || "";
2790
3617
  node.addEventListener("dblclick", (e) => {
2791
3618
  e.stopPropagation();
2792
3619
  const id = node.dataset["elementId"];
@@ -2794,11 +3621,13 @@ var DomNodeManager = class {
2794
3621
  });
2795
3622
  }
2796
3623
  if (!this.isEditingElement(element.id)) {
2797
- if (node.textContent !== element.text) {
2798
- node.textContent = element.text || "";
3624
+ const text = element.text || "";
3625
+ if (node.innerHTML !== text) {
3626
+ node.innerHTML = text;
2799
3627
  }
2800
3628
  node.style.backgroundColor = element.backgroundColor;
2801
3629
  node.style.color = element.textColor;
3630
+ node.style.fontSize = `${element.fontSize ?? DEFAULT_NOTE_FONT_SIZE}px`;
2802
3631
  }
2803
3632
  }
2804
3633
  if (element.type === "html" && !node.dataset["initialized"]) {
@@ -3020,7 +3849,15 @@ var RenderLoop = class {
3020
3849
  ctx.save();
3021
3850
  ctx.scale(dpr, dpr);
3022
3851
  this.renderer.setCanvasSize(cssWidth, cssHeight);
3023
- this.background.render(ctx, this.camera);
3852
+ const hasGridElement = this.store.getElementsByType("grid").length > 0;
3853
+ if (hasGridElement) {
3854
+ ctx.save();
3855
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3856
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
3857
+ ctx.restore();
3858
+ } else {
3859
+ this.background.render(ctx, this.camera);
3860
+ }
3024
3861
  ctx.save();
3025
3862
  ctx.translate(this.camera.position.x, this.camera.position.y);
3026
3863
  ctx.scale(this.camera.zoom, this.camera.zoom);
@@ -3221,7 +4058,10 @@ var Viewport = class {
3221
4058
  this.renderLoop.markAllLayersDirty();
3222
4059
  this.requestRender();
3223
4060
  });
3224
- this.noteEditor = new NoteEditor();
4061
+ this.noteEditor = new NoteEditor({
4062
+ fontSizePresets: options.fontSizePresets,
4063
+ toolbar: options.toolbar
4064
+ });
3225
4065
  this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
3226
4066
  this.history = new HistoryStack();
3227
4067
  this.historyRecorder = new HistoryRecorder(this.store, this.history);
@@ -3277,20 +4117,24 @@ var Viewport = class {
3277
4117
  });
3278
4118
  this.unsubCamera = this.camera.onChange(() => {
3279
4119
  this.applyCameraTransform();
4120
+ this.noteEditor.updateToolbarPosition();
3280
4121
  this.requestRender();
3281
4122
  });
3282
4123
  this.unsubStore = [
3283
4124
  this.store.on("add", (el) => {
4125
+ if (el.type === "grid") this.syncGridContext();
3284
4126
  this.renderLoop.markLayerDirty(el.layerId);
3285
4127
  this.requestRender();
3286
4128
  }),
3287
4129
  this.store.on("remove", (el) => {
4130
+ if (el.type === "grid") this.syncGridContext();
3288
4131
  this.unbindArrowsFrom(el);
3289
4132
  this.domNodeManager.removeDomNode(el.id);
3290
4133
  this.renderLoop.markLayerDirty(el.layerId);
3291
4134
  this.requestRender();
3292
4135
  }),
3293
4136
  this.store.on("update", ({ previous, current }) => {
4137
+ if (current.type === "grid") this.syncGridContext();
3294
4138
  this.renderLoop.markLayerDirty(current.layerId);
3295
4139
  if (previous.layerId !== current.layerId) {
3296
4140
  this.renderLoop.markLayerDirty(previous.layerId);
@@ -3300,6 +4144,7 @@ var Viewport = class {
3300
4144
  this.store.on("clear", () => {
3301
4145
  this.domNodeManager.clearDomNodes();
3302
4146
  this.renderLoop.markAllLayersDirty();
4147
+ this.syncGridContext();
3303
4148
  this.requestRender();
3304
4149
  })
3305
4150
  ];
@@ -3313,6 +4158,7 @@ var Viewport = class {
3313
4158
  this.observeResize();
3314
4159
  this.syncCanvasSize();
3315
4160
  this.renderLoop.start();
4161
+ this.syncGridContext();
3316
4162
  }
3317
4163
  camera;
3318
4164
  store;
@@ -3629,6 +4475,18 @@ var Viewport = class {
3629
4475
  this.renderLoop.setCanvasSize(rect.width * dpr, rect.height * dpr);
3630
4476
  this.requestRender();
3631
4477
  }
4478
+ syncGridContext() {
4479
+ const grid = this.store.getElementsByType("grid")[0];
4480
+ if (grid) {
4481
+ this.toolContext.gridSize = grid.cellSize;
4482
+ this.toolContext.gridType = grid.gridType;
4483
+ this.toolContext.hexOrientation = grid.hexOrientation;
4484
+ } else {
4485
+ this.toolContext.gridSize = this._gridSize;
4486
+ this.toolContext.gridType = void 0;
4487
+ this.toolContext.hexOrientation = void 0;
4488
+ }
4489
+ }
3632
4490
  observeResize() {
3633
4491
  if (typeof ResizeObserver === "undefined") return;
3634
4492
  this.resizeObserver = new ResizeObserver(() => this.syncCanvasSize());
@@ -4003,7 +4861,7 @@ var SelectTool = class {
4003
4861
  ctx.setCursor?.("default");
4004
4862
  }
4005
4863
  snap(point, ctx) {
4006
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
4864
+ return smartSnap(point, ctx);
4007
4865
  }
4008
4866
  onPointerDown(state, ctx) {
4009
4867
  this.ctx = ctx;
@@ -4020,6 +4878,12 @@ var SelectTool = class {
4020
4878
  ctx.requestRender();
4021
4879
  return;
4022
4880
  }
4881
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4882
+ if (templateResizeHit) {
4883
+ this.mode = { type: "resizing-template", elementId: templateResizeHit };
4884
+ ctx.requestRender();
4885
+ return;
4886
+ }
4023
4887
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4024
4888
  if (resizeHit) {
4025
4889
  const el = ctx.store.getById(resizeHit.elementId);
@@ -4054,6 +4918,11 @@ var SelectTool = class {
4054
4918
  applyArrowHandleDrag(this.mode.handle, this.mode.elementId, world, ctx);
4055
4919
  return;
4056
4920
  }
4921
+ if (this.mode.type === "resizing-template") {
4922
+ ctx.setCursor?.("nwse-resize");
4923
+ this.handleTemplateResize(world, ctx);
4924
+ return;
4925
+ }
4057
4926
  if (this.mode.type === "resizing") {
4058
4927
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
4059
4928
  this.handleResize(world, ctx);
@@ -4077,6 +4946,16 @@ var SelectTool = class {
4077
4946
  from: { x: el.from.x + dx, y: el.from.y + dy },
4078
4947
  to: { x: el.to.x + dx, y: el.to.y + dy }
4079
4948
  });
4949
+ } else if (ctx.gridType && "size" in el) {
4950
+ const centerX = el.position.x + el.size.w / 2 + dx;
4951
+ const centerY = el.position.y + el.size.h / 2 + dy;
4952
+ const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
4953
+ ctx.store.update(id, {
4954
+ position: {
4955
+ x: snappedCenter.x - el.size.w / 2,
4956
+ y: snappedCenter.y - el.size.h / 2
4957
+ }
4958
+ });
4080
4959
  } else {
4081
4960
  ctx.store.update(id, {
4082
4961
  position: { x: el.position.x + dx, y: el.position.y + dy }
@@ -4151,6 +5030,11 @@ var SelectTool = class {
4151
5030
  ctx.setCursor?.(getArrowHandleCursor(arrowHit.handle, false));
4152
5031
  return;
4153
5032
  }
5033
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
5034
+ if (templateResizeHit) {
5035
+ ctx.setCursor?.("nwse-resize");
5036
+ return;
5037
+ }
4154
5038
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4155
5039
  if (resizeHit) {
4156
5040
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -4287,6 +5171,24 @@ var SelectTool = class {
4287
5171
  );
4288
5172
  }
4289
5173
  canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
5174
+ } else if (el.type === "template") {
5175
+ canvasCtx.setLineDash([]);
5176
+ canvasCtx.fillStyle = "#ffffff";
5177
+ const hx = bounds.x + bounds.w;
5178
+ const hy = bounds.y + bounds.h;
5179
+ canvasCtx.fillRect(
5180
+ hx - handleWorldSize / 2,
5181
+ hy - handleWorldSize / 2,
5182
+ handleWorldSize,
5183
+ handleWorldSize
5184
+ );
5185
+ canvasCtx.strokeRect(
5186
+ hx - handleWorldSize / 2,
5187
+ hy - handleWorldSize / 2,
5188
+ handleWorldSize,
5189
+ handleWorldSize
5190
+ );
5191
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
4290
5192
  }
4291
5193
  }
4292
5194
  canvasCtx.restore();
@@ -4311,6 +5213,43 @@ var SelectTool = class {
4311
5213
  }
4312
5214
  canvasCtx.restore();
4313
5215
  }
5216
+ hitTestTemplateResizeHandle(world, ctx) {
5217
+ if (this._selectedIds.length === 0) return null;
5218
+ const zoom = ctx.camera.zoom;
5219
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
5220
+ for (const id of this._selectedIds) {
5221
+ const el = ctx.store.getById(id);
5222
+ if (!el || el.type !== "template") continue;
5223
+ const bounds = getElementBounds(el);
5224
+ if (!bounds) continue;
5225
+ const hx = bounds.x + bounds.w;
5226
+ const hy = bounds.y + bounds.h;
5227
+ if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
5228
+ return id;
5229
+ }
5230
+ }
5231
+ return null;
5232
+ }
5233
+ handleTemplateResize(world, ctx) {
5234
+ if (this.mode.type !== "resizing-template") return;
5235
+ const el = ctx.store.getById(this.mode.elementId);
5236
+ if (!el || el.type !== "template" || el.locked) return;
5237
+ const dx = world.x - el.position.x;
5238
+ const dy = world.y - el.position.y;
5239
+ let newRadius = Math.sqrt(dx * dx + dy * dy);
5240
+ if (ctx.snapToGrid && ctx.gridSize && ctx.gridSize > 0) {
5241
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
5242
+ newRadius = Math.max(snapUnit, Math.round(newRadius / snapUnit) * snapUnit);
5243
+ }
5244
+ newRadius = Math.max(MIN_ELEMENT_SIZE, newRadius);
5245
+ const updates = { radius: newRadius };
5246
+ if (el.feetPerCell != null && ctx.gridSize && ctx.gridSize > 0) {
5247
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
5248
+ updates.radiusFeet = newRadius / snapUnit * el.feetPerCell;
5249
+ }
5250
+ ctx.store.update(this.mode.elementId, updates);
5251
+ ctx.requestRender();
5252
+ }
4314
5253
  getMarqueeRect() {
4315
5254
  if (this.mode.type !== "marquee") return null;
4316
5255
  const { start } = this.mode;
@@ -4367,6 +5306,11 @@ var SelectTool = class {
4367
5306
  if (el.type === "arrow") {
4368
5307
  return isNearBezier(point, el.from, el.to, el.bend, 10);
4369
5308
  }
5309
+ if (el.type === "template") {
5310
+ const bounds = getElementBounds(el);
5311
+ if (!bounds) return false;
5312
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
5313
+ }
4370
5314
  return false;
4371
5315
  }
4372
5316
  };
@@ -4424,7 +5368,7 @@ var ArrowTool = class {
4424
5368
  this.fromBinding = { elementId: target.id };
4425
5369
  this.fromTarget = target;
4426
5370
  } else {
4427
- this.start = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
5371
+ this.start = smartSnap(world, ctx);
4428
5372
  this.fromBinding = void 0;
4429
5373
  this.fromTarget = null;
4430
5374
  }
@@ -4442,7 +5386,7 @@ var ArrowTool = class {
4442
5386
  this.end = getElementCenter(target);
4443
5387
  this.toTarget = target;
4444
5388
  } else {
4445
- this.end = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
5389
+ this.end = smartSnap(world, ctx);
4446
5390
  this.toTarget = null;
4447
5391
  }
4448
5392
  ctx.requestRender();
@@ -4523,17 +5467,20 @@ var NoteTool = class {
4523
5467
  backgroundColor;
4524
5468
  textColor;
4525
5469
  size;
5470
+ fontSize;
4526
5471
  optionListeners = /* @__PURE__ */ new Set();
4527
5472
  constructor(options = {}) {
4528
5473
  this.backgroundColor = options.backgroundColor ?? "#ffeb3b";
4529
5474
  this.textColor = options.textColor ?? "#000000";
4530
5475
  this.size = options.size ?? { w: 200, h: 100 };
5476
+ this.fontSize = options.fontSize ?? DEFAULT_NOTE_FONT_SIZE;
4531
5477
  }
4532
5478
  getOptions() {
4533
5479
  return {
4534
5480
  backgroundColor: this.backgroundColor,
4535
5481
  textColor: this.textColor,
4536
- size: { ...this.size }
5482
+ size: { ...this.size },
5483
+ fontSize: this.fontSize
4537
5484
  };
4538
5485
  }
4539
5486
  onOptionsChange(listener) {
@@ -4544,6 +5491,7 @@ var NoteTool = class {
4544
5491
  if (options.backgroundColor !== void 0) this.backgroundColor = options.backgroundColor;
4545
5492
  if (options.textColor !== void 0) this.textColor = options.textColor;
4546
5493
  if (options.size !== void 0) this.size = options.size;
5494
+ if (options.fontSize !== void 0) this.fontSize = options.fontSize;
4547
5495
  this.notifyOptionsChange();
4548
5496
  }
4549
5497
  notifyOptionsChange() {
@@ -4555,14 +5503,13 @@ var NoteTool = class {
4555
5503
  }
4556
5504
  onPointerUp(state, ctx) {
4557
5505
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4558
- if (ctx.snapToGrid && ctx.gridSize) {
4559
- world = snapPoint(world, ctx.gridSize);
4560
- }
5506
+ world = smartSnap(world, ctx);
4561
5507
  const note = createNote({
4562
5508
  position: world,
4563
5509
  size: { ...this.size },
4564
5510
  backgroundColor: this.backgroundColor,
4565
5511
  textColor: this.textColor,
5512
+ fontSize: this.fontSize,
4566
5513
  layerId: ctx.activeLayerId ?? ""
4567
5514
  });
4568
5515
  ctx.store.add(note);
@@ -4612,9 +5559,7 @@ var TextTool = class {
4612
5559
  }
4613
5560
  onPointerUp(state, ctx) {
4614
5561
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4615
- if (ctx.snapToGrid && ctx.gridSize) {
4616
- world = snapPoint(world, ctx.gridSize);
4617
- }
5562
+ world = smartSnap(world, ctx);
4618
5563
  const textEl = createText({
4619
5564
  position: world,
4620
5565
  fontSize: this.fontSize,
@@ -4647,8 +5592,12 @@ var ImageTool = class {
4647
5592
  onPointerUp(state, ctx) {
4648
5593
  if (!this.src) return;
4649
5594
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5595
+ const snapped = smartSnap(world, ctx);
4650
5596
  const image = createImage({
4651
- position: world,
5597
+ position: {
5598
+ x: snapped.x - this.size.w / 2,
5599
+ y: snapped.y - this.size.h / 2
5600
+ },
4652
5601
  size: { ...this.size },
4653
5602
  src: this.src
4654
5603
  });
@@ -4785,7 +5734,7 @@ var ShapeTool = class {
4785
5734
  for (const listener of this.optionListeners) listener();
4786
5735
  }
4787
5736
  snap(point, ctx) {
4788
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
5737
+ return smartSnap(point, ctx);
4789
5738
  }
4790
5739
  onKeyDown = (e) => {
4791
5740
  if (e.key === "Shift") this.shiftHeld = true;
@@ -4795,6 +5744,398 @@ var ShapeTool = class {
4795
5744
  };
4796
5745
  };
4797
5746
 
5747
+ // src/tools/measure-tool.ts
5748
+ var MeasureTool = class {
5749
+ name = "measure";
5750
+ start = null;
5751
+ end = null;
5752
+ gridSize = 1;
5753
+ gridType;
5754
+ hexOrientation;
5755
+ feetPerCell;
5756
+ optionListeners = /* @__PURE__ */ new Set();
5757
+ constructor(options = {}) {
5758
+ this.feetPerCell = options.feetPerCell ?? 5;
5759
+ }
5760
+ getOptions() {
5761
+ return { feetPerCell: this.feetPerCell };
5762
+ }
5763
+ setOptions(options) {
5764
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5765
+ this.notifyOptionsChange();
5766
+ }
5767
+ onOptionsChange(listener) {
5768
+ this.optionListeners.add(listener);
5769
+ return () => this.optionListeners.delete(listener);
5770
+ }
5771
+ onPointerDown(state, ctx) {
5772
+ this.gridSize = ctx.gridSize ?? 1;
5773
+ this.gridType = ctx.gridType;
5774
+ this.hexOrientation = ctx.hexOrientation;
5775
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5776
+ this.start = this.snapToGrid(world, ctx);
5777
+ this.end = { ...this.start };
5778
+ }
5779
+ onPointerMove(state, ctx) {
5780
+ if (!this.start) return;
5781
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5782
+ this.end = this.snapToGrid(world, ctx);
5783
+ ctx.requestRender();
5784
+ }
5785
+ onPointerUp(_state, ctx) {
5786
+ if (!this.start) return;
5787
+ this.start = null;
5788
+ this.end = null;
5789
+ ctx.requestRender();
5790
+ }
5791
+ onDeactivate(_ctx) {
5792
+ this.start = null;
5793
+ this.end = null;
5794
+ }
5795
+ getMeasurement() {
5796
+ if (!this.start || !this.end) return null;
5797
+ const dx = this.end.x - this.start.x;
5798
+ const dy = this.end.y - this.start.y;
5799
+ const worldDistance = Math.sqrt(dx * dx + dy * dy);
5800
+ let cells;
5801
+ if (this.gridType === "hex" && this.hexOrientation) {
5802
+ cells = getHexDistance(this.start, this.end, this.gridSize, this.hexOrientation);
5803
+ } else {
5804
+ const snapUnit = this.gridSize;
5805
+ cells = worldDistance / snapUnit;
5806
+ }
5807
+ const feet = cells * this.feetPerCell;
5808
+ return {
5809
+ start: { ...this.start },
5810
+ end: { ...this.end },
5811
+ worldDistance,
5812
+ cells,
5813
+ feet
5814
+ };
5815
+ }
5816
+ renderOverlay(ctx) {
5817
+ const m = this.getMeasurement();
5818
+ if (!m) return;
5819
+ ctx.save();
5820
+ ctx.strokeStyle = "#FF5722";
5821
+ ctx.setLineDash([8, 4]);
5822
+ ctx.lineWidth = 2;
5823
+ ctx.beginPath();
5824
+ ctx.moveTo(m.start.x, m.start.y);
5825
+ ctx.lineTo(m.end.x, m.end.y);
5826
+ ctx.stroke();
5827
+ ctx.setLineDash([]);
5828
+ ctx.fillStyle = "#FF5722";
5829
+ const dotRadius = 4;
5830
+ ctx.beginPath();
5831
+ ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
5832
+ ctx.fill();
5833
+ ctx.beginPath();
5834
+ ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
5835
+ ctx.fill();
5836
+ const label = `${Math.round(m.feet)} ft`;
5837
+ const midX = (m.start.x + m.end.x) / 2;
5838
+ const midY = (m.start.y + m.end.y) / 2;
5839
+ ctx.font = "14px sans-serif";
5840
+ const metrics = ctx.measureText(label);
5841
+ const padX = 6;
5842
+ const padY = 4;
5843
+ const textH = 14;
5844
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
5845
+ ctx.beginPath();
5846
+ ctx.roundRect(
5847
+ midX - metrics.width / 2 - padX,
5848
+ midY - textH / 2 - padY,
5849
+ metrics.width + padX * 2,
5850
+ textH + padY * 2,
5851
+ 4
5852
+ );
5853
+ ctx.fill();
5854
+ ctx.fillStyle = "#FFFFFF";
5855
+ ctx.textAlign = "center";
5856
+ ctx.textBaseline = "middle";
5857
+ ctx.fillText(label, midX, midY);
5858
+ ctx.restore();
5859
+ }
5860
+ snapToGrid(point, ctx) {
5861
+ if (!ctx.gridSize) return point;
5862
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
5863
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
5864
+ }
5865
+ if (ctx.gridType === "square") {
5866
+ return snapPoint(point, ctx.gridSize);
5867
+ }
5868
+ if (ctx.snapToGrid) {
5869
+ return snapPoint(point, ctx.gridSize);
5870
+ }
5871
+ return point;
5872
+ }
5873
+ notifyOptionsChange() {
5874
+ for (const listener of this.optionListeners) listener();
5875
+ }
5876
+ };
5877
+
5878
+ // src/tools/template-tool.ts
5879
+ var TemplateTool = class {
5880
+ name = "template";
5881
+ drawing = false;
5882
+ origin = { x: 0, y: 0 };
5883
+ current = { x: 0, y: 0 };
5884
+ gridSize = 1;
5885
+ gridType;
5886
+ hexOrientation;
5887
+ snapEnabled = false;
5888
+ templateShape;
5889
+ fillColor;
5890
+ strokeColor;
5891
+ strokeWidth;
5892
+ opacity;
5893
+ feetPerCell;
5894
+ optionListeners = /* @__PURE__ */ new Set();
5895
+ constructor(options = {}) {
5896
+ this.templateShape = options.templateShape ?? "circle";
5897
+ this.fillColor = options.fillColor ?? "rgba(255, 87, 34, 0.2)";
5898
+ this.strokeColor = options.strokeColor ?? "#FF5722";
5899
+ this.strokeWidth = options.strokeWidth ?? 2;
5900
+ this.opacity = options.opacity ?? 0.6;
5901
+ this.feetPerCell = options.feetPerCell ?? 5;
5902
+ }
5903
+ getOptions() {
5904
+ return {
5905
+ templateShape: this.templateShape,
5906
+ fillColor: this.fillColor,
5907
+ strokeColor: this.strokeColor,
5908
+ strokeWidth: this.strokeWidth,
5909
+ opacity: this.opacity,
5910
+ feetPerCell: this.feetPerCell
5911
+ };
5912
+ }
5913
+ setOptions(options) {
5914
+ if (options.templateShape !== void 0) this.templateShape = options.templateShape;
5915
+ if (options.fillColor !== void 0) this.fillColor = options.fillColor;
5916
+ if (options.strokeColor !== void 0) this.strokeColor = options.strokeColor;
5917
+ if (options.strokeWidth !== void 0) this.strokeWidth = options.strokeWidth;
5918
+ if (options.opacity !== void 0) this.opacity = options.opacity;
5919
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5920
+ this.notifyOptionsChange();
5921
+ }
5922
+ onOptionsChange(listener) {
5923
+ this.optionListeners.add(listener);
5924
+ return () => this.optionListeners.delete(listener);
5925
+ }
5926
+ onPointerDown(state, ctx) {
5927
+ this.drawing = true;
5928
+ this.gridSize = ctx.gridSize ?? 1;
5929
+ this.gridType = ctx.gridType;
5930
+ this.hexOrientation = ctx.hexOrientation;
5931
+ this.snapEnabled = !!ctx.gridType || (ctx.snapToGrid ?? false);
5932
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5933
+ this.origin = this.snapToGrid(world, ctx);
5934
+ this.current = { ...this.origin };
5935
+ }
5936
+ onPointerMove(state, ctx) {
5937
+ if (!this.drawing) return;
5938
+ this.current = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5939
+ ctx.requestRender();
5940
+ }
5941
+ onPointerUp(_state, ctx) {
5942
+ if (!this.drawing) return;
5943
+ this.drawing = false;
5944
+ const radius = this.computeRadius();
5945
+ if (radius <= 0) return;
5946
+ const angle = this.computeAngle();
5947
+ const gridSize = ctx.gridSize;
5948
+ const snapUnit = gridSize && gridSize > 0 ? ctx.gridType === "hex" ? Math.sqrt(3) * gridSize : gridSize : 0;
5949
+ const cells = snapUnit > 0 ? radius / snapUnit : 0;
5950
+ const radiusFeet = cells * this.feetPerCell;
5951
+ const element = createTemplate({
5952
+ position: { ...this.origin },
5953
+ templateShape: this.templateShape,
5954
+ radius,
5955
+ angle,
5956
+ fillColor: this.fillColor,
5957
+ strokeColor: this.strokeColor,
5958
+ strokeWidth: this.strokeWidth,
5959
+ opacity: this.opacity,
5960
+ feetPerCell: this.feetPerCell,
5961
+ radiusFeet: radiusFeet > 0 ? radiusFeet : void 0,
5962
+ layerId: ctx.activeLayerId ?? ""
5963
+ });
5964
+ ctx.store.add(element);
5965
+ ctx.requestRender();
5966
+ ctx.switchTool?.("select");
5967
+ }
5968
+ onDeactivate(_ctx) {
5969
+ this.drawing = false;
5970
+ this.origin = { x: 0, y: 0 };
5971
+ this.current = { x: 0, y: 0 };
5972
+ }
5973
+ renderOverlay(ctx) {
5974
+ if (!this.drawing) return;
5975
+ const radius = this.computeRadius();
5976
+ if (radius <= 0) return;
5977
+ if (this.gridType === "hex" && this.hexOrientation) {
5978
+ this.renderHexOverlay(ctx, radius);
5979
+ return;
5980
+ }
5981
+ this.renderGeometricOverlay(ctx, radius);
5982
+ }
5983
+ renderGeometricOverlay(ctx, radius) {
5984
+ const cx = this.origin.x;
5985
+ const cy = this.origin.y;
5986
+ const angle = this.computeAngle();
5987
+ ctx.save();
5988
+ ctx.globalAlpha = 0.4;
5989
+ ctx.fillStyle = this.fillColor;
5990
+ ctx.strokeStyle = this.strokeColor;
5991
+ ctx.lineWidth = this.strokeWidth;
5992
+ switch (this.templateShape) {
5993
+ case "circle":
5994
+ ctx.beginPath();
5995
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
5996
+ ctx.fill();
5997
+ ctx.stroke();
5998
+ break;
5999
+ case "square":
6000
+ ctx.fillRect(cx - radius / 2, cy - radius / 2, radius, radius);
6001
+ ctx.strokeRect(cx - radius / 2, cy - radius / 2, radius, radius);
6002
+ break;
6003
+ case "cone": {
6004
+ const halfAngle = Math.atan(0.5);
6005
+ ctx.beginPath();
6006
+ ctx.moveTo(cx, cy);
6007
+ ctx.arc(cx, cy, radius, angle - halfAngle, angle + halfAngle);
6008
+ ctx.closePath();
6009
+ ctx.fill();
6010
+ ctx.stroke();
6011
+ break;
6012
+ }
6013
+ case "line": {
6014
+ const halfW = radius / 12;
6015
+ const cos = Math.cos(angle);
6016
+ const sin = Math.sin(angle);
6017
+ const perpX = -sin * halfW;
6018
+ const perpY = cos * halfW;
6019
+ ctx.beginPath();
6020
+ ctx.moveTo(cx + perpX, cy + perpY);
6021
+ ctx.lineTo(cx + radius * cos + perpX, cy + radius * sin + perpY);
6022
+ ctx.lineTo(cx + radius * cos - perpX, cy + radius * sin - perpY);
6023
+ ctx.lineTo(cx - perpX, cy - perpY);
6024
+ ctx.closePath();
6025
+ ctx.fill();
6026
+ ctx.stroke();
6027
+ break;
6028
+ }
6029
+ }
6030
+ ctx.restore();
6031
+ }
6032
+ renderHexOverlay(ctx, radius) {
6033
+ const orientation = this.hexOrientation;
6034
+ if (!orientation) return;
6035
+ const cellSize = this.gridSize;
6036
+ const snapUnit = Math.sqrt(3) * cellSize;
6037
+ const radiusCells = radius / snapUnit;
6038
+ const angle = this.computeAngle();
6039
+ const center = this.origin;
6040
+ let hexCells;
6041
+ switch (this.templateShape) {
6042
+ case "circle":
6043
+ hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
6044
+ break;
6045
+ case "cone":
6046
+ hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
6047
+ break;
6048
+ case "line":
6049
+ hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
6050
+ break;
6051
+ case "square":
6052
+ hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
6053
+ break;
6054
+ }
6055
+ ctx.save();
6056
+ ctx.globalAlpha = 0.4;
6057
+ ctx.beginPath();
6058
+ for (const cell of hexCells) {
6059
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
6060
+ }
6061
+ ctx.fillStyle = this.fillColor;
6062
+ ctx.fill();
6063
+ ctx.beginPath();
6064
+ for (const cell of hexCells) {
6065
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
6066
+ }
6067
+ ctx.strokeStyle = this.strokeColor;
6068
+ ctx.lineWidth = this.strokeWidth;
6069
+ ctx.stroke();
6070
+ if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
6071
+ ctx.globalAlpha = 0.5;
6072
+ ctx.beginPath();
6073
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
6074
+ ctx.fillStyle = this.strokeColor;
6075
+ ctx.fill();
6076
+ ctx.strokeStyle = this.strokeColor;
6077
+ ctx.lineWidth = this.strokeWidth;
6078
+ ctx.stroke();
6079
+ }
6080
+ if (this.templateShape === "circle") {
6081
+ const feet = radiusCells * this.feetPerCell;
6082
+ if (feet > 0) {
6083
+ ctx.globalAlpha = 1;
6084
+ const label = `${Math.round(feet)} ft`;
6085
+ const fontSize = Math.max(10, Math.min(14, radius * 0.15));
6086
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
6087
+ ctx.textAlign = "center";
6088
+ ctx.textBaseline = "bottom";
6089
+ const textX = center.x;
6090
+ const textY = center.y - 4;
6091
+ const metrics = ctx.measureText(label);
6092
+ const padX = 4;
6093
+ const padY = 2;
6094
+ const textW = metrics.width + padX * 2;
6095
+ const textH = fontSize + padY * 2;
6096
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
6097
+ ctx.beginPath();
6098
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
6099
+ ctx.fill();
6100
+ ctx.fillStyle = this.strokeColor;
6101
+ ctx.fillText(label, textX, textY - padY);
6102
+ }
6103
+ }
6104
+ ctx.restore();
6105
+ }
6106
+ computeRadius() {
6107
+ const dx = this.current.x - this.origin.x;
6108
+ const dy = this.current.y - this.origin.y;
6109
+ const raw = Math.sqrt(dx * dx + dy * dy);
6110
+ if (this.snapEnabled && this.gridSize > 0) {
6111
+ const snapUnit = this.gridType === "hex" ? Math.sqrt(3) * this.gridSize : this.gridSize;
6112
+ return Math.max(snapUnit, Math.round(raw / snapUnit) * snapUnit);
6113
+ }
6114
+ return raw;
6115
+ }
6116
+ computeAngle() {
6117
+ const dx = this.current.x - this.origin.x;
6118
+ const dy = this.current.y - this.origin.y;
6119
+ return Math.atan2(dy, dx);
6120
+ }
6121
+ snapToGrid(point, ctx) {
6122
+ if (!ctx.gridSize) return point;
6123
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
6124
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
6125
+ }
6126
+ if (ctx.gridType === "square") {
6127
+ return snapPoint(point, ctx.gridSize);
6128
+ }
6129
+ if (ctx.snapToGrid) {
6130
+ return snapPoint(point, ctx.gridSize);
6131
+ }
6132
+ return point;
6133
+ }
6134
+ notifyOptionsChange() {
6135
+ for (const listener of this.optionListeners) listener();
6136
+ }
6137
+ };
6138
+
4798
6139
  // src/history/layer-commands.ts
4799
6140
  var CreateLayerCommand = class {
4800
6141
  constructor(manager, layer) {
@@ -4836,7 +6177,7 @@ var UpdateLayerCommand = class {
4836
6177
  };
4837
6178
 
4838
6179
  // src/index.ts
4839
- var VERSION = "0.8.11";
6180
+ var VERSION = "0.10.0";
4840
6181
  export {
4841
6182
  AddElementCommand,
4842
6183
  ArrowTool,
@@ -4845,6 +6186,8 @@ export {
4845
6186
  BatchCommand,
4846
6187
  Camera,
4847
6188
  CreateLayerCommand,
6189
+ DEFAULT_FONT_SIZE_PRESETS,
6190
+ DEFAULT_NOTE_FONT_SIZE,
4848
6191
  ElementRenderer,
4849
6192
  ElementStore,
4850
6193
  EraserTool,
@@ -4855,14 +6198,17 @@ export {
4855
6198
  ImageTool,
4856
6199
  InputHandler,
4857
6200
  LayerManager,
6201
+ MeasureTool,
4858
6202
  NoteEditor,
4859
6203
  NoteTool,
6204
+ NoteToolbar,
4860
6205
  PencilTool,
4861
6206
  Quadtree,
4862
6207
  RemoveElementCommand,
4863
6208
  RemoveLayerCommand,
4864
6209
  SelectTool,
4865
6210
  ShapeTool,
6211
+ TemplateTool,
4866
6212
  TextTool,
4867
6213
  ToolManager,
4868
6214
  UpdateElementCommand,
@@ -4879,11 +6225,14 @@ export {
4879
6225
  createNote,
4880
6226
  createShape,
4881
6227
  createStroke,
6228
+ createTemplate,
4882
6229
  createText,
6230
+ drawHexPath,
4883
6231
  exportImage,
4884
6232
  exportState,
4885
6233
  findBindTarget,
4886
6234
  findBoundArrows,
6235
+ getActiveFormats,
4887
6236
  getArrowBounds,
4888
6237
  getArrowControlPoint,
4889
6238
  getArrowMidpoint,
@@ -4892,10 +6241,23 @@ export {
4892
6241
  getEdgeIntersection,
4893
6242
  getElementBounds,
4894
6243
  getElementCenter,
6244
+ getHexCellsInCone,
6245
+ getHexCellsInLine,
6246
+ getHexCellsInRadius,
6247
+ getHexCellsInSquare,
6248
+ getHexDistance,
4895
6249
  isBindable,
4896
6250
  isNearBezier,
4897
6251
  parseState,
6252
+ sanitizeNoteHtml,
6253
+ setFontSize,
6254
+ smartSnap,
4898
6255
  snapPoint,
6256
+ snapToHexCenter,
6257
+ toggleBold,
6258
+ toggleItalic,
6259
+ toggleStrikethrough,
6260
+ toggleUnderline,
4899
6261
  unbindArrow,
4900
6262
  updateBoundArrow
4901
6263
  };