@fieldnotes/core 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AddElementCommand: () => AddElementCommand,
24
24
  ArrowTool: () => ArrowTool,
25
+ AutoSave: () => AutoSave,
25
26
  Background: () => Background,
26
27
  BatchCommand: () => BatchCommand,
27
28
  Camera: () => Camera,
@@ -151,8 +152,74 @@ function migrateElement(obj) {
151
152
  if (obj["type"] === "arrow" && typeof obj["bend"] !== "number") {
152
153
  obj["bend"] = 0;
153
154
  }
155
+ if (obj["type"] === "stroke" && Array.isArray(obj["points"])) {
156
+ for (const pt of obj["points"]) {
157
+ if (typeof pt["pressure"] !== "number") {
158
+ pt["pressure"] = 0.5;
159
+ }
160
+ }
161
+ }
154
162
  }
155
163
 
164
+ // src/core/auto-save.ts
165
+ var DEFAULT_KEY = "fieldnotes-autosave";
166
+ var DEFAULT_DEBOUNCE_MS = 1e3;
167
+ var AutoSave = class {
168
+ constructor(store, camera, options = {}) {
169
+ this.store = store;
170
+ this.camera = camera;
171
+ this.key = options.key ?? DEFAULT_KEY;
172
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
173
+ }
174
+ key;
175
+ debounceMs;
176
+ timerId = null;
177
+ unsubscribers = [];
178
+ start() {
179
+ const schedule = () => this.scheduleSave();
180
+ this.unsubscribers = [
181
+ this.store.on("add", schedule),
182
+ this.store.on("remove", schedule),
183
+ this.store.on("update", schedule),
184
+ this.camera.onChange(schedule)
185
+ ];
186
+ }
187
+ stop() {
188
+ this.cancelPending();
189
+ this.unsubscribers.forEach((fn) => fn());
190
+ this.unsubscribers = [];
191
+ }
192
+ load() {
193
+ if (typeof localStorage === "undefined") return null;
194
+ const json = localStorage.getItem(this.key);
195
+ if (!json) return null;
196
+ try {
197
+ return parseState(json);
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+ clear() {
203
+ if (typeof localStorage === "undefined") return;
204
+ localStorage.removeItem(this.key);
205
+ }
206
+ scheduleSave() {
207
+ this.cancelPending();
208
+ this.timerId = setTimeout(() => this.save(), this.debounceMs);
209
+ }
210
+ cancelPending() {
211
+ if (this.timerId !== null) {
212
+ clearTimeout(this.timerId);
213
+ this.timerId = null;
214
+ }
215
+ }
216
+ save() {
217
+ if (typeof localStorage === "undefined") return;
218
+ const state = exportState(this.store.snapshot(), this.camera);
219
+ localStorage.setItem(this.key, JSON.stringify(state));
220
+ }
221
+ };
222
+
156
223
  // src/canvas/camera.ts
157
224
  var DEFAULT_MIN_ZOOM = 0.1;
158
225
  var DEFAULT_MAX_ZOOM = 10;
@@ -347,24 +414,8 @@ var InputHandler = class {
347
414
  y: e.clientY - rect.top
348
415
  });
349
416
  };
350
- isInteractiveHtmlContent(e) {
351
- const target = e.target;
352
- if (!target) return false;
353
- const node = target.closest("[data-element-id]");
354
- if (!node) return false;
355
- const elementId = node.dataset["elementId"];
356
- if (!elementId) return false;
357
- const store = this.toolContext?.store;
358
- if (!store) return false;
359
- const element = store.getById(elementId);
360
- if (!element || element.type !== "html") return false;
361
- return true;
362
- }
363
417
  onPointerDown = (e) => {
364
418
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
365
- if (this.isInteractiveHtmlContent(e)) {
366
- return;
367
- }
368
419
  this.element.setPointerCapture?.(e.pointerId);
369
420
  if (this.activePointers.size === 2) {
370
421
  this.startPinch();
@@ -690,6 +741,79 @@ function isNearLine(point, a, b, threshold) {
690
741
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
691
742
  }
692
743
 
744
+ // src/elements/stroke-smoothing.ts
745
+ var MIN_PRESSURE_SCALE = 0.2;
746
+ function pressureToWidth(pressure, baseWidth) {
747
+ return baseWidth * (MIN_PRESSURE_SCALE + (1 - MIN_PRESSURE_SCALE) * pressure);
748
+ }
749
+ function simplifyPoints(points, tolerance) {
750
+ if (points.length <= 2) return points.slice();
751
+ return rdp(points, 0, points.length - 1, tolerance);
752
+ }
753
+ function rdp(points, start, end, tolerance) {
754
+ const first = points[start];
755
+ const last = points[end];
756
+ if (!first || !last) return [];
757
+ if (end - start <= 1) return [first, last];
758
+ let maxDist = 0;
759
+ let maxIndex = start;
760
+ for (let i = start + 1; i < end; i++) {
761
+ const pt = points[i];
762
+ if (!pt) continue;
763
+ const dist = perpendicularDistance(pt, first, last);
764
+ if (dist > maxDist) {
765
+ maxDist = dist;
766
+ maxIndex = i;
767
+ }
768
+ }
769
+ if (maxDist <= tolerance) return [first, last];
770
+ const left = rdp(points, start, maxIndex, tolerance);
771
+ const right = rdp(points, maxIndex, end, tolerance);
772
+ return left.concat(right.slice(1));
773
+ }
774
+ function perpendicularDistance(pt, lineStart, lineEnd) {
775
+ const dx = lineEnd.x - lineStart.x;
776
+ const dy = lineEnd.y - lineStart.y;
777
+ const lenSq = dx * dx + dy * dy;
778
+ if (lenSq === 0) {
779
+ const ex = pt.x - lineStart.x;
780
+ const ey = pt.y - lineStart.y;
781
+ return Math.sqrt(ex * ex + ey * ey);
782
+ }
783
+ const num = Math.abs(dy * pt.x - dx * pt.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x);
784
+ return num / Math.sqrt(lenSq);
785
+ }
786
+ function smoothToSegments(points) {
787
+ if (points.length < 2) return [];
788
+ if (points.length === 2) {
789
+ const p0 = points[0];
790
+ const p1 = points[1];
791
+ if (!p0 || !p1) return [];
792
+ const mx = (p0.x + p1.x) / 2;
793
+ const my = (p0.y + p1.y) / 2;
794
+ return [{ start: p0, cp1: { x: mx, y: my }, cp2: { x: mx, y: my }, end: p1 }];
795
+ }
796
+ const segments = [];
797
+ const n = points.length;
798
+ for (let i = 0; i < n - 1; i++) {
799
+ const p0 = points[Math.max(0, i - 1)];
800
+ const p1 = points[i];
801
+ const p2 = points[i + 1];
802
+ const p3 = points[Math.min(n - 1, i + 2)];
803
+ if (!p0 || !p1 || !p2 || !p3) continue;
804
+ const cp1 = {
805
+ x: p1.x + (p2.x - p0.x) / 6,
806
+ y: p1.y + (p2.y - p0.y) / 6
807
+ };
808
+ const cp2 = {
809
+ x: p2.x - (p3.x - p1.x) / 6,
810
+ y: p2.y - (p3.y - p1.y) / 6
811
+ };
812
+ segments.push({ start: p1, cp1, cp2, end: p2 });
813
+ }
814
+ return segments;
815
+ }
816
+
693
817
  // src/elements/element-renderer.ts
694
818
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html"]);
695
819
  var ARROWHEAD_LENGTH = 12;
@@ -713,22 +837,18 @@ var ElementRenderer = class {
713
837
  ctx.save();
714
838
  ctx.translate(stroke.position.x, stroke.position.y);
715
839
  ctx.strokeStyle = stroke.color;
716
- ctx.lineWidth = stroke.width;
717
840
  ctx.lineCap = "round";
718
841
  ctx.lineJoin = "round";
719
842
  ctx.globalAlpha = stroke.opacity;
720
- ctx.beginPath();
721
- const first = stroke.points[0];
722
- if (first) {
723
- ctx.moveTo(first.x, first.y);
724
- }
725
- for (let i = 1; i < stroke.points.length; i++) {
726
- const pt = stroke.points[i];
727
- if (pt) {
728
- ctx.lineTo(pt.x, pt.y);
729
- }
843
+ const segments = smoothToSegments(stroke.points);
844
+ for (const seg of segments) {
845
+ const w = (pressureToWidth(seg.start.pressure, stroke.width) + pressureToWidth(seg.end.pressure, stroke.width)) / 2;
846
+ ctx.lineWidth = w;
847
+ ctx.beginPath();
848
+ ctx.moveTo(seg.start.x, seg.start.y);
849
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
850
+ ctx.stroke();
730
851
  }
731
- ctx.stroke();
732
852
  ctx.restore();
733
853
  }
734
854
  renderArrow(ctx, arrow) {
@@ -871,6 +991,9 @@ var ToolManager = class {
871
991
  register(tool) {
872
992
  this.tools.set(tool.name, tool);
873
993
  }
994
+ getTool(name) {
995
+ return this.tools.get(name);
996
+ }
874
997
  setTool(name, ctx) {
875
998
  const tool = this.tools.get(name);
876
999
  if (!tool) return;
@@ -1230,6 +1353,7 @@ var Viewport = class {
1230
1353
  needsRender = true;
1231
1354
  domNodes = /* @__PURE__ */ new Map();
1232
1355
  htmlContent = /* @__PURE__ */ new Map();
1356
+ interactingElementId = null;
1233
1357
  get ctx() {
1234
1358
  return this.canvasEl.getContext("2d");
1235
1359
  }
@@ -1275,6 +1399,7 @@ var Viewport = class {
1275
1399
  this.store.add(image);
1276
1400
  this.historyRecorder.commit();
1277
1401
  this.requestRender();
1402
+ return image.id;
1278
1403
  }
1279
1404
  addHtmlElement(dom, position, size = { w: 200, h: 150 }) {
1280
1405
  const el = createHtmlElement({ position, size });
@@ -1287,6 +1412,7 @@ var Viewport = class {
1287
1412
  }
1288
1413
  destroy() {
1289
1414
  cancelAnimationFrame(this.animFrameId);
1415
+ this.stopInteracting();
1290
1416
  this.noteEditor.destroy(this.store);
1291
1417
  this.historyRecorder.destroy();
1292
1418
  this.wrapper.removeEventListener("dblclick", this.onDblClick);
@@ -1344,11 +1470,69 @@ var Viewport = class {
1344
1470
  }
1345
1471
  onDblClick = (e) => {
1346
1472
  const el = document.elementFromPoint(e.clientX, e.clientY);
1347
- if (!el) return;
1348
- const nodeEl = el.closest("[data-element-id]");
1349
- if (!nodeEl) return;
1350
- const elementId = nodeEl.dataset["elementId"];
1351
- if (elementId) this.startEditingNote(elementId);
1473
+ const nodeEl = el?.closest("[data-element-id]");
1474
+ if (nodeEl) {
1475
+ const elementId = nodeEl.dataset["elementId"];
1476
+ if (elementId) {
1477
+ const element = this.store.getById(elementId);
1478
+ if (element?.type === "note") {
1479
+ this.startEditingNote(elementId);
1480
+ return;
1481
+ }
1482
+ }
1483
+ }
1484
+ const rect = this.wrapper.getBoundingClientRect();
1485
+ const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
1486
+ const world = this.camera.screenToWorld(screen);
1487
+ const hit = this.hitTestWorld(world);
1488
+ if (hit?.type === "html") {
1489
+ this.startInteracting(hit.id);
1490
+ }
1491
+ };
1492
+ hitTestWorld(world) {
1493
+ const elements = this.store.getAll().reverse();
1494
+ for (const el of elements) {
1495
+ if (!("size" in el)) continue;
1496
+ const { x, y } = el.position;
1497
+ const { w, h } = el.size;
1498
+ if (world.x >= x && world.x <= x + w && world.y >= y && world.y <= y + h) {
1499
+ return el;
1500
+ }
1501
+ }
1502
+ return null;
1503
+ }
1504
+ startInteracting(id) {
1505
+ this.stopInteracting();
1506
+ const node = this.domNodes.get(id);
1507
+ if (!node) return;
1508
+ this.interactingElementId = id;
1509
+ node.style.pointerEvents = "auto";
1510
+ window.addEventListener("keydown", this.onInteractKeyDown);
1511
+ window.addEventListener("pointerdown", this.onInteractPointerDown);
1512
+ }
1513
+ stopInteracting() {
1514
+ if (!this.interactingElementId) return;
1515
+ const node = this.domNodes.get(this.interactingElementId);
1516
+ if (node) {
1517
+ node.style.pointerEvents = "none";
1518
+ }
1519
+ this.interactingElementId = null;
1520
+ window.removeEventListener("keydown", this.onInteractKeyDown);
1521
+ window.removeEventListener("pointerdown", this.onInteractPointerDown);
1522
+ }
1523
+ onInteractKeyDown = (e) => {
1524
+ if (e.key === "Escape") {
1525
+ this.stopInteracting();
1526
+ }
1527
+ };
1528
+ onInteractPointerDown = (e) => {
1529
+ if (!this.interactingElementId) return;
1530
+ const target = e.target;
1531
+ if (!target) return;
1532
+ const node = this.domNodes.get(this.interactingElementId);
1533
+ if (node && !node.contains(target)) {
1534
+ this.stopInteracting();
1535
+ }
1352
1536
  };
1353
1537
  onDragOver = (e) => {
1354
1538
  e.preventDefault();
@@ -1446,7 +1630,8 @@ var Viewport = class {
1446
1630
  if (content) {
1447
1631
  node.dataset["initialized"] = "true";
1448
1632
  Object.assign(node.style, {
1449
- overflow: "hidden"
1633
+ overflow: "hidden",
1634
+ pointerEvents: "none"
1450
1635
  });
1451
1636
  node.appendChild(content);
1452
1637
  }
@@ -1549,15 +1734,19 @@ var HandTool = class {
1549
1734
 
1550
1735
  // src/tools/pencil-tool.ts
1551
1736
  var MIN_POINTS_FOR_STROKE = 2;
1737
+ var DEFAULT_SMOOTHING = 1.5;
1738
+ var DEFAULT_PRESSURE = 0.5;
1552
1739
  var PencilTool = class {
1553
1740
  name = "pencil";
1554
1741
  drawing = false;
1555
1742
  points = [];
1556
1743
  color;
1557
1744
  width;
1745
+ smoothing;
1558
1746
  constructor(options = {}) {
1559
1747
  this.color = options.color ?? "#000000";
1560
1748
  this.width = options.width ?? 2;
1749
+ this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
1561
1750
  }
1562
1751
  onActivate(ctx) {
1563
1752
  ctx.setCursor?.("crosshair");
@@ -1568,16 +1757,19 @@ var PencilTool = class {
1568
1757
  setOptions(options) {
1569
1758
  if (options.color !== void 0) this.color = options.color;
1570
1759
  if (options.width !== void 0) this.width = options.width;
1760
+ if (options.smoothing !== void 0) this.smoothing = options.smoothing;
1571
1761
  }
1572
1762
  onPointerDown(state, ctx) {
1573
1763
  this.drawing = true;
1574
1764
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1575
- this.points = [world];
1765
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
1766
+ this.points = [{ x: world.x, y: world.y, pressure }];
1576
1767
  }
1577
1768
  onPointerMove(state, ctx) {
1578
1769
  if (!this.drawing) return;
1579
1770
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1580
- this.points.push(world);
1771
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
1772
+ this.points.push({ x: world.x, y: world.y, pressure });
1581
1773
  ctx.requestRender();
1582
1774
  }
1583
1775
  onPointerUp(_state, ctx) {
@@ -1587,8 +1779,9 @@ var PencilTool = class {
1587
1779
  this.points = [];
1588
1780
  return;
1589
1781
  }
1782
+ const simplified = simplifyPoints(this.points, this.smoothing);
1590
1783
  const stroke = createStroke({
1591
- points: this.points,
1784
+ points: simplified,
1592
1785
  color: this.color,
1593
1786
  width: this.width
1594
1787
  });
@@ -1600,19 +1793,18 @@ var PencilTool = class {
1600
1793
  if (!this.drawing || this.points.length < 2) return;
1601
1794
  ctx.save();
1602
1795
  ctx.strokeStyle = this.color;
1603
- ctx.lineWidth = this.width;
1604
1796
  ctx.lineCap = "round";
1605
1797
  ctx.lineJoin = "round";
1606
1798
  ctx.globalAlpha = 0.8;
1607
- ctx.beginPath();
1608
- const first = this.points[0];
1609
- if (!first) return;
1610
- ctx.moveTo(first.x, first.y);
1611
- for (let i = 1; i < this.points.length; i++) {
1612
- const p = this.points[i];
1613
- if (p) ctx.lineTo(p.x, p.y);
1799
+ const segments = smoothToSegments(this.points);
1800
+ for (const seg of segments) {
1801
+ const w = (pressureToWidth(seg.start.pressure, this.width) + pressureToWidth(seg.end.pressure, this.width)) / 2;
1802
+ ctx.lineWidth = w;
1803
+ ctx.beginPath();
1804
+ ctx.moveTo(seg.start.x, seg.start.y);
1805
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
1806
+ ctx.stroke();
1614
1807
  }
1615
- ctx.stroke();
1616
1808
  ctx.restore();
1617
1809
  }
1618
1810
  };
@@ -1900,7 +2092,7 @@ var SelectTool = class {
1900
2092
  handleResize(world, ctx) {
1901
2093
  if (this.mode.type !== "resizing") return;
1902
2094
  const el = ctx.store.getById(this.mode.elementId);
1903
- if (!el || !("size" in el)) return;
2095
+ if (!el || !("size" in el) || el.locked) return;
1904
2096
  const { handle } = this.mode;
1905
2097
  const dx = world.x - this.lastWorld.x;
1906
2098
  const dy = world.y - this.lastWorld.y;
@@ -2107,6 +2299,10 @@ var ArrowTool = class {
2107
2299
  this.color = options.color ?? "#000000";
2108
2300
  this.width = options.width ?? 2;
2109
2301
  }
2302
+ setOptions(options) {
2303
+ if (options.color !== void 0) this.color = options.color;
2304
+ if (options.width !== void 0) this.width = options.width;
2305
+ }
2110
2306
  onPointerDown(state, ctx) {
2111
2307
  this.drawing = true;
2112
2308
  this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
@@ -2171,6 +2367,10 @@ var NoteTool = class {
2171
2367
  this.backgroundColor = options.backgroundColor ?? "#ffeb3b";
2172
2368
  this.size = options.size ?? { w: 200, h: 100 };
2173
2369
  }
2370
+ setOptions(options) {
2371
+ if (options.backgroundColor !== void 0) this.backgroundColor = options.backgroundColor;
2372
+ if (options.size !== void 0) this.size = options.size;
2373
+ }
2174
2374
  onPointerDown(_state, _ctx) {
2175
2375
  }
2176
2376
  onPointerMove(_state, _ctx) {
@@ -2220,11 +2420,12 @@ var ImageTool = class {
2220
2420
  };
2221
2421
 
2222
2422
  // src/index.ts
2223
- var VERSION = "0.1.2";
2423
+ var VERSION = "0.2.1";
2224
2424
  // Annotate the CommonJS export names for ESM import in node:
2225
2425
  0 && (module.exports = {
2226
2426
  AddElementCommand,
2227
2427
  ArrowTool,
2428
+ AutoSave,
2228
2429
  Background,
2229
2430
  BatchCommand,
2230
2431
  Camera,