@fieldnotes/core 0.13.0 → 0.15.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
@@ -285,16 +285,24 @@ function sanitizeAttributes(el, tag) {
285
285
 
286
286
  // src/core/state-serializer.ts
287
287
  var CURRENT_VERSION = 2;
288
- function exportState(elements, camera, layers = []) {
289
- return {
288
+ function exportState(elements, camera, layers = [], activeLayerId) {
289
+ const state = {
290
290
  version: CURRENT_VERSION,
291
291
  camera: {
292
292
  position: { ...camera.position },
293
293
  zoom: camera.zoom
294
294
  },
295
- elements: elements.map((el) => structuredClone(el)),
295
+ elements: elements.map((el) => {
296
+ const clone = structuredClone(el);
297
+ if (clone.type === "arrow") {
298
+ delete clone.cachedControlPoint;
299
+ }
300
+ return clone;
301
+ }),
296
302
  layers: layers.map((l) => ({ ...l }))
297
303
  };
304
+ if (activeLayerId) state.activeLayerId = activeLayerId;
305
+ return state;
298
306
  }
299
307
  function parseState(json) {
300
308
  const data = JSON.parse(json);
@@ -452,12 +460,14 @@ var AutoSave = class {
452
460
  this.key = options.key ?? DEFAULT_KEY;
453
461
  this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
454
462
  this.layerManager = options.layerManager;
463
+ this.onError = options.onError;
455
464
  }
456
465
  key;
457
466
  debounceMs;
458
467
  layerManager;
459
468
  timerId = null;
460
469
  unsubscribers = [];
470
+ onError;
461
471
  start() {
462
472
  const schedule = () => this.scheduleSave();
463
473
  this.unsubscribers = [
@@ -505,8 +515,9 @@ var AutoSave = class {
505
515
  const state = exportState(this.store.snapshot(), this.camera, layers);
506
516
  try {
507
517
  localStorage.setItem(this.key, JSON.stringify(state));
508
- } catch {
518
+ } catch (e) {
509
519
  console.warn("Auto-save failed: storage quota exceeded. State too large for localStorage.");
520
+ this.onError?.(e instanceof Error ? e : new Error(String(e)));
510
521
  }
511
522
  }
512
523
  };
@@ -575,6 +586,15 @@ var Camera = class {
575
586
  h: bottomRight.y - topLeft.y
576
587
  };
577
588
  }
589
+ fitToContent(boundingBox, canvasWidth, canvasHeight, padding = 40) {
590
+ if (boundingBox.w === 0 && boundingBox.h === 0) return;
591
+ const scaleX = canvasWidth / (boundingBox.w + 2 * padding);
592
+ const scaleY = canvasHeight / (boundingBox.h + 2 * padding);
593
+ this.z = Math.min(this.maxZoom, Math.max(this.minZoom, Math.min(scaleX, scaleY)));
594
+ this.x = (canvasWidth - boundingBox.w * this.z) / 2 - boundingBox.x * this.z;
595
+ this.y = (canvasHeight - boundingBox.h * this.z) / 2 - boundingBox.y * this.z;
596
+ this.notifyPanAndZoom();
597
+ }
578
598
  toCSSTransform() {
579
599
  return `translate3d(${this.x}px, ${this.y}px, 0) scale(${this.z})`;
580
600
  }
@@ -730,6 +750,67 @@ var Background = class {
730
750
  }
731
751
  };
732
752
 
753
+ // src/canvas/input-filter.ts
754
+ var InputFilter = class _InputFilter {
755
+ activePenId = null;
756
+ pendingTap = null;
757
+ static MIN_MOVE_DISTANCE = 3;
758
+ filterDown(e) {
759
+ if (e.pointerType === "pen") {
760
+ this.activePenId = e.pointerId;
761
+ return { event: e, action: "dispatch" };
762
+ }
763
+ if (e.pointerType === "touch" && this.activePenId !== null) {
764
+ return { event: e, action: "suppress" };
765
+ }
766
+ if (e.pointerType === "touch") {
767
+ this.pendingTap = { pointerId: e.pointerId, x: e.clientX, y: e.clientY };
768
+ return { event: e, action: "defer" };
769
+ }
770
+ return { event: e, action: "dispatch" };
771
+ }
772
+ filterMove(e) {
773
+ if (e.pointerType === "touch" && this.activePenId !== null) {
774
+ return { event: e, action: "suppress" };
775
+ }
776
+ if (this.pendingTap && e.pointerId === this.pendingTap.pointerId) {
777
+ const dx = e.clientX - this.pendingTap.x;
778
+ const dy = e.clientY - this.pendingTap.y;
779
+ if (dx * dx + dy * dy > _InputFilter.MIN_MOVE_DISTANCE * _InputFilter.MIN_MOVE_DISTANCE) {
780
+ this.pendingTap = null;
781
+ return { event: e, action: "dispatch" };
782
+ }
783
+ return { event: e, action: "suppress" };
784
+ }
785
+ return { event: e, action: "dispatch" };
786
+ }
787
+ filterUp(e) {
788
+ if (e.pointerId === this.activePenId) {
789
+ this.activePenId = null;
790
+ return { event: e, action: "dispatch" };
791
+ }
792
+ if (e.pointerType === "touch" && this.activePenId !== null) {
793
+ return { event: e, action: "suppress" };
794
+ }
795
+ if (this.pendingTap && e.pointerId === this.pendingTap.pointerId) {
796
+ const tap = { x: this.pendingTap.x, y: this.pendingTap.y };
797
+ this.pendingTap = null;
798
+ return { event: e, action: "dispatch", pendingTap: tap };
799
+ }
800
+ return { event: e, action: "dispatch" };
801
+ }
802
+ reset() {
803
+ this.activePenId = null;
804
+ this.pendingTap = null;
805
+ }
806
+ };
807
+
808
+ // src/elements/create-id.ts
809
+ var counter = 0;
810
+ function createId(prefix) {
811
+ return `${prefix}_${Date.now().toString(36)}_${(counter++).toString(36)}`;
812
+ }
813
+
733
814
  // src/canvas/input-handler.ts
734
815
  var ZOOM_SENSITIVITY = 1e-3;
735
816
  var MIDDLE_BUTTON = 1;
@@ -755,13 +836,21 @@ var InputHandler = class {
755
836
  historyRecorder;
756
837
  historyStack;
757
838
  isToolActive = false;
839
+ lastPointerEvent = null;
840
+ inputFilter = new InputFilter();
841
+ deferredDown = null;
758
842
  abortController = new AbortController();
843
+ clipboard = [];
844
+ pasteCount = 0;
759
845
  setToolManager(toolManager, toolContext) {
760
846
  this.toolManager = toolManager;
761
847
  this.toolContext = toolContext;
762
848
  }
763
849
  destroy() {
764
850
  this.abortController.abort();
851
+ this.inputFilter.reset();
852
+ this.deferredDown = null;
853
+ this.lastPointerEvent = null;
765
854
  }
766
855
  bind() {
767
856
  const opts = { signal: this.abortController.signal };
@@ -797,11 +886,18 @@ var InputHandler = class {
797
886
  this.lastPointer = { x: e.clientX, y: e.clientY };
798
887
  return;
799
888
  }
800
- if (this.activePointers.size === 1 && e.button === 0) {
889
+ if (this.activePointers.size === 1 && (e.button === 0 || e.pointerType === "touch" || e.pointerType === "pen")) {
890
+ const result = this.inputFilter.filterDown(e);
891
+ if (result.action === "suppress") return;
892
+ if (result.action === "defer") {
893
+ this.deferredDown = e;
894
+ return;
895
+ }
801
896
  this.dispatchToolDown(e);
802
897
  }
803
898
  };
804
899
  onPointerMove = (e) => {
900
+ this.lastPointerEvent = e;
805
901
  if (this.activePointers.has(e.pointerId)) {
806
902
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
807
903
  }
@@ -816,13 +912,26 @@ var InputHandler = class {
816
912
  this.camera.pan(dx, dy);
817
913
  return;
818
914
  }
819
- if (this.isToolActive) {
915
+ if (e.pointerType === "pen" && !this.activePointers.has(e.pointerId)) {
916
+ this.dispatchToolHover(e);
917
+ } else if (this.isToolActive) {
820
918
  this.dispatchToolMove(e);
919
+ } else if (this.deferredDown) {
920
+ const result = this.inputFilter.filterMove(e);
921
+ if (result.action === "dispatch") {
922
+ this.dispatchToolDown(this.deferredDown);
923
+ this.deferredDown = null;
924
+ this.dispatchToolMove(e);
925
+ }
821
926
  } else if (this.activePointers.size === 0) {
822
927
  this.dispatchToolHover(e);
823
928
  }
824
929
  };
825
930
  onPointerUp = (e) => {
931
+ try {
932
+ this.element.releasePointerCapture(e.pointerId);
933
+ } catch {
934
+ }
826
935
  this.activePointers.delete(e.pointerId);
827
936
  if (this.activePointers.size < 2) {
828
937
  this.lastPinchDistance = 0;
@@ -830,9 +939,16 @@ var InputHandler = class {
830
939
  if (this.isPanning && this.activePointers.size === 0) {
831
940
  this.isPanning = false;
832
941
  }
942
+ const upResult = this.inputFilter.filterUp(e);
833
943
  if (this.isToolActive) {
834
944
  this.dispatchToolUp(e);
835
945
  this.isToolActive = false;
946
+ } else if (this.deferredDown && upResult.pendingTap) {
947
+ this.dispatchToolDown(this.deferredDown);
948
+ this.dispatchToolUp(e);
949
+ this.deferredDown = null;
950
+ } else {
951
+ this.deferredDown = null;
836
952
  }
837
953
  };
838
954
  onKeyDown = (e) => {
@@ -851,13 +967,38 @@ var InputHandler = class {
851
967
  e.preventDefault();
852
968
  this.handleRedo();
853
969
  }
970
+ if ((e.ctrlKey || e.metaKey) && e.key === "c") {
971
+ e.preventDefault();
972
+ this.handleCopy();
973
+ }
974
+ if ((e.ctrlKey || e.metaKey) && e.key === "v") {
975
+ e.preventDefault();
976
+ this.handlePaste();
977
+ }
978
+ if (e.key === "]") {
979
+ e.preventDefault();
980
+ this.handleZOrder(e.ctrlKey || e.metaKey ? "front" : "forward");
981
+ }
982
+ if (e.key === "[") {
983
+ e.preventDefault();
984
+ this.handleZOrder(e.ctrlKey || e.metaKey ? "back" : "backward");
985
+ }
854
986
  };
855
987
  onKeyUp = (e) => {
856
988
  if (e.key === " ") {
857
989
  this.spaceHeld = false;
990
+ if (this.activePointers.size === 0) {
991
+ if (this.lastPointerEvent) {
992
+ this.dispatchToolHover(this.lastPointerEvent);
993
+ } else {
994
+ this.toolContext?.setCursor?.("default");
995
+ }
996
+ }
858
997
  }
859
998
  };
860
999
  startPinch() {
1000
+ this.inputFilter.reset();
1001
+ this.deferredDown = null;
861
1002
  this.isPanning = true;
862
1003
  const [a, b] = this.getPinchPoints();
863
1004
  this.lastPinchDistance = this.distance(a, b);
@@ -897,7 +1038,9 @@ var InputHandler = class {
897
1038
  return {
898
1039
  x: e.clientX - rect.left,
899
1040
  y: e.clientY - rect.top,
900
- pressure: e.pressure
1041
+ pressure: e.pressure,
1042
+ pointerType: e.pointerType === "touch" || e.pointerType === "pen" ? e.pointerType : "mouse",
1043
+ shiftKey: e.shiftKey
901
1044
  };
902
1045
  }
903
1046
  dispatchToolDown(e) {
@@ -950,11 +1093,147 @@ var InputHandler = class {
950
1093
  this.historyRecorder?.resume();
951
1094
  this.toolContext.requestRender();
952
1095
  }
1096
+ handleCopy() {
1097
+ if (!this.toolManager || !this.toolContext || this.isToolActive) return;
1098
+ const tool = this.toolManager.activeTool;
1099
+ if (tool?.name !== "select") return;
1100
+ const selectTool = tool;
1101
+ const ids = selectTool.selectedIds;
1102
+ if (ids.length === 0) return;
1103
+ this.clipboard = [];
1104
+ for (const id of ids) {
1105
+ const el = this.toolContext.store.getById(id);
1106
+ if (el) this.clipboard.push(structuredClone(el));
1107
+ }
1108
+ this.pasteCount = 0;
1109
+ }
1110
+ handlePaste() {
1111
+ if (!this.toolManager || !this.toolContext || this.clipboard.length === 0 || this.isToolActive)
1112
+ return;
1113
+ const tool = this.toolManager.activeTool;
1114
+ if (tool?.name !== "select") return;
1115
+ const selectTool = tool;
1116
+ this.pasteCount++;
1117
+ const offset = this.pasteCount * 20;
1118
+ const idMap = /* @__PURE__ */ new Map();
1119
+ for (const el of this.clipboard) {
1120
+ idMap.set(el.id, createId(el.type));
1121
+ }
1122
+ const newIds = [];
1123
+ this.historyRecorder?.begin();
1124
+ for (const el of this.clipboard) {
1125
+ const clone = structuredClone(el);
1126
+ const newId = idMap.get(el.id);
1127
+ if (!newId) continue;
1128
+ clone.id = newId;
1129
+ clone.position = { x: clone.position.x + offset, y: clone.position.y + offset };
1130
+ if (clone.type === "arrow") {
1131
+ const arrow = clone;
1132
+ arrow.from = { x: arrow.from.x + offset, y: arrow.from.y + offset };
1133
+ arrow.to = { x: arrow.to.x + offset, y: arrow.to.y + offset };
1134
+ delete arrow.cachedControlPoint;
1135
+ if (arrow.fromBinding) {
1136
+ const newTarget = idMap.get(arrow.fromBinding.elementId);
1137
+ if (newTarget) {
1138
+ arrow.fromBinding = { elementId: newTarget };
1139
+ } else {
1140
+ delete arrow.fromBinding;
1141
+ }
1142
+ }
1143
+ if (arrow.toBinding) {
1144
+ const newTarget = idMap.get(arrow.toBinding.elementId);
1145
+ if (newTarget) {
1146
+ arrow.toBinding = { elementId: newTarget };
1147
+ } else {
1148
+ delete arrow.toBinding;
1149
+ }
1150
+ }
1151
+ }
1152
+ if (this.toolContext.activeLayerId) {
1153
+ clone.layerId = this.toolContext.activeLayerId;
1154
+ }
1155
+ this.toolContext.store.add(clone);
1156
+ newIds.push(clone.id);
1157
+ }
1158
+ this.historyRecorder?.commit();
1159
+ selectTool.setSelection(newIds);
1160
+ this.toolContext.requestRender();
1161
+ }
1162
+ handleZOrder(operation) {
1163
+ if (!this.toolManager || !this.toolContext) return;
1164
+ const tool = this.toolManager.activeTool;
1165
+ if (tool?.name !== "select") return;
1166
+ const selectTool = tool;
1167
+ const ids = selectTool.selectedIds;
1168
+ if (ids.length === 0) return;
1169
+ this.historyRecorder?.begin();
1170
+ for (const id of ids) {
1171
+ switch (operation) {
1172
+ case "forward":
1173
+ this.toolContext.store.bringForward(id);
1174
+ break;
1175
+ case "backward":
1176
+ this.toolContext.store.sendBackward(id);
1177
+ break;
1178
+ case "front":
1179
+ this.toolContext.store.bringToFront(id);
1180
+ break;
1181
+ case "back":
1182
+ this.toolContext.store.sendToBack(id);
1183
+ break;
1184
+ }
1185
+ }
1186
+ this.historyRecorder?.commit();
1187
+ this.toolContext.requestRender();
1188
+ }
953
1189
  cancelToolIfActive(e) {
954
1190
  if (this.isToolActive) {
955
1191
  this.dispatchToolUp(e);
956
1192
  this.isToolActive = false;
957
1193
  }
1194
+ this.deferredDown = null;
1195
+ }
1196
+ };
1197
+
1198
+ // src/canvas/double-tap-detector.ts
1199
+ var DEFAULT_TIMEOUT = 300;
1200
+ var DEFAULT_MAX_DISTANCE = 20;
1201
+ var DoubleTapDetector = class {
1202
+ timeout;
1203
+ maxDistance;
1204
+ lastTapTime = 0;
1205
+ lastTapX = 0;
1206
+ lastTapY = 0;
1207
+ hasPendingTap = false;
1208
+ constructor(options) {
1209
+ this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1210
+ this.maxDistance = options?.maxDistance ?? DEFAULT_MAX_DISTANCE;
1211
+ }
1212
+ feed(e) {
1213
+ const now = Date.now();
1214
+ const x = e.clientX;
1215
+ const y = e.clientY;
1216
+ if (this.hasPendingTap) {
1217
+ const elapsed = now - this.lastTapTime;
1218
+ const dx = x - this.lastTapX;
1219
+ const dy = y - this.lastTapY;
1220
+ const dist = Math.sqrt(dx * dx + dy * dy);
1221
+ if (elapsed <= this.timeout && dist <= this.maxDistance) {
1222
+ this.reset();
1223
+ return true;
1224
+ }
1225
+ }
1226
+ this.lastTapTime = now;
1227
+ this.lastTapX = x;
1228
+ this.lastTapY = y;
1229
+ this.hasPendingTap = true;
1230
+ return false;
1231
+ }
1232
+ reset() {
1233
+ this.hasPendingTap = false;
1234
+ this.lastTapTime = 0;
1235
+ this.lastTapX = 0;
1236
+ this.lastTapY = 0;
958
1237
  }
959
1238
  };
960
1239
 
@@ -1202,19 +1481,27 @@ var ElementStore = class {
1202
1481
  bus = new EventBus();
1203
1482
  layerOrderMap = /* @__PURE__ */ new Map();
1204
1483
  spatialIndex = new Quadtree({ x: -1e5, y: -1e5, w: 2e5, h: 2e5 });
1484
+ sortedCache = null;
1485
+ _versions = /* @__PURE__ */ new Map();
1205
1486
  get count() {
1206
1487
  return this.elements.size;
1207
1488
  }
1489
+ getVersion(id) {
1490
+ return this._versions.get(id) ?? -1;
1491
+ }
1208
1492
  setLayerOrder(order) {
1209
1493
  this.layerOrderMap = new Map(order);
1494
+ this.sortedCache = null;
1210
1495
  }
1211
1496
  getAll() {
1212
- return [...this.elements.values()].sort((a, b) => {
1497
+ if (this.sortedCache) return this.sortedCache;
1498
+ this.sortedCache = [...this.elements.values()].sort((a, b) => {
1213
1499
  const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
1214
1500
  const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
1215
1501
  if (layerA !== layerB) return layerA - layerB;
1216
1502
  return a.zIndex - b.zIndex;
1217
1503
  });
1504
+ return this.sortedCache;
1218
1505
  }
1219
1506
  getById(id) {
1220
1507
  return this.elements.get(id);
@@ -1225,6 +1512,8 @@ var ElementStore = class {
1225
1512
  );
1226
1513
  }
1227
1514
  add(element) {
1515
+ this.sortedCache = null;
1516
+ this._versions.set(element.id, 0);
1228
1517
  this.elements.set(element.id, element);
1229
1518
  const bounds = getElementBounds(element);
1230
1519
  if (bounds) this.spatialIndex.insert(element.id, bounds);
@@ -1233,11 +1522,16 @@ var ElementStore = class {
1233
1522
  update(id, partial) {
1234
1523
  const existing = this.elements.get(id);
1235
1524
  if (!existing) return;
1525
+ this.sortedCache = null;
1526
+ this._versions.set(id, (this._versions.get(id) ?? 0) + 1);
1236
1527
  const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
1237
1528
  if (updated.type === "arrow") {
1238
1529
  const arrow = updated;
1239
1530
  arrow.cachedControlPoint = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
1240
1531
  }
1532
+ if (updated.type === "note" && "text" in partial) {
1533
+ updated.text = sanitizeNoteHtml(updated.text);
1534
+ }
1241
1535
  this.elements.set(id, updated);
1242
1536
  const newBounds = getElementBounds(updated);
1243
1537
  if (newBounds) {
@@ -1248,11 +1542,15 @@ var ElementStore = class {
1248
1542
  remove(id) {
1249
1543
  const element = this.elements.get(id);
1250
1544
  if (!element) return;
1545
+ this.sortedCache = null;
1546
+ this._versions.delete(id);
1251
1547
  this.elements.delete(id);
1252
1548
  this.spatialIndex.remove(id);
1253
1549
  this.bus.emit("remove", element);
1254
1550
  }
1255
1551
  clear() {
1552
+ this.sortedCache = null;
1553
+ this._versions.clear();
1256
1554
  this.elements.clear();
1257
1555
  this.spatialIndex.clear();
1258
1556
  this.bus.emit("clear", null);
@@ -1261,13 +1559,68 @@ var ElementStore = class {
1261
1559
  return this.getAll().map((el) => ({ ...el }));
1262
1560
  }
1263
1561
  loadSnapshot(elements) {
1562
+ this.sortedCache = null;
1563
+ this._versions.clear();
1264
1564
  this.elements.clear();
1265
1565
  this.spatialIndex.clear();
1266
1566
  for (const el of elements) {
1267
1567
  this.elements.set(el.id, el);
1568
+ this._versions.set(el.id, 0);
1268
1569
  const bounds = getElementBounds(el);
1269
1570
  if (bounds) this.spatialIndex.insert(el.id, bounds);
1270
1571
  }
1572
+ this.bus.emit("clear", null);
1573
+ for (const el of elements) {
1574
+ this.bus.emit("add", el);
1575
+ }
1576
+ }
1577
+ bringToFront(id) {
1578
+ const el = this.elements.get(id);
1579
+ if (!el) return;
1580
+ const siblings = [...this.elements.values()].filter(
1581
+ (e) => e.layerId === el.layerId && e.id !== id
1582
+ );
1583
+ if (siblings.length === 0) return;
1584
+ const maxZ = Math.max(...siblings.map((e) => e.zIndex));
1585
+ if (el.zIndex >= maxZ) return;
1586
+ this.update(id, { zIndex: maxZ + 1 });
1587
+ }
1588
+ sendToBack(id) {
1589
+ const el = this.elements.get(id);
1590
+ if (!el) return;
1591
+ const siblings = [...this.elements.values()].filter(
1592
+ (e) => e.layerId === el.layerId && e.id !== id
1593
+ );
1594
+ if (siblings.length === 0) return;
1595
+ const minZ = Math.min(...siblings.map((e) => e.zIndex));
1596
+ if (el.zIndex <= minZ) return;
1597
+ this.update(id, { zIndex: minZ - 1 });
1598
+ }
1599
+ bringForward(id) {
1600
+ const el = this.elements.get(id);
1601
+ if (!el) return;
1602
+ const sorted = [...this.elements.values()].filter((e) => e.layerId === el.layerId).sort((a, b) => a.zIndex - b.zIndex);
1603
+ const idx = sorted.findIndex((e) => e.id === id);
1604
+ if (idx < 0 || idx >= sorted.length - 1) return;
1605
+ const next = sorted[idx + 1];
1606
+ if (!next) return;
1607
+ const myZ = el.zIndex;
1608
+ const nextZ = next.zIndex;
1609
+ this.update(id, { zIndex: nextZ });
1610
+ this.update(next.id, { zIndex: myZ });
1611
+ }
1612
+ sendBackward(id) {
1613
+ const el = this.elements.get(id);
1614
+ if (!el) return;
1615
+ const sorted = [...this.elements.values()].filter((e) => e.layerId === el.layerId).sort((a, b) => a.zIndex - b.zIndex);
1616
+ const idx = sorted.findIndex((e) => e.id === id);
1617
+ if (idx <= 0) return;
1618
+ const prev = sorted[idx - 1];
1619
+ if (!prev) return;
1620
+ const myZ = el.zIndex;
1621
+ const prevZ = prev.zIndex;
1622
+ this.update(id, { zIndex: prevZ });
1623
+ this.update(prev.id, { zIndex: myZ });
1271
1624
  }
1272
1625
  queryRect(rect) {
1273
1626
  const ids = this.spatialIndex.query(rect);
@@ -2317,12 +2670,6 @@ var ElementRenderer = class {
2317
2670
  }
2318
2671
  };
2319
2672
 
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
2673
  // src/elements/element-factory.ts
2327
2674
  var DEFAULT_NOTE_FONT_SIZE = 18;
2328
2675
  function createStroke(input) {
@@ -2348,7 +2695,7 @@ function createNote(input) {
2348
2695
  locked: input.locked ?? false,
2349
2696
  layerId: input.layerId ?? "",
2350
2697
  size: input.size ?? { w: 200, h: 100 },
2351
- text: input.text ?? "",
2698
+ text: sanitizeNoteHtml(input.text ?? ""),
2352
2699
  backgroundColor: input.backgroundColor ?? "#ffeb3b",
2353
2700
  textColor: input.textColor ?? "#000000",
2354
2701
  fontSize: input.fontSize ?? DEFAULT_NOTE_FONT_SIZE
@@ -2397,6 +2744,7 @@ function createHtmlElement(input) {
2397
2744
  size: input.size
2398
2745
  };
2399
2746
  if (input.domId) el.domId = input.domId;
2747
+ if (input.interactive) el.interactive = input.interactive;
2400
2748
  return el;
2401
2749
  }
2402
2750
  function createShape(input) {
@@ -2509,7 +2857,7 @@ function getActiveFormats() {
2509
2857
  }
2510
2858
 
2511
2859
  // src/elements/note-toolbar.ts
2512
- var TOOLBAR_HEIGHT = 32;
2860
+ var TOOLBAR_HEIGHT = 52;
2513
2861
  var TOOLBAR_GAP = 4;
2514
2862
  var FORMAT_BUTTONS = [
2515
2863
  { label: "B", format: "bold", command: "bold" },
@@ -2596,9 +2944,9 @@ var NoteToolbar = class {
2596
2944
  fontWeight: config.format === "bold" ? "bold" : "normal",
2597
2945
  fontStyle: config.format === "italic" ? "italic" : "normal",
2598
2946
  textDecoration: config.format === "underline" ? "underline" : config.format === "strikethrough" ? "line-through" : "none",
2599
- minWidth: "24px",
2600
- height: "24px",
2601
- lineHeight: "24px"
2947
+ minWidth: "44px",
2948
+ height: "44px",
2949
+ lineHeight: "44px"
2602
2950
  });
2603
2951
  btn.addEventListener("pointerdown", (e) => {
2604
2952
  e.preventDefault();
@@ -2616,7 +2964,7 @@ var NoteToolbar = class {
2616
2964
  cursor: "pointer",
2617
2965
  padding: "2px",
2618
2966
  fontSize: "12px",
2619
- height: "24px",
2967
+ height: "44px",
2620
2968
  marginLeft: "4px"
2621
2969
  });
2622
2970
  for (const preset of this.fontSizePresets) {
@@ -3536,10 +3884,14 @@ var DomNodeManager = class {
3536
3884
  domLayer;
3537
3885
  onEditRequest;
3538
3886
  isEditingElement;
3887
+ getVersion;
3888
+ lastSyncedVersion = /* @__PURE__ */ new Map();
3889
+ lastSyncedZIndex = /* @__PURE__ */ new Map();
3539
3890
  constructor(deps) {
3540
3891
  this.domLayer = deps.domLayer;
3541
3892
  this.onEditRequest = deps.onEditRequest;
3542
3893
  this.isEditingElement = deps.isEditingElement;
3894
+ this.getVersion = deps.getVersion ?? null;
3543
3895
  }
3544
3896
  getNode(id) {
3545
3897
  return this.domNodes.get(id);
@@ -3558,6 +3910,17 @@ var DomNodeManager = class {
3558
3910
  });
3559
3911
  this.domLayer.appendChild(node);
3560
3912
  this.domNodes.set(element.id, node);
3913
+ } else if (this.getVersion) {
3914
+ const currentVersion = this.getVersion(element.id);
3915
+ const lastVersion = this.lastSyncedVersion.get(element.id);
3916
+ const lastZ = this.lastSyncedZIndex.get(element.id);
3917
+ if (lastVersion === currentVersion && lastZ === zIndex) {
3918
+ return;
3919
+ }
3920
+ }
3921
+ if (this.getVersion) {
3922
+ this.lastSyncedVersion.set(element.id, this.getVersion(element.id));
3923
+ this.lastSyncedZIndex.set(element.id, zIndex);
3561
3924
  }
3562
3925
  const size = "size" in element ? element.size : null;
3563
3926
  Object.assign(node.style, {
@@ -3576,6 +3939,8 @@ var DomNodeManager = class {
3576
3939
  }
3577
3940
  removeDomNode(id) {
3578
3941
  this.htmlContent.delete(id);
3942
+ this.lastSyncedVersion.delete(id);
3943
+ this.lastSyncedZIndex.delete(id);
3579
3944
  const node = this.domNodes.get(id);
3580
3945
  if (node) {
3581
3946
  node.remove();
@@ -3586,6 +3951,8 @@ var DomNodeManager = class {
3586
3951
  this.domNodes.forEach((node) => node.remove());
3587
3952
  this.domNodes.clear();
3588
3953
  this.htmlContent.clear();
3954
+ this.lastSyncedVersion.clear();
3955
+ this.lastSyncedZIndex.clear();
3589
3956
  }
3590
3957
  reattachHtmlContent(store) {
3591
3958
  for (const el of store.getElementsByType("html")) {
@@ -3614,10 +3981,13 @@ var DomNodeManager = class {
3614
3981
  wordWrap: "break-word"
3615
3982
  });
3616
3983
  node.innerHTML = element.text || "";
3617
- node.addEventListener("dblclick", (e) => {
3618
- e.stopPropagation();
3619
- const id = node.dataset["elementId"];
3620
- if (id) this.onEditRequest(id);
3984
+ const detector = new DoubleTapDetector();
3985
+ node.addEventListener("pointerup", (e) => {
3986
+ if (detector.feed(e)) {
3987
+ e.stopPropagation();
3988
+ const id = node.dataset["elementId"];
3989
+ if (id) this.onEditRequest(id);
3990
+ }
3621
3991
  });
3622
3992
  }
3623
3993
  if (!this.isEditingElement(element.id)) {
@@ -3630,15 +4000,19 @@ var DomNodeManager = class {
3630
4000
  node.style.fontSize = `${element.fontSize ?? DEFAULT_NOTE_FONT_SIZE}px`;
3631
4001
  }
3632
4002
  }
3633
- if (element.type === "html" && !node.dataset["initialized"]) {
3634
- const content = this.htmlContent.get(element.id);
3635
- if (content) {
3636
- node.dataset["initialized"] = "true";
3637
- Object.assign(node.style, {
3638
- overflow: "hidden",
3639
- pointerEvents: "none"
3640
- });
3641
- node.appendChild(content);
4003
+ if (element.type === "html") {
4004
+ if (!node.dataset["initialized"]) {
4005
+ const content = this.htmlContent.get(element.id);
4006
+ if (content) {
4007
+ node.dataset["initialized"] = "true";
4008
+ Object.assign(node.style, {
4009
+ overflow: "hidden",
4010
+ pointerEvents: element.interactive ? "auto" : "none"
4011
+ });
4012
+ node.appendChild(content);
4013
+ }
4014
+ } else {
4015
+ node.style.pointerEvents = element.interactive ? "auto" : "none";
3642
4016
  }
3643
4017
  }
3644
4018
  if (element.type === "text") {
@@ -3660,10 +4034,13 @@ var DomNodeManager = class {
3660
4034
  lineHeight: "1.4"
3661
4035
  });
3662
4036
  node.textContent = element.text || "";
3663
- node.addEventListener("dblclick", (e) => {
3664
- e.stopPropagation();
3665
- const id = node.dataset["elementId"];
3666
- if (id) this.onEditRequest(id);
4037
+ const detector = new DoubleTapDetector();
4038
+ node.addEventListener("pointerup", (e) => {
4039
+ if (detector.feed(e)) {
4040
+ e.stopPropagation();
4041
+ const id = node.dataset["elementId"];
4042
+ if (id) this.onEditRequest(id);
4043
+ }
3667
4044
  });
3668
4045
  }
3669
4046
  if (!this.isEditingElement(element.id)) {
@@ -4095,7 +4472,8 @@ var Viewport = class {
4095
4472
  this.domNodeManager = new DomNodeManager({
4096
4473
  domLayer: this.domLayer,
4097
4474
  onEditRequest: (id) => this.startEditingElement(id),
4098
- isEditingElement: (id) => this.noteEditor.isEditing && this.noteEditor.editingElementId === id
4475
+ isEditingElement: (id) => this.noteEditor.isEditing && this.noteEditor.editingElementId === id,
4476
+ getVersion: (id) => this.store.getVersion(id)
4099
4477
  });
4100
4478
  this.interactMode = new InteractMode({
4101
4479
  getNode: (id) => this.domNodeManager.getNode(id)
@@ -4152,7 +4530,8 @@ var Viewport = class {
4152
4530
  this.toolContext.activeLayerId = this.layerManager.activeLayerId;
4153
4531
  this.requestRender();
4154
4532
  });
4155
- this.wrapper.addEventListener("dblclick", this.onDblClick);
4533
+ this.wrapper.addEventListener("pointerdown", this.onTapDown);
4534
+ this.wrapper.addEventListener("pointerup", this.onDoubleTap);
4156
4535
  this.wrapper.addEventListener("dragover", this.onDragOver);
4157
4536
  this.wrapper.addEventListener("drop", this.onDrop);
4158
4537
  this.observeResize();
@@ -4183,6 +4562,9 @@ var Viewport = class {
4183
4562
  domNodeManager;
4184
4563
  interactMode;
4185
4564
  gridChangeListeners = /* @__PURE__ */ new Set();
4565
+ doubleTapDetector = new DoubleTapDetector();
4566
+ tapDownX = 0;
4567
+ tapDownY = 0;
4186
4568
  get ctx() {
4187
4569
  return this.canvasEl.getContext("2d");
4188
4570
  }
@@ -4197,7 +4579,12 @@ var Viewport = class {
4197
4579
  this.renderLoop.requestRender();
4198
4580
  }
4199
4581
  exportState() {
4200
- return exportState(this.store.snapshot(), this.camera, this.layerManager.snapshot());
4582
+ return exportState(
4583
+ this.store.snapshot(),
4584
+ this.camera,
4585
+ this.layerManager.snapshot(),
4586
+ this.layerManager.activeLayerId
4587
+ );
4201
4588
  }
4202
4589
  exportJSON() {
4203
4590
  return JSON.stringify(this.exportState());
@@ -4213,6 +4600,9 @@ var Viewport = class {
4213
4600
  if (state.layers && state.layers.length > 0) {
4214
4601
  this.layerManager.loadSnapshot(state.layers);
4215
4602
  }
4603
+ if (state.activeLayerId) {
4604
+ this.layerManager.setActiveLayer(state.activeLayerId);
4605
+ }
4216
4606
  this.domNodeManager.reattachHtmlContent(this.store);
4217
4607
  this.history.clear();
4218
4608
  this.historyRecorder.resume();
@@ -4320,7 +4710,8 @@ var Viewport = class {
4320
4710
  this.interactMode.destroy();
4321
4711
  this.noteEditor.destroy(this.store);
4322
4712
  this.historyRecorder.destroy();
4323
- this.wrapper.removeEventListener("dblclick", this.onDblClick);
4713
+ this.wrapper.removeEventListener("pointerdown", this.onTapDown);
4714
+ this.wrapper.removeEventListener("pointerup", this.onDoubleTap);
4324
4715
  this.wrapper.removeEventListener("dragover", this.onDragOver);
4325
4716
  this.wrapper.removeEventListener("drop", this.onDrop);
4326
4717
  this.inputHandler.destroy();
@@ -4358,7 +4749,17 @@ var Viewport = class {
4358
4749
  }
4359
4750
  }
4360
4751
  }
4361
- onDblClick = (e) => {
4752
+ onTapDown = (e) => {
4753
+ this.tapDownX = e.clientX;
4754
+ this.tapDownY = e.clientY;
4755
+ };
4756
+ onDoubleTap = (e) => {
4757
+ const dx = e.clientX - this.tapDownX;
4758
+ const dy = e.clientY - this.tapDownY;
4759
+ const moved = Math.sqrt(dx * dx + dy * dy);
4760
+ if (moved > 10) return;
4761
+ if (!this.doubleTapDetector.feed(e)) return;
4762
+ if (typeof document.elementFromPoint !== "function") return;
4362
4763
  const el = document.elementFromPoint(e.clientX, e.clientY);
4363
4764
  const nodeEl = el?.closest("[data-element-id]");
4364
4765
  if (nodeEl) {
@@ -4455,7 +4856,10 @@ var Viewport = class {
4455
4856
  position: "relative",
4456
4857
  width: "100%",
4457
4858
  height: "100%",
4458
- overflow: "hidden"
4859
+ overflow: "hidden",
4860
+ overscrollBehavior: "none",
4861
+ userSelect: "none",
4862
+ webkitUserSelect: "none"
4459
4863
  });
4460
4864
  return el;
4461
4865
  }
@@ -4518,6 +4922,26 @@ var Viewport = class {
4518
4922
  }
4519
4923
  };
4520
4924
 
4925
+ // src/elements/bounds.ts
4926
+ function getElementsBoundingBox(elements) {
4927
+ let minX = Infinity;
4928
+ let minY = Infinity;
4929
+ let maxX = -Infinity;
4930
+ let maxY = -Infinity;
4931
+ let found = false;
4932
+ for (const el of elements) {
4933
+ const b = getElementBounds(el);
4934
+ if (!b) continue;
4935
+ found = true;
4936
+ if (b.x < minX) minX = b.x;
4937
+ if (b.y < minY) minY = b.y;
4938
+ if (b.x + b.w > maxX) maxX = b.x + b.w;
4939
+ if (b.y + b.h > maxY) maxY = b.y + b.h;
4940
+ }
4941
+ if (!found) return null;
4942
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
4943
+ }
4944
+
4521
4945
  // src/tools/hand-tool.ts
4522
4946
  var HandTool = class {
4523
4947
  name = "hand";
@@ -4870,9 +5294,16 @@ var SelectTool = class {
4870
5294
  lastWorld = { x: 0, y: 0 };
4871
5295
  currentWorld = { x: 0, y: 0 };
4872
5296
  ctx = null;
5297
+ pendingSingleSelectId = null;
5298
+ hasDragged = false;
5299
+ resizeAspectRatio = 0;
4873
5300
  get selectedIds() {
4874
5301
  return [...this._selectedIds];
4875
5302
  }
5303
+ setSelection(ids) {
5304
+ this._selectedIds = ids;
5305
+ this.ctx?.requestRender();
5306
+ }
4876
5307
  get isMarqueeActive() {
4877
5308
  return this.mode.type === "marquee";
4878
5309
  }
@@ -4911,7 +5342,8 @@ var SelectTool = class {
4911
5342
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4912
5343
  if (resizeHit) {
4913
5344
  const el = ctx.store.getById(resizeHit.elementId);
4914
- if (el) {
5345
+ if (el && "size" in el) {
5346
+ this.resizeAspectRatio = el.size.h > 0 ? el.size.w / el.size.h : 0;
4915
5347
  this.mode = {
4916
5348
  type: "resizing",
4917
5349
  elementId: resizeHit.elementId,
@@ -4921,13 +5353,27 @@ var SelectTool = class {
4921
5353
  return;
4922
5354
  }
4923
5355
  }
5356
+ this.pendingSingleSelectId = null;
5357
+ this.hasDragged = false;
4924
5358
  const hit = this.hitTest(world, ctx);
4925
5359
  if (hit) {
4926
5360
  const alreadySelected = this._selectedIds.includes(hit.id);
4927
- if (!alreadySelected) {
4928
- this._selectedIds = [hit.id];
5361
+ if (state.shiftKey) {
5362
+ if (alreadySelected) {
5363
+ this._selectedIds = this._selectedIds.filter((id) => id !== hit.id);
5364
+ this.mode = { type: "idle" };
5365
+ } else {
5366
+ this._selectedIds = [...this._selectedIds, hit.id];
5367
+ this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
5368
+ }
5369
+ } else {
5370
+ if (!alreadySelected) {
5371
+ this._selectedIds = [hit.id];
5372
+ } else if (this._selectedIds.length > 1) {
5373
+ this.pendingSingleSelectId = hit.id;
5374
+ }
5375
+ this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
4929
5376
  }
4930
- this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
4931
5377
  } else {
4932
5378
  this._selectedIds = [];
4933
5379
  this.mode = { type: "marquee", start: world };
@@ -4949,10 +5395,11 @@ var SelectTool = class {
4949
5395
  }
4950
5396
  if (this.mode.type === "resizing") {
4951
5397
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
4952
- this.handleResize(world, ctx);
5398
+ this.handleResize(world, ctx, state.shiftKey);
4953
5399
  return;
4954
5400
  }
4955
5401
  if (this.mode.type === "dragging" && this._selectedIds.length > 0) {
5402
+ this.hasDragged = true;
4956
5403
  ctx.setCursor?.("move");
4957
5404
  const snapped = this.snap(world, ctx);
4958
5405
  const dx = snapped.x - this.lastWorld.x;
@@ -5021,6 +5468,11 @@ var SelectTool = class {
5021
5468
  }
5022
5469
  ctx.requestRender();
5023
5470
  }
5471
+ if (!this.hasDragged && this.pendingSingleSelectId !== null) {
5472
+ this._selectedIds = [this.pendingSingleSelectId];
5473
+ }
5474
+ this.pendingSingleSelectId = null;
5475
+ this.hasDragged = false;
5024
5476
  this.mode = { type: "idle" };
5025
5477
  ctx.setCursor?.("default");
5026
5478
  }
@@ -5067,7 +5519,7 @@ var SelectTool = class {
5067
5519
  const hit = this.hitTest(world, ctx);
5068
5520
  ctx.setCursor?.(hit ? "move" : "default");
5069
5521
  }
5070
- handleResize(world, ctx) {
5522
+ handleResize(world, ctx, shiftKey = false) {
5071
5523
  if (this.mode.type !== "resizing") return;
5072
5524
  const el = ctx.store.getById(this.mode.elementId);
5073
5525
  if (!el || !("size" in el) || el.locked) return;
@@ -5098,6 +5550,21 @@ var SelectTool = class {
5098
5550
  h -= dy;
5099
5551
  break;
5100
5552
  }
5553
+ if (shiftKey && this.resizeAspectRatio > 0) {
5554
+ const absDw = Math.abs(w - el.size.w);
5555
+ const absDh = Math.abs(h - el.size.h);
5556
+ if (absDw >= absDh) {
5557
+ h = w / this.resizeAspectRatio;
5558
+ } else {
5559
+ w = h * this.resizeAspectRatio;
5560
+ }
5561
+ if (handle === "nw" || handle === "sw") {
5562
+ x = el.position.x + el.size.w - w;
5563
+ }
5564
+ if (handle === "nw" || handle === "ne") {
5565
+ y = el.position.y + el.size.h - h;
5566
+ }
5567
+ }
5101
5568
  if (w < MIN_ELEMENT_SIZE) {
5102
5569
  if (handle === "nw" || handle === "sw") x = el.position.x + el.size.w - MIN_ELEMENT_SIZE;
5103
5570
  w = MIN_ELEMENT_SIZE;
@@ -6201,7 +6668,7 @@ var UpdateLayerCommand = class {
6201
6668
  };
6202
6669
 
6203
6670
  // src/index.ts
6204
- var VERSION = "0.11.0";
6671
+ var VERSION = "0.15.0";
6205
6672
  export {
6206
6673
  AddElementCommand,
6207
6674
  ArrowTool,
@@ -6212,6 +6679,7 @@ export {
6212
6679
  CreateLayerCommand,
6213
6680
  DEFAULT_FONT_SIZE_PRESETS,
6214
6681
  DEFAULT_NOTE_FONT_SIZE,
6682
+ DoubleTapDetector,
6215
6683
  ElementRenderer,
6216
6684
  ElementStore,
6217
6685
  EraserTool,
@@ -6220,6 +6688,7 @@ export {
6220
6688
  HistoryRecorder,
6221
6689
  HistoryStack,
6222
6690
  ImageTool,
6691
+ InputFilter,
6223
6692
  InputHandler,
6224
6693
  LayerManager,
6225
6694
  MeasureTool,
@@ -6265,6 +6734,7 @@ export {
6265
6734
  getEdgeIntersection,
6266
6735
  getElementBounds,
6267
6736
  getElementCenter,
6737
+ getElementsBoundingBox,
6268
6738
  getHexCellsInCone,
6269
6739
  getHexCellsInLine,
6270
6740
  getHexCellsInRadius,