@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.js CHANGED
@@ -89,8 +89,74 @@ function migrateElement(obj) {
89
89
  if (obj["type"] === "arrow" && typeof obj["bend"] !== "number") {
90
90
  obj["bend"] = 0;
91
91
  }
92
+ if (obj["type"] === "stroke" && Array.isArray(obj["points"])) {
93
+ for (const pt of obj["points"]) {
94
+ if (typeof pt["pressure"] !== "number") {
95
+ pt["pressure"] = 0.5;
96
+ }
97
+ }
98
+ }
92
99
  }
93
100
 
101
+ // src/core/auto-save.ts
102
+ var DEFAULT_KEY = "fieldnotes-autosave";
103
+ var DEFAULT_DEBOUNCE_MS = 1e3;
104
+ var AutoSave = class {
105
+ constructor(store, camera, options = {}) {
106
+ this.store = store;
107
+ this.camera = camera;
108
+ this.key = options.key ?? DEFAULT_KEY;
109
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
110
+ }
111
+ key;
112
+ debounceMs;
113
+ timerId = null;
114
+ unsubscribers = [];
115
+ start() {
116
+ const schedule = () => this.scheduleSave();
117
+ this.unsubscribers = [
118
+ this.store.on("add", schedule),
119
+ this.store.on("remove", schedule),
120
+ this.store.on("update", schedule),
121
+ this.camera.onChange(schedule)
122
+ ];
123
+ }
124
+ stop() {
125
+ this.cancelPending();
126
+ this.unsubscribers.forEach((fn) => fn());
127
+ this.unsubscribers = [];
128
+ }
129
+ load() {
130
+ if (typeof localStorage === "undefined") return null;
131
+ const json = localStorage.getItem(this.key);
132
+ if (!json) return null;
133
+ try {
134
+ return parseState(json);
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+ clear() {
140
+ if (typeof localStorage === "undefined") return;
141
+ localStorage.removeItem(this.key);
142
+ }
143
+ scheduleSave() {
144
+ this.cancelPending();
145
+ this.timerId = setTimeout(() => this.save(), this.debounceMs);
146
+ }
147
+ cancelPending() {
148
+ if (this.timerId !== null) {
149
+ clearTimeout(this.timerId);
150
+ this.timerId = null;
151
+ }
152
+ }
153
+ save() {
154
+ if (typeof localStorage === "undefined") return;
155
+ const state = exportState(this.store.snapshot(), this.camera);
156
+ localStorage.setItem(this.key, JSON.stringify(state));
157
+ }
158
+ };
159
+
94
160
  // src/canvas/camera.ts
95
161
  var DEFAULT_MIN_ZOOM = 0.1;
96
162
  var DEFAULT_MAX_ZOOM = 10;
@@ -285,24 +351,8 @@ var InputHandler = class {
285
351
  y: e.clientY - rect.top
286
352
  });
287
353
  };
288
- isInteractiveHtmlContent(e) {
289
- const target = e.target;
290
- if (!target) return false;
291
- const node = target.closest("[data-element-id]");
292
- if (!node) return false;
293
- const elementId = node.dataset["elementId"];
294
- if (!elementId) return false;
295
- const store = this.toolContext?.store;
296
- if (!store) return false;
297
- const element = store.getById(elementId);
298
- if (!element || element.type !== "html") return false;
299
- return true;
300
- }
301
354
  onPointerDown = (e) => {
302
355
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
303
- if (this.isInteractiveHtmlContent(e)) {
304
- return;
305
- }
306
356
  this.element.setPointerCapture?.(e.pointerId);
307
357
  if (this.activePointers.size === 2) {
308
358
  this.startPinch();
@@ -628,6 +678,79 @@ function isNearLine(point, a, b, threshold) {
628
678
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
629
679
  }
630
680
 
681
+ // src/elements/stroke-smoothing.ts
682
+ var MIN_PRESSURE_SCALE = 0.2;
683
+ function pressureToWidth(pressure, baseWidth) {
684
+ return baseWidth * (MIN_PRESSURE_SCALE + (1 - MIN_PRESSURE_SCALE) * pressure);
685
+ }
686
+ function simplifyPoints(points, tolerance) {
687
+ if (points.length <= 2) return points.slice();
688
+ return rdp(points, 0, points.length - 1, tolerance);
689
+ }
690
+ function rdp(points, start, end, tolerance) {
691
+ const first = points[start];
692
+ const last = points[end];
693
+ if (!first || !last) return [];
694
+ if (end - start <= 1) return [first, last];
695
+ let maxDist = 0;
696
+ let maxIndex = start;
697
+ for (let i = start + 1; i < end; i++) {
698
+ const pt = points[i];
699
+ if (!pt) continue;
700
+ const dist = perpendicularDistance(pt, first, last);
701
+ if (dist > maxDist) {
702
+ maxDist = dist;
703
+ maxIndex = i;
704
+ }
705
+ }
706
+ if (maxDist <= tolerance) return [first, last];
707
+ const left = rdp(points, start, maxIndex, tolerance);
708
+ const right = rdp(points, maxIndex, end, tolerance);
709
+ return left.concat(right.slice(1));
710
+ }
711
+ function perpendicularDistance(pt, lineStart, lineEnd) {
712
+ const dx = lineEnd.x - lineStart.x;
713
+ const dy = lineEnd.y - lineStart.y;
714
+ const lenSq = dx * dx + dy * dy;
715
+ if (lenSq === 0) {
716
+ const ex = pt.x - lineStart.x;
717
+ const ey = pt.y - lineStart.y;
718
+ return Math.sqrt(ex * ex + ey * ey);
719
+ }
720
+ const num = Math.abs(dy * pt.x - dx * pt.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x);
721
+ return num / Math.sqrt(lenSq);
722
+ }
723
+ function smoothToSegments(points) {
724
+ if (points.length < 2) return [];
725
+ if (points.length === 2) {
726
+ const p0 = points[0];
727
+ const p1 = points[1];
728
+ if (!p0 || !p1) return [];
729
+ const mx = (p0.x + p1.x) / 2;
730
+ const my = (p0.y + p1.y) / 2;
731
+ return [{ start: p0, cp1: { x: mx, y: my }, cp2: { x: mx, y: my }, end: p1 }];
732
+ }
733
+ const segments = [];
734
+ const n = points.length;
735
+ for (let i = 0; i < n - 1; i++) {
736
+ const p0 = points[Math.max(0, i - 1)];
737
+ const p1 = points[i];
738
+ const p2 = points[i + 1];
739
+ const p3 = points[Math.min(n - 1, i + 2)];
740
+ if (!p0 || !p1 || !p2 || !p3) continue;
741
+ const cp1 = {
742
+ x: p1.x + (p2.x - p0.x) / 6,
743
+ y: p1.y + (p2.y - p0.y) / 6
744
+ };
745
+ const cp2 = {
746
+ x: p2.x - (p3.x - p1.x) / 6,
747
+ y: p2.y - (p3.y - p1.y) / 6
748
+ };
749
+ segments.push({ start: p1, cp1, cp2, end: p2 });
750
+ }
751
+ return segments;
752
+ }
753
+
631
754
  // src/elements/element-renderer.ts
632
755
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html"]);
633
756
  var ARROWHEAD_LENGTH = 12;
@@ -651,22 +774,18 @@ var ElementRenderer = class {
651
774
  ctx.save();
652
775
  ctx.translate(stroke.position.x, stroke.position.y);
653
776
  ctx.strokeStyle = stroke.color;
654
- ctx.lineWidth = stroke.width;
655
777
  ctx.lineCap = "round";
656
778
  ctx.lineJoin = "round";
657
779
  ctx.globalAlpha = stroke.opacity;
658
- ctx.beginPath();
659
- const first = stroke.points[0];
660
- if (first) {
661
- ctx.moveTo(first.x, first.y);
662
- }
663
- for (let i = 1; i < stroke.points.length; i++) {
664
- const pt = stroke.points[i];
665
- if (pt) {
666
- ctx.lineTo(pt.x, pt.y);
667
- }
780
+ const segments = smoothToSegments(stroke.points);
781
+ for (const seg of segments) {
782
+ const w = (pressureToWidth(seg.start.pressure, stroke.width) + pressureToWidth(seg.end.pressure, stroke.width)) / 2;
783
+ ctx.lineWidth = w;
784
+ ctx.beginPath();
785
+ ctx.moveTo(seg.start.x, seg.start.y);
786
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
787
+ ctx.stroke();
668
788
  }
669
- ctx.stroke();
670
789
  ctx.restore();
671
790
  }
672
791
  renderArrow(ctx, arrow) {
@@ -809,6 +928,9 @@ var ToolManager = class {
809
928
  register(tool) {
810
929
  this.tools.set(tool.name, tool);
811
930
  }
931
+ getTool(name) {
932
+ return this.tools.get(name);
933
+ }
812
934
  setTool(name, ctx) {
813
935
  const tool = this.tools.get(name);
814
936
  if (!tool) return;
@@ -1168,6 +1290,7 @@ var Viewport = class {
1168
1290
  needsRender = true;
1169
1291
  domNodes = /* @__PURE__ */ new Map();
1170
1292
  htmlContent = /* @__PURE__ */ new Map();
1293
+ interactingElementId = null;
1171
1294
  get ctx() {
1172
1295
  return this.canvasEl.getContext("2d");
1173
1296
  }
@@ -1213,6 +1336,7 @@ var Viewport = class {
1213
1336
  this.store.add(image);
1214
1337
  this.historyRecorder.commit();
1215
1338
  this.requestRender();
1339
+ return image.id;
1216
1340
  }
1217
1341
  addHtmlElement(dom, position, size = { w: 200, h: 150 }) {
1218
1342
  const el = createHtmlElement({ position, size });
@@ -1225,6 +1349,7 @@ var Viewport = class {
1225
1349
  }
1226
1350
  destroy() {
1227
1351
  cancelAnimationFrame(this.animFrameId);
1352
+ this.stopInteracting();
1228
1353
  this.noteEditor.destroy(this.store);
1229
1354
  this.historyRecorder.destroy();
1230
1355
  this.wrapper.removeEventListener("dblclick", this.onDblClick);
@@ -1282,11 +1407,69 @@ var Viewport = class {
1282
1407
  }
1283
1408
  onDblClick = (e) => {
1284
1409
  const el = document.elementFromPoint(e.clientX, e.clientY);
1285
- if (!el) return;
1286
- const nodeEl = el.closest("[data-element-id]");
1287
- if (!nodeEl) return;
1288
- const elementId = nodeEl.dataset["elementId"];
1289
- if (elementId) this.startEditingNote(elementId);
1410
+ const nodeEl = el?.closest("[data-element-id]");
1411
+ if (nodeEl) {
1412
+ const elementId = nodeEl.dataset["elementId"];
1413
+ if (elementId) {
1414
+ const element = this.store.getById(elementId);
1415
+ if (element?.type === "note") {
1416
+ this.startEditingNote(elementId);
1417
+ return;
1418
+ }
1419
+ }
1420
+ }
1421
+ const rect = this.wrapper.getBoundingClientRect();
1422
+ const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
1423
+ const world = this.camera.screenToWorld(screen);
1424
+ const hit = this.hitTestWorld(world);
1425
+ if (hit?.type === "html") {
1426
+ this.startInteracting(hit.id);
1427
+ }
1428
+ };
1429
+ hitTestWorld(world) {
1430
+ const elements = this.store.getAll().reverse();
1431
+ for (const el of elements) {
1432
+ if (!("size" in el)) continue;
1433
+ const { x, y } = el.position;
1434
+ const { w, h } = el.size;
1435
+ if (world.x >= x && world.x <= x + w && world.y >= y && world.y <= y + h) {
1436
+ return el;
1437
+ }
1438
+ }
1439
+ return null;
1440
+ }
1441
+ startInteracting(id) {
1442
+ this.stopInteracting();
1443
+ const node = this.domNodes.get(id);
1444
+ if (!node) return;
1445
+ this.interactingElementId = id;
1446
+ node.style.pointerEvents = "auto";
1447
+ window.addEventListener("keydown", this.onInteractKeyDown);
1448
+ window.addEventListener("pointerdown", this.onInteractPointerDown);
1449
+ }
1450
+ stopInteracting() {
1451
+ if (!this.interactingElementId) return;
1452
+ const node = this.domNodes.get(this.interactingElementId);
1453
+ if (node) {
1454
+ node.style.pointerEvents = "none";
1455
+ }
1456
+ this.interactingElementId = null;
1457
+ window.removeEventListener("keydown", this.onInteractKeyDown);
1458
+ window.removeEventListener("pointerdown", this.onInteractPointerDown);
1459
+ }
1460
+ onInteractKeyDown = (e) => {
1461
+ if (e.key === "Escape") {
1462
+ this.stopInteracting();
1463
+ }
1464
+ };
1465
+ onInteractPointerDown = (e) => {
1466
+ if (!this.interactingElementId) return;
1467
+ const target = e.target;
1468
+ if (!target) return;
1469
+ const node = this.domNodes.get(this.interactingElementId);
1470
+ if (node && !node.contains(target)) {
1471
+ this.stopInteracting();
1472
+ }
1290
1473
  };
1291
1474
  onDragOver = (e) => {
1292
1475
  e.preventDefault();
@@ -1384,7 +1567,8 @@ var Viewport = class {
1384
1567
  if (content) {
1385
1568
  node.dataset["initialized"] = "true";
1386
1569
  Object.assign(node.style, {
1387
- overflow: "hidden"
1570
+ overflow: "hidden",
1571
+ pointerEvents: "none"
1388
1572
  });
1389
1573
  node.appendChild(content);
1390
1574
  }
@@ -1487,15 +1671,19 @@ var HandTool = class {
1487
1671
 
1488
1672
  // src/tools/pencil-tool.ts
1489
1673
  var MIN_POINTS_FOR_STROKE = 2;
1674
+ var DEFAULT_SMOOTHING = 1.5;
1675
+ var DEFAULT_PRESSURE = 0.5;
1490
1676
  var PencilTool = class {
1491
1677
  name = "pencil";
1492
1678
  drawing = false;
1493
1679
  points = [];
1494
1680
  color;
1495
1681
  width;
1682
+ smoothing;
1496
1683
  constructor(options = {}) {
1497
1684
  this.color = options.color ?? "#000000";
1498
1685
  this.width = options.width ?? 2;
1686
+ this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
1499
1687
  }
1500
1688
  onActivate(ctx) {
1501
1689
  ctx.setCursor?.("crosshair");
@@ -1506,16 +1694,19 @@ var PencilTool = class {
1506
1694
  setOptions(options) {
1507
1695
  if (options.color !== void 0) this.color = options.color;
1508
1696
  if (options.width !== void 0) this.width = options.width;
1697
+ if (options.smoothing !== void 0) this.smoothing = options.smoothing;
1509
1698
  }
1510
1699
  onPointerDown(state, ctx) {
1511
1700
  this.drawing = true;
1512
1701
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1513
- this.points = [world];
1702
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
1703
+ this.points = [{ x: world.x, y: world.y, pressure }];
1514
1704
  }
1515
1705
  onPointerMove(state, ctx) {
1516
1706
  if (!this.drawing) return;
1517
1707
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1518
- this.points.push(world);
1708
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
1709
+ this.points.push({ x: world.x, y: world.y, pressure });
1519
1710
  ctx.requestRender();
1520
1711
  }
1521
1712
  onPointerUp(_state, ctx) {
@@ -1525,8 +1716,9 @@ var PencilTool = class {
1525
1716
  this.points = [];
1526
1717
  return;
1527
1718
  }
1719
+ const simplified = simplifyPoints(this.points, this.smoothing);
1528
1720
  const stroke = createStroke({
1529
- points: this.points,
1721
+ points: simplified,
1530
1722
  color: this.color,
1531
1723
  width: this.width
1532
1724
  });
@@ -1538,19 +1730,18 @@ var PencilTool = class {
1538
1730
  if (!this.drawing || this.points.length < 2) return;
1539
1731
  ctx.save();
1540
1732
  ctx.strokeStyle = this.color;
1541
- ctx.lineWidth = this.width;
1542
1733
  ctx.lineCap = "round";
1543
1734
  ctx.lineJoin = "round";
1544
1735
  ctx.globalAlpha = 0.8;
1545
- ctx.beginPath();
1546
- const first = this.points[0];
1547
- if (!first) return;
1548
- ctx.moveTo(first.x, first.y);
1549
- for (let i = 1; i < this.points.length; i++) {
1550
- const p = this.points[i];
1551
- if (p) ctx.lineTo(p.x, p.y);
1736
+ const segments = smoothToSegments(this.points);
1737
+ for (const seg of segments) {
1738
+ const w = (pressureToWidth(seg.start.pressure, this.width) + pressureToWidth(seg.end.pressure, this.width)) / 2;
1739
+ ctx.lineWidth = w;
1740
+ ctx.beginPath();
1741
+ ctx.moveTo(seg.start.x, seg.start.y);
1742
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
1743
+ ctx.stroke();
1552
1744
  }
1553
- ctx.stroke();
1554
1745
  ctx.restore();
1555
1746
  }
1556
1747
  };
@@ -1838,7 +2029,7 @@ var SelectTool = class {
1838
2029
  handleResize(world, ctx) {
1839
2030
  if (this.mode.type !== "resizing") return;
1840
2031
  const el = ctx.store.getById(this.mode.elementId);
1841
- if (!el || !("size" in el)) return;
2032
+ if (!el || !("size" in el) || el.locked) return;
1842
2033
  const { handle } = this.mode;
1843
2034
  const dx = world.x - this.lastWorld.x;
1844
2035
  const dy = world.y - this.lastWorld.y;
@@ -2045,6 +2236,10 @@ var ArrowTool = class {
2045
2236
  this.color = options.color ?? "#000000";
2046
2237
  this.width = options.width ?? 2;
2047
2238
  }
2239
+ setOptions(options) {
2240
+ if (options.color !== void 0) this.color = options.color;
2241
+ if (options.width !== void 0) this.width = options.width;
2242
+ }
2048
2243
  onPointerDown(state, ctx) {
2049
2244
  this.drawing = true;
2050
2245
  this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
@@ -2109,6 +2304,10 @@ var NoteTool = class {
2109
2304
  this.backgroundColor = options.backgroundColor ?? "#ffeb3b";
2110
2305
  this.size = options.size ?? { w: 200, h: 100 };
2111
2306
  }
2307
+ setOptions(options) {
2308
+ if (options.backgroundColor !== void 0) this.backgroundColor = options.backgroundColor;
2309
+ if (options.size !== void 0) this.size = options.size;
2310
+ }
2112
2311
  onPointerDown(_state, _ctx) {
2113
2312
  }
2114
2313
  onPointerMove(_state, _ctx) {
@@ -2158,10 +2357,11 @@ var ImageTool = class {
2158
2357
  };
2159
2358
 
2160
2359
  // src/index.ts
2161
- var VERSION = "0.1.2";
2360
+ var VERSION = "0.2.1";
2162
2361
  export {
2163
2362
  AddElementCommand,
2164
2363
  ArrowTool,
2364
+ AutoSave,
2165
2365
  Background,
2166
2366
  BatchCommand,
2167
2367
  Camera,