@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.cjs CHANGED
@@ -29,6 +29,7 @@ __export(index_exports, {
29
29
  CreateLayerCommand: () => CreateLayerCommand,
30
30
  DEFAULT_FONT_SIZE_PRESETS: () => DEFAULT_FONT_SIZE_PRESETS,
31
31
  DEFAULT_NOTE_FONT_SIZE: () => DEFAULT_NOTE_FONT_SIZE,
32
+ DoubleTapDetector: () => DoubleTapDetector,
32
33
  ElementRenderer: () => ElementRenderer,
33
34
  ElementStore: () => ElementStore,
34
35
  EraserTool: () => EraserTool,
@@ -37,6 +38,7 @@ __export(index_exports, {
37
38
  HistoryRecorder: () => HistoryRecorder,
38
39
  HistoryStack: () => HistoryStack,
39
40
  ImageTool: () => ImageTool,
41
+ InputFilter: () => InputFilter,
40
42
  InputHandler: () => InputHandler,
41
43
  LayerManager: () => LayerManager,
42
44
  MeasureTool: () => MeasureTool,
@@ -82,6 +84,7 @@ __export(index_exports, {
82
84
  getEdgeIntersection: () => getEdgeIntersection,
83
85
  getElementBounds: () => getElementBounds,
84
86
  getElementCenter: () => getElementCenter,
87
+ getElementsBoundingBox: () => getElementsBoundingBox,
85
88
  getHexCellsInCone: () => getHexCellsInCone,
86
89
  getHexCellsInLine: () => getHexCellsInLine,
87
90
  getHexCellsInRadius: () => getHexCellsInRadius,
@@ -391,16 +394,24 @@ function sanitizeAttributes(el, tag) {
391
394
 
392
395
  // src/core/state-serializer.ts
393
396
  var CURRENT_VERSION = 2;
394
- function exportState(elements, camera, layers = []) {
395
- return {
397
+ function exportState(elements, camera, layers = [], activeLayerId) {
398
+ const state = {
396
399
  version: CURRENT_VERSION,
397
400
  camera: {
398
401
  position: { ...camera.position },
399
402
  zoom: camera.zoom
400
403
  },
401
- elements: elements.map((el) => structuredClone(el)),
404
+ elements: elements.map((el) => {
405
+ const clone = structuredClone(el);
406
+ if (clone.type === "arrow") {
407
+ delete clone.cachedControlPoint;
408
+ }
409
+ return clone;
410
+ }),
402
411
  layers: layers.map((l) => ({ ...l }))
403
412
  };
413
+ if (activeLayerId) state.activeLayerId = activeLayerId;
414
+ return state;
404
415
  }
405
416
  function parseState(json) {
406
417
  const data = JSON.parse(json);
@@ -558,12 +569,14 @@ var AutoSave = class {
558
569
  this.key = options.key ?? DEFAULT_KEY;
559
570
  this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
560
571
  this.layerManager = options.layerManager;
572
+ this.onError = options.onError;
561
573
  }
562
574
  key;
563
575
  debounceMs;
564
576
  layerManager;
565
577
  timerId = null;
566
578
  unsubscribers = [];
579
+ onError;
567
580
  start() {
568
581
  const schedule = () => this.scheduleSave();
569
582
  this.unsubscribers = [
@@ -611,8 +624,9 @@ var AutoSave = class {
611
624
  const state = exportState(this.store.snapshot(), this.camera, layers);
612
625
  try {
613
626
  localStorage.setItem(this.key, JSON.stringify(state));
614
- } catch {
627
+ } catch (e) {
615
628
  console.warn("Auto-save failed: storage quota exceeded. State too large for localStorage.");
629
+ this.onError?.(e instanceof Error ? e : new Error(String(e)));
616
630
  }
617
631
  }
618
632
  };
@@ -681,6 +695,15 @@ var Camera = class {
681
695
  h: bottomRight.y - topLeft.y
682
696
  };
683
697
  }
698
+ fitToContent(boundingBox, canvasWidth, canvasHeight, padding = 40) {
699
+ if (boundingBox.w === 0 && boundingBox.h === 0) return;
700
+ const scaleX = canvasWidth / (boundingBox.w + 2 * padding);
701
+ const scaleY = canvasHeight / (boundingBox.h + 2 * padding);
702
+ this.z = Math.min(this.maxZoom, Math.max(this.minZoom, Math.min(scaleX, scaleY)));
703
+ this.x = (canvasWidth - boundingBox.w * this.z) / 2 - boundingBox.x * this.z;
704
+ this.y = (canvasHeight - boundingBox.h * this.z) / 2 - boundingBox.y * this.z;
705
+ this.notifyPanAndZoom();
706
+ }
684
707
  toCSSTransform() {
685
708
  return `translate3d(${this.x}px, ${this.y}px, 0) scale(${this.z})`;
686
709
  }
@@ -836,6 +859,67 @@ var Background = class {
836
859
  }
837
860
  };
838
861
 
862
+ // src/canvas/input-filter.ts
863
+ var InputFilter = class _InputFilter {
864
+ activePenId = null;
865
+ pendingTap = null;
866
+ static MIN_MOVE_DISTANCE = 3;
867
+ filterDown(e) {
868
+ if (e.pointerType === "pen") {
869
+ this.activePenId = e.pointerId;
870
+ return { event: e, action: "dispatch" };
871
+ }
872
+ if (e.pointerType === "touch" && this.activePenId !== null) {
873
+ return { event: e, action: "suppress" };
874
+ }
875
+ if (e.pointerType === "touch") {
876
+ this.pendingTap = { pointerId: e.pointerId, x: e.clientX, y: e.clientY };
877
+ return { event: e, action: "defer" };
878
+ }
879
+ return { event: e, action: "dispatch" };
880
+ }
881
+ filterMove(e) {
882
+ if (e.pointerType === "touch" && this.activePenId !== null) {
883
+ return { event: e, action: "suppress" };
884
+ }
885
+ if (this.pendingTap && e.pointerId === this.pendingTap.pointerId) {
886
+ const dx = e.clientX - this.pendingTap.x;
887
+ const dy = e.clientY - this.pendingTap.y;
888
+ if (dx * dx + dy * dy > _InputFilter.MIN_MOVE_DISTANCE * _InputFilter.MIN_MOVE_DISTANCE) {
889
+ this.pendingTap = null;
890
+ return { event: e, action: "dispatch" };
891
+ }
892
+ return { event: e, action: "suppress" };
893
+ }
894
+ return { event: e, action: "dispatch" };
895
+ }
896
+ filterUp(e) {
897
+ if (e.pointerId === this.activePenId) {
898
+ this.activePenId = null;
899
+ return { event: e, action: "dispatch" };
900
+ }
901
+ if (e.pointerType === "touch" && this.activePenId !== null) {
902
+ return { event: e, action: "suppress" };
903
+ }
904
+ if (this.pendingTap && e.pointerId === this.pendingTap.pointerId) {
905
+ const tap = { x: this.pendingTap.x, y: this.pendingTap.y };
906
+ this.pendingTap = null;
907
+ return { event: e, action: "dispatch", pendingTap: tap };
908
+ }
909
+ return { event: e, action: "dispatch" };
910
+ }
911
+ reset() {
912
+ this.activePenId = null;
913
+ this.pendingTap = null;
914
+ }
915
+ };
916
+
917
+ // src/elements/create-id.ts
918
+ var counter = 0;
919
+ function createId(prefix) {
920
+ return `${prefix}_${Date.now().toString(36)}_${(counter++).toString(36)}`;
921
+ }
922
+
839
923
  // src/canvas/input-handler.ts
840
924
  var ZOOM_SENSITIVITY = 1e-3;
841
925
  var MIDDLE_BUTTON = 1;
@@ -861,13 +945,21 @@ var InputHandler = class {
861
945
  historyRecorder;
862
946
  historyStack;
863
947
  isToolActive = false;
948
+ lastPointerEvent = null;
949
+ inputFilter = new InputFilter();
950
+ deferredDown = null;
864
951
  abortController = new AbortController();
952
+ clipboard = [];
953
+ pasteCount = 0;
865
954
  setToolManager(toolManager, toolContext) {
866
955
  this.toolManager = toolManager;
867
956
  this.toolContext = toolContext;
868
957
  }
869
958
  destroy() {
870
959
  this.abortController.abort();
960
+ this.inputFilter.reset();
961
+ this.deferredDown = null;
962
+ this.lastPointerEvent = null;
871
963
  }
872
964
  bind() {
873
965
  const opts = { signal: this.abortController.signal };
@@ -903,11 +995,18 @@ var InputHandler = class {
903
995
  this.lastPointer = { x: e.clientX, y: e.clientY };
904
996
  return;
905
997
  }
906
- if (this.activePointers.size === 1 && e.button === 0) {
998
+ if (this.activePointers.size === 1 && (e.button === 0 || e.pointerType === "touch" || e.pointerType === "pen")) {
999
+ const result = this.inputFilter.filterDown(e);
1000
+ if (result.action === "suppress") return;
1001
+ if (result.action === "defer") {
1002
+ this.deferredDown = e;
1003
+ return;
1004
+ }
907
1005
  this.dispatchToolDown(e);
908
1006
  }
909
1007
  };
910
1008
  onPointerMove = (e) => {
1009
+ this.lastPointerEvent = e;
911
1010
  if (this.activePointers.has(e.pointerId)) {
912
1011
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
913
1012
  }
@@ -922,13 +1021,26 @@ var InputHandler = class {
922
1021
  this.camera.pan(dx, dy);
923
1022
  return;
924
1023
  }
925
- if (this.isToolActive) {
1024
+ if (e.pointerType === "pen" && !this.activePointers.has(e.pointerId)) {
1025
+ this.dispatchToolHover(e);
1026
+ } else if (this.isToolActive) {
926
1027
  this.dispatchToolMove(e);
1028
+ } else if (this.deferredDown) {
1029
+ const result = this.inputFilter.filterMove(e);
1030
+ if (result.action === "dispatch") {
1031
+ this.dispatchToolDown(this.deferredDown);
1032
+ this.deferredDown = null;
1033
+ this.dispatchToolMove(e);
1034
+ }
927
1035
  } else if (this.activePointers.size === 0) {
928
1036
  this.dispatchToolHover(e);
929
1037
  }
930
1038
  };
931
1039
  onPointerUp = (e) => {
1040
+ try {
1041
+ this.element.releasePointerCapture(e.pointerId);
1042
+ } catch {
1043
+ }
932
1044
  this.activePointers.delete(e.pointerId);
933
1045
  if (this.activePointers.size < 2) {
934
1046
  this.lastPinchDistance = 0;
@@ -936,9 +1048,16 @@ var InputHandler = class {
936
1048
  if (this.isPanning && this.activePointers.size === 0) {
937
1049
  this.isPanning = false;
938
1050
  }
1051
+ const upResult = this.inputFilter.filterUp(e);
939
1052
  if (this.isToolActive) {
940
1053
  this.dispatchToolUp(e);
941
1054
  this.isToolActive = false;
1055
+ } else if (this.deferredDown && upResult.pendingTap) {
1056
+ this.dispatchToolDown(this.deferredDown);
1057
+ this.dispatchToolUp(e);
1058
+ this.deferredDown = null;
1059
+ } else {
1060
+ this.deferredDown = null;
942
1061
  }
943
1062
  };
944
1063
  onKeyDown = (e) => {
@@ -957,13 +1076,38 @@ var InputHandler = class {
957
1076
  e.preventDefault();
958
1077
  this.handleRedo();
959
1078
  }
1079
+ if ((e.ctrlKey || e.metaKey) && e.key === "c") {
1080
+ e.preventDefault();
1081
+ this.handleCopy();
1082
+ }
1083
+ if ((e.ctrlKey || e.metaKey) && e.key === "v") {
1084
+ e.preventDefault();
1085
+ this.handlePaste();
1086
+ }
1087
+ if (e.key === "]") {
1088
+ e.preventDefault();
1089
+ this.handleZOrder(e.ctrlKey || e.metaKey ? "front" : "forward");
1090
+ }
1091
+ if (e.key === "[") {
1092
+ e.preventDefault();
1093
+ this.handleZOrder(e.ctrlKey || e.metaKey ? "back" : "backward");
1094
+ }
960
1095
  };
961
1096
  onKeyUp = (e) => {
962
1097
  if (e.key === " ") {
963
1098
  this.spaceHeld = false;
1099
+ if (this.activePointers.size === 0) {
1100
+ if (this.lastPointerEvent) {
1101
+ this.dispatchToolHover(this.lastPointerEvent);
1102
+ } else {
1103
+ this.toolContext?.setCursor?.("default");
1104
+ }
1105
+ }
964
1106
  }
965
1107
  };
966
1108
  startPinch() {
1109
+ this.inputFilter.reset();
1110
+ this.deferredDown = null;
967
1111
  this.isPanning = true;
968
1112
  const [a, b] = this.getPinchPoints();
969
1113
  this.lastPinchDistance = this.distance(a, b);
@@ -1003,7 +1147,9 @@ var InputHandler = class {
1003
1147
  return {
1004
1148
  x: e.clientX - rect.left,
1005
1149
  y: e.clientY - rect.top,
1006
- pressure: e.pressure
1150
+ pressure: e.pressure,
1151
+ pointerType: e.pointerType === "touch" || e.pointerType === "pen" ? e.pointerType : "mouse",
1152
+ shiftKey: e.shiftKey
1007
1153
  };
1008
1154
  }
1009
1155
  dispatchToolDown(e) {
@@ -1056,11 +1202,147 @@ var InputHandler = class {
1056
1202
  this.historyRecorder?.resume();
1057
1203
  this.toolContext.requestRender();
1058
1204
  }
1205
+ handleCopy() {
1206
+ if (!this.toolManager || !this.toolContext || this.isToolActive) return;
1207
+ const tool = this.toolManager.activeTool;
1208
+ if (tool?.name !== "select") return;
1209
+ const selectTool = tool;
1210
+ const ids = selectTool.selectedIds;
1211
+ if (ids.length === 0) return;
1212
+ this.clipboard = [];
1213
+ for (const id of ids) {
1214
+ const el = this.toolContext.store.getById(id);
1215
+ if (el) this.clipboard.push(structuredClone(el));
1216
+ }
1217
+ this.pasteCount = 0;
1218
+ }
1219
+ handlePaste() {
1220
+ if (!this.toolManager || !this.toolContext || this.clipboard.length === 0 || this.isToolActive)
1221
+ return;
1222
+ const tool = this.toolManager.activeTool;
1223
+ if (tool?.name !== "select") return;
1224
+ const selectTool = tool;
1225
+ this.pasteCount++;
1226
+ const offset = this.pasteCount * 20;
1227
+ const idMap = /* @__PURE__ */ new Map();
1228
+ for (const el of this.clipboard) {
1229
+ idMap.set(el.id, createId(el.type));
1230
+ }
1231
+ const newIds = [];
1232
+ this.historyRecorder?.begin();
1233
+ for (const el of this.clipboard) {
1234
+ const clone = structuredClone(el);
1235
+ const newId = idMap.get(el.id);
1236
+ if (!newId) continue;
1237
+ clone.id = newId;
1238
+ clone.position = { x: clone.position.x + offset, y: clone.position.y + offset };
1239
+ if (clone.type === "arrow") {
1240
+ const arrow = clone;
1241
+ arrow.from = { x: arrow.from.x + offset, y: arrow.from.y + offset };
1242
+ arrow.to = { x: arrow.to.x + offset, y: arrow.to.y + offset };
1243
+ delete arrow.cachedControlPoint;
1244
+ if (arrow.fromBinding) {
1245
+ const newTarget = idMap.get(arrow.fromBinding.elementId);
1246
+ if (newTarget) {
1247
+ arrow.fromBinding = { elementId: newTarget };
1248
+ } else {
1249
+ delete arrow.fromBinding;
1250
+ }
1251
+ }
1252
+ if (arrow.toBinding) {
1253
+ const newTarget = idMap.get(arrow.toBinding.elementId);
1254
+ if (newTarget) {
1255
+ arrow.toBinding = { elementId: newTarget };
1256
+ } else {
1257
+ delete arrow.toBinding;
1258
+ }
1259
+ }
1260
+ }
1261
+ if (this.toolContext.activeLayerId) {
1262
+ clone.layerId = this.toolContext.activeLayerId;
1263
+ }
1264
+ this.toolContext.store.add(clone);
1265
+ newIds.push(clone.id);
1266
+ }
1267
+ this.historyRecorder?.commit();
1268
+ selectTool.setSelection(newIds);
1269
+ this.toolContext.requestRender();
1270
+ }
1271
+ handleZOrder(operation) {
1272
+ if (!this.toolManager || !this.toolContext) return;
1273
+ const tool = this.toolManager.activeTool;
1274
+ if (tool?.name !== "select") return;
1275
+ const selectTool = tool;
1276
+ const ids = selectTool.selectedIds;
1277
+ if (ids.length === 0) return;
1278
+ this.historyRecorder?.begin();
1279
+ for (const id of ids) {
1280
+ switch (operation) {
1281
+ case "forward":
1282
+ this.toolContext.store.bringForward(id);
1283
+ break;
1284
+ case "backward":
1285
+ this.toolContext.store.sendBackward(id);
1286
+ break;
1287
+ case "front":
1288
+ this.toolContext.store.bringToFront(id);
1289
+ break;
1290
+ case "back":
1291
+ this.toolContext.store.sendToBack(id);
1292
+ break;
1293
+ }
1294
+ }
1295
+ this.historyRecorder?.commit();
1296
+ this.toolContext.requestRender();
1297
+ }
1059
1298
  cancelToolIfActive(e) {
1060
1299
  if (this.isToolActive) {
1061
1300
  this.dispatchToolUp(e);
1062
1301
  this.isToolActive = false;
1063
1302
  }
1303
+ this.deferredDown = null;
1304
+ }
1305
+ };
1306
+
1307
+ // src/canvas/double-tap-detector.ts
1308
+ var DEFAULT_TIMEOUT = 300;
1309
+ var DEFAULT_MAX_DISTANCE = 20;
1310
+ var DoubleTapDetector = class {
1311
+ timeout;
1312
+ maxDistance;
1313
+ lastTapTime = 0;
1314
+ lastTapX = 0;
1315
+ lastTapY = 0;
1316
+ hasPendingTap = false;
1317
+ constructor(options) {
1318
+ this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
1319
+ this.maxDistance = options?.maxDistance ?? DEFAULT_MAX_DISTANCE;
1320
+ }
1321
+ feed(e) {
1322
+ const now = Date.now();
1323
+ const x = e.clientX;
1324
+ const y = e.clientY;
1325
+ if (this.hasPendingTap) {
1326
+ const elapsed = now - this.lastTapTime;
1327
+ const dx = x - this.lastTapX;
1328
+ const dy = y - this.lastTapY;
1329
+ const dist = Math.sqrt(dx * dx + dy * dy);
1330
+ if (elapsed <= this.timeout && dist <= this.maxDistance) {
1331
+ this.reset();
1332
+ return true;
1333
+ }
1334
+ }
1335
+ this.lastTapTime = now;
1336
+ this.lastTapX = x;
1337
+ this.lastTapY = y;
1338
+ this.hasPendingTap = true;
1339
+ return false;
1340
+ }
1341
+ reset() {
1342
+ this.hasPendingTap = false;
1343
+ this.lastTapTime = 0;
1344
+ this.lastTapX = 0;
1345
+ this.lastTapY = 0;
1064
1346
  }
1065
1347
  };
1066
1348
 
@@ -1308,19 +1590,27 @@ var ElementStore = class {
1308
1590
  bus = new EventBus();
1309
1591
  layerOrderMap = /* @__PURE__ */ new Map();
1310
1592
  spatialIndex = new Quadtree({ x: -1e5, y: -1e5, w: 2e5, h: 2e5 });
1593
+ sortedCache = null;
1594
+ _versions = /* @__PURE__ */ new Map();
1311
1595
  get count() {
1312
1596
  return this.elements.size;
1313
1597
  }
1598
+ getVersion(id) {
1599
+ return this._versions.get(id) ?? -1;
1600
+ }
1314
1601
  setLayerOrder(order) {
1315
1602
  this.layerOrderMap = new Map(order);
1603
+ this.sortedCache = null;
1316
1604
  }
1317
1605
  getAll() {
1318
- return [...this.elements.values()].sort((a, b) => {
1606
+ if (this.sortedCache) return this.sortedCache;
1607
+ this.sortedCache = [...this.elements.values()].sort((a, b) => {
1319
1608
  const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
1320
1609
  const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
1321
1610
  if (layerA !== layerB) return layerA - layerB;
1322
1611
  return a.zIndex - b.zIndex;
1323
1612
  });
1613
+ return this.sortedCache;
1324
1614
  }
1325
1615
  getById(id) {
1326
1616
  return this.elements.get(id);
@@ -1331,6 +1621,8 @@ var ElementStore = class {
1331
1621
  );
1332
1622
  }
1333
1623
  add(element) {
1624
+ this.sortedCache = null;
1625
+ this._versions.set(element.id, 0);
1334
1626
  this.elements.set(element.id, element);
1335
1627
  const bounds = getElementBounds(element);
1336
1628
  if (bounds) this.spatialIndex.insert(element.id, bounds);
@@ -1339,11 +1631,16 @@ var ElementStore = class {
1339
1631
  update(id, partial) {
1340
1632
  const existing = this.elements.get(id);
1341
1633
  if (!existing) return;
1634
+ this.sortedCache = null;
1635
+ this._versions.set(id, (this._versions.get(id) ?? 0) + 1);
1342
1636
  const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
1343
1637
  if (updated.type === "arrow") {
1344
1638
  const arrow = updated;
1345
1639
  arrow.cachedControlPoint = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
1346
1640
  }
1641
+ if (updated.type === "note" && "text" in partial) {
1642
+ updated.text = sanitizeNoteHtml(updated.text);
1643
+ }
1347
1644
  this.elements.set(id, updated);
1348
1645
  const newBounds = getElementBounds(updated);
1349
1646
  if (newBounds) {
@@ -1354,11 +1651,15 @@ var ElementStore = class {
1354
1651
  remove(id) {
1355
1652
  const element = this.elements.get(id);
1356
1653
  if (!element) return;
1654
+ this.sortedCache = null;
1655
+ this._versions.delete(id);
1357
1656
  this.elements.delete(id);
1358
1657
  this.spatialIndex.remove(id);
1359
1658
  this.bus.emit("remove", element);
1360
1659
  }
1361
1660
  clear() {
1661
+ this.sortedCache = null;
1662
+ this._versions.clear();
1362
1663
  this.elements.clear();
1363
1664
  this.spatialIndex.clear();
1364
1665
  this.bus.emit("clear", null);
@@ -1367,13 +1668,68 @@ var ElementStore = class {
1367
1668
  return this.getAll().map((el) => ({ ...el }));
1368
1669
  }
1369
1670
  loadSnapshot(elements) {
1671
+ this.sortedCache = null;
1672
+ this._versions.clear();
1370
1673
  this.elements.clear();
1371
1674
  this.spatialIndex.clear();
1372
1675
  for (const el of elements) {
1373
1676
  this.elements.set(el.id, el);
1677
+ this._versions.set(el.id, 0);
1374
1678
  const bounds = getElementBounds(el);
1375
1679
  if (bounds) this.spatialIndex.insert(el.id, bounds);
1376
1680
  }
1681
+ this.bus.emit("clear", null);
1682
+ for (const el of elements) {
1683
+ this.bus.emit("add", el);
1684
+ }
1685
+ }
1686
+ bringToFront(id) {
1687
+ const el = this.elements.get(id);
1688
+ if (!el) return;
1689
+ const siblings = [...this.elements.values()].filter(
1690
+ (e) => e.layerId === el.layerId && e.id !== id
1691
+ );
1692
+ if (siblings.length === 0) return;
1693
+ const maxZ = Math.max(...siblings.map((e) => e.zIndex));
1694
+ if (el.zIndex >= maxZ) return;
1695
+ this.update(id, { zIndex: maxZ + 1 });
1696
+ }
1697
+ sendToBack(id) {
1698
+ const el = this.elements.get(id);
1699
+ if (!el) return;
1700
+ const siblings = [...this.elements.values()].filter(
1701
+ (e) => e.layerId === el.layerId && e.id !== id
1702
+ );
1703
+ if (siblings.length === 0) return;
1704
+ const minZ = Math.min(...siblings.map((e) => e.zIndex));
1705
+ if (el.zIndex <= minZ) return;
1706
+ this.update(id, { zIndex: minZ - 1 });
1707
+ }
1708
+ bringForward(id) {
1709
+ const el = this.elements.get(id);
1710
+ if (!el) return;
1711
+ const sorted = [...this.elements.values()].filter((e) => e.layerId === el.layerId).sort((a, b) => a.zIndex - b.zIndex);
1712
+ const idx = sorted.findIndex((e) => e.id === id);
1713
+ if (idx < 0 || idx >= sorted.length - 1) return;
1714
+ const next = sorted[idx + 1];
1715
+ if (!next) return;
1716
+ const myZ = el.zIndex;
1717
+ const nextZ = next.zIndex;
1718
+ this.update(id, { zIndex: nextZ });
1719
+ this.update(next.id, { zIndex: myZ });
1720
+ }
1721
+ sendBackward(id) {
1722
+ const el = this.elements.get(id);
1723
+ if (!el) return;
1724
+ const sorted = [...this.elements.values()].filter((e) => e.layerId === el.layerId).sort((a, b) => a.zIndex - b.zIndex);
1725
+ const idx = sorted.findIndex((e) => e.id === id);
1726
+ if (idx <= 0) return;
1727
+ const prev = sorted[idx - 1];
1728
+ if (!prev) return;
1729
+ const myZ = el.zIndex;
1730
+ const prevZ = prev.zIndex;
1731
+ this.update(id, { zIndex: prevZ });
1732
+ this.update(prev.id, { zIndex: myZ });
1377
1733
  }
1378
1734
  queryRect(rect) {
1379
1735
  const ids = this.spatialIndex.query(rect);
@@ -2423,12 +2779,6 @@ var ElementRenderer = class {
2423
2779
  }
2424
2780
  };
2425
2781
 
2426
- // src/elements/create-id.ts
2427
- var counter = 0;
2428
- function createId(prefix) {
2429
- return `${prefix}_${Date.now().toString(36)}_${(counter++).toString(36)}`;
2430
- }
2431
-
2432
2782
  // src/elements/element-factory.ts
2433
2783
  var DEFAULT_NOTE_FONT_SIZE = 18;
2434
2784
  function createStroke(input) {
@@ -2454,7 +2804,7 @@ function createNote(input) {
2454
2804
  locked: input.locked ?? false,
2455
2805
  layerId: input.layerId ?? "",
2456
2806
  size: input.size ?? { w: 200, h: 100 },
2457
- text: input.text ?? "",
2807
+ text: sanitizeNoteHtml(input.text ?? ""),
2458
2808
  backgroundColor: input.backgroundColor ?? "#ffeb3b",
2459
2809
  textColor: input.textColor ?? "#000000",
2460
2810
  fontSize: input.fontSize ?? DEFAULT_NOTE_FONT_SIZE
@@ -2503,6 +2853,7 @@ function createHtmlElement(input) {
2503
2853
  size: input.size
2504
2854
  };
2505
2855
  if (input.domId) el.domId = input.domId;
2856
+ if (input.interactive) el.interactive = input.interactive;
2506
2857
  return el;
2507
2858
  }
2508
2859
  function createShape(input) {
@@ -2615,7 +2966,7 @@ function getActiveFormats() {
2615
2966
  }
2616
2967
 
2617
2968
  // src/elements/note-toolbar.ts
2618
- var TOOLBAR_HEIGHT = 32;
2969
+ var TOOLBAR_HEIGHT = 52;
2619
2970
  var TOOLBAR_GAP = 4;
2620
2971
  var FORMAT_BUTTONS = [
2621
2972
  { label: "B", format: "bold", command: "bold" },
@@ -2702,9 +3053,9 @@ var NoteToolbar = class {
2702
3053
  fontWeight: config.format === "bold" ? "bold" : "normal",
2703
3054
  fontStyle: config.format === "italic" ? "italic" : "normal",
2704
3055
  textDecoration: config.format === "underline" ? "underline" : config.format === "strikethrough" ? "line-through" : "none",
2705
- minWidth: "24px",
2706
- height: "24px",
2707
- lineHeight: "24px"
3056
+ minWidth: "44px",
3057
+ height: "44px",
3058
+ lineHeight: "44px"
2708
3059
  });
2709
3060
  btn.addEventListener("pointerdown", (e) => {
2710
3061
  e.preventDefault();
@@ -2722,7 +3073,7 @@ var NoteToolbar = class {
2722
3073
  cursor: "pointer",
2723
3074
  padding: "2px",
2724
3075
  fontSize: "12px",
2725
- height: "24px",
3076
+ height: "44px",
2726
3077
  marginLeft: "4px"
2727
3078
  });
2728
3079
  for (const preset of this.fontSizePresets) {
@@ -3642,10 +3993,14 @@ var DomNodeManager = class {
3642
3993
  domLayer;
3643
3994
  onEditRequest;
3644
3995
  isEditingElement;
3996
+ getVersion;
3997
+ lastSyncedVersion = /* @__PURE__ */ new Map();
3998
+ lastSyncedZIndex = /* @__PURE__ */ new Map();
3645
3999
  constructor(deps) {
3646
4000
  this.domLayer = deps.domLayer;
3647
4001
  this.onEditRequest = deps.onEditRequest;
3648
4002
  this.isEditingElement = deps.isEditingElement;
4003
+ this.getVersion = deps.getVersion ?? null;
3649
4004
  }
3650
4005
  getNode(id) {
3651
4006
  return this.domNodes.get(id);
@@ -3664,6 +4019,17 @@ var DomNodeManager = class {
3664
4019
  });
3665
4020
  this.domLayer.appendChild(node);
3666
4021
  this.domNodes.set(element.id, node);
4022
+ } else if (this.getVersion) {
4023
+ const currentVersion = this.getVersion(element.id);
4024
+ const lastVersion = this.lastSyncedVersion.get(element.id);
4025
+ const lastZ = this.lastSyncedZIndex.get(element.id);
4026
+ if (lastVersion === currentVersion && lastZ === zIndex) {
4027
+ return;
4028
+ }
4029
+ }
4030
+ if (this.getVersion) {
4031
+ this.lastSyncedVersion.set(element.id, this.getVersion(element.id));
4032
+ this.lastSyncedZIndex.set(element.id, zIndex);
3667
4033
  }
3668
4034
  const size = "size" in element ? element.size : null;
3669
4035
  Object.assign(node.style, {
@@ -3682,6 +4048,8 @@ var DomNodeManager = class {
3682
4048
  }
3683
4049
  removeDomNode(id) {
3684
4050
  this.htmlContent.delete(id);
4051
+ this.lastSyncedVersion.delete(id);
4052
+ this.lastSyncedZIndex.delete(id);
3685
4053
  const node = this.domNodes.get(id);
3686
4054
  if (node) {
3687
4055
  node.remove();
@@ -3692,6 +4060,8 @@ var DomNodeManager = class {
3692
4060
  this.domNodes.forEach((node) => node.remove());
3693
4061
  this.domNodes.clear();
3694
4062
  this.htmlContent.clear();
4063
+ this.lastSyncedVersion.clear();
4064
+ this.lastSyncedZIndex.clear();
3695
4065
  }
3696
4066
  reattachHtmlContent(store) {
3697
4067
  for (const el of store.getElementsByType("html")) {
@@ -3720,10 +4090,13 @@ var DomNodeManager = class {
3720
4090
  wordWrap: "break-word"
3721
4091
  });
3722
4092
  node.innerHTML = element.text || "";
3723
- node.addEventListener("dblclick", (e) => {
3724
- e.stopPropagation();
3725
- const id = node.dataset["elementId"];
3726
- if (id) this.onEditRequest(id);
4093
+ const detector = new DoubleTapDetector();
4094
+ node.addEventListener("pointerup", (e) => {
4095
+ if (detector.feed(e)) {
4096
+ e.stopPropagation();
4097
+ const id = node.dataset["elementId"];
4098
+ if (id) this.onEditRequest(id);
4099
+ }
3727
4100
  });
3728
4101
  }
3729
4102
  if (!this.isEditingElement(element.id)) {
@@ -3736,15 +4109,19 @@ var DomNodeManager = class {
3736
4109
  node.style.fontSize = `${element.fontSize ?? DEFAULT_NOTE_FONT_SIZE}px`;
3737
4110
  }
3738
4111
  }
3739
- if (element.type === "html" && !node.dataset["initialized"]) {
3740
- const content = this.htmlContent.get(element.id);
3741
- if (content) {
3742
- node.dataset["initialized"] = "true";
3743
- Object.assign(node.style, {
3744
- overflow: "hidden",
3745
- pointerEvents: "none"
3746
- });
3747
- node.appendChild(content);
4112
+ if (element.type === "html") {
4113
+ if (!node.dataset["initialized"]) {
4114
+ const content = this.htmlContent.get(element.id);
4115
+ if (content) {
4116
+ node.dataset["initialized"] = "true";
4117
+ Object.assign(node.style, {
4118
+ overflow: "hidden",
4119
+ pointerEvents: element.interactive ? "auto" : "none"
4120
+ });
4121
+ node.appendChild(content);
4122
+ }
4123
+ } else {
4124
+ node.style.pointerEvents = element.interactive ? "auto" : "none";
3748
4125
  }
3749
4126
  }
3750
4127
  if (element.type === "text") {
@@ -3766,10 +4143,13 @@ var DomNodeManager = class {
3766
4143
  lineHeight: "1.4"
3767
4144
  });
3768
4145
  node.textContent = element.text || "";
3769
- node.addEventListener("dblclick", (e) => {
3770
- e.stopPropagation();
3771
- const id = node.dataset["elementId"];
3772
- if (id) this.onEditRequest(id);
4146
+ const detector = new DoubleTapDetector();
4147
+ node.addEventListener("pointerup", (e) => {
4148
+ if (detector.feed(e)) {
4149
+ e.stopPropagation();
4150
+ const id = node.dataset["elementId"];
4151
+ if (id) this.onEditRequest(id);
4152
+ }
3773
4153
  });
3774
4154
  }
3775
4155
  if (!this.isEditingElement(element.id)) {
@@ -4201,7 +4581,8 @@ var Viewport = class {
4201
4581
  this.domNodeManager = new DomNodeManager({
4202
4582
  domLayer: this.domLayer,
4203
4583
  onEditRequest: (id) => this.startEditingElement(id),
4204
- isEditingElement: (id) => this.noteEditor.isEditing && this.noteEditor.editingElementId === id
4584
+ isEditingElement: (id) => this.noteEditor.isEditing && this.noteEditor.editingElementId === id,
4585
+ getVersion: (id) => this.store.getVersion(id)
4205
4586
  });
4206
4587
  this.interactMode = new InteractMode({
4207
4588
  getNode: (id) => this.domNodeManager.getNode(id)
@@ -4258,7 +4639,8 @@ var Viewport = class {
4258
4639
  this.toolContext.activeLayerId = this.layerManager.activeLayerId;
4259
4640
  this.requestRender();
4260
4641
  });
4261
- this.wrapper.addEventListener("dblclick", this.onDblClick);
4642
+ this.wrapper.addEventListener("pointerdown", this.onTapDown);
4643
+ this.wrapper.addEventListener("pointerup", this.onDoubleTap);
4262
4644
  this.wrapper.addEventListener("dragover", this.onDragOver);
4263
4645
  this.wrapper.addEventListener("drop", this.onDrop);
4264
4646
  this.observeResize();
@@ -4289,6 +4671,9 @@ var Viewport = class {
4289
4671
  domNodeManager;
4290
4672
  interactMode;
4291
4673
  gridChangeListeners = /* @__PURE__ */ new Set();
4674
+ doubleTapDetector = new DoubleTapDetector();
4675
+ tapDownX = 0;
4676
+ tapDownY = 0;
4292
4677
  get ctx() {
4293
4678
  return this.canvasEl.getContext("2d");
4294
4679
  }
@@ -4303,7 +4688,12 @@ var Viewport = class {
4303
4688
  this.renderLoop.requestRender();
4304
4689
  }
4305
4690
  exportState() {
4306
- return exportState(this.store.snapshot(), this.camera, this.layerManager.snapshot());
4691
+ return exportState(
4692
+ this.store.snapshot(),
4693
+ this.camera,
4694
+ this.layerManager.snapshot(),
4695
+ this.layerManager.activeLayerId
4696
+ );
4307
4697
  }
4308
4698
  exportJSON() {
4309
4699
  return JSON.stringify(this.exportState());
@@ -4319,6 +4709,9 @@ var Viewport = class {
4319
4709
  if (state.layers && state.layers.length > 0) {
4320
4710
  this.layerManager.loadSnapshot(state.layers);
4321
4711
  }
4712
+ if (state.activeLayerId) {
4713
+ this.layerManager.setActiveLayer(state.activeLayerId);
4714
+ }
4322
4715
  this.domNodeManager.reattachHtmlContent(this.store);
4323
4716
  this.history.clear();
4324
4717
  this.historyRecorder.resume();
@@ -4426,7 +4819,8 @@ var Viewport = class {
4426
4819
  this.interactMode.destroy();
4427
4820
  this.noteEditor.destroy(this.store);
4428
4821
  this.historyRecorder.destroy();
4429
- this.wrapper.removeEventListener("dblclick", this.onDblClick);
4822
+ this.wrapper.removeEventListener("pointerdown", this.onTapDown);
4823
+ this.wrapper.removeEventListener("pointerup", this.onDoubleTap);
4430
4824
  this.wrapper.removeEventListener("dragover", this.onDragOver);
4431
4825
  this.wrapper.removeEventListener("drop", this.onDrop);
4432
4826
  this.inputHandler.destroy();
@@ -4464,7 +4858,17 @@ var Viewport = class {
4464
4858
  }
4465
4859
  }
4466
4860
  }
4467
- onDblClick = (e) => {
4861
+ onTapDown = (e) => {
4862
+ this.tapDownX = e.clientX;
4863
+ this.tapDownY = e.clientY;
4864
+ };
4865
+ onDoubleTap = (e) => {
4866
+ const dx = e.clientX - this.tapDownX;
4867
+ const dy = e.clientY - this.tapDownY;
4868
+ const moved = Math.sqrt(dx * dx + dy * dy);
4869
+ if (moved > 10) return;
4870
+ if (!this.doubleTapDetector.feed(e)) return;
4871
+ if (typeof document.elementFromPoint !== "function") return;
4468
4872
  const el = document.elementFromPoint(e.clientX, e.clientY);
4469
4873
  const nodeEl = el?.closest("[data-element-id]");
4470
4874
  if (nodeEl) {
@@ -4561,7 +4965,10 @@ var Viewport = class {
4561
4965
  position: "relative",
4562
4966
  width: "100%",
4563
4967
  height: "100%",
4564
- overflow: "hidden"
4968
+ overflow: "hidden",
4969
+ overscrollBehavior: "none",
4970
+ userSelect: "none",
4971
+ webkitUserSelect: "none"
4565
4972
  });
4566
4973
  return el;
4567
4974
  }
@@ -4624,6 +5031,26 @@ var Viewport = class {
4624
5031
  }
4625
5032
  };
4626
5033
 
5034
+ // src/elements/bounds.ts
5035
+ function getElementsBoundingBox(elements) {
5036
+ let minX = Infinity;
5037
+ let minY = Infinity;
5038
+ let maxX = -Infinity;
5039
+ let maxY = -Infinity;
5040
+ let found = false;
5041
+ for (const el of elements) {
5042
+ const b = getElementBounds(el);
5043
+ if (!b) continue;
5044
+ found = true;
5045
+ if (b.x < minX) minX = b.x;
5046
+ if (b.y < minY) minY = b.y;
5047
+ if (b.x + b.w > maxX) maxX = b.x + b.w;
5048
+ if (b.y + b.h > maxY) maxY = b.y + b.h;
5049
+ }
5050
+ if (!found) return null;
5051
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
5052
+ }
5053
+
4627
5054
  // src/tools/hand-tool.ts
4628
5055
  var HandTool = class {
4629
5056
  name = "hand";
@@ -4976,9 +5403,16 @@ var SelectTool = class {
4976
5403
  lastWorld = { x: 0, y: 0 };
4977
5404
  currentWorld = { x: 0, y: 0 };
4978
5405
  ctx = null;
5406
+ pendingSingleSelectId = null;
5407
+ hasDragged = false;
5408
+ resizeAspectRatio = 0;
4979
5409
  get selectedIds() {
4980
5410
  return [...this._selectedIds];
4981
5411
  }
5412
+ setSelection(ids) {
5413
+ this._selectedIds = ids;
5414
+ this.ctx?.requestRender();
5415
+ }
4982
5416
  get isMarqueeActive() {
4983
5417
  return this.mode.type === "marquee";
4984
5418
  }
@@ -5017,7 +5451,8 @@ var SelectTool = class {
5017
5451
  const resizeHit = this.hitTestResizeHandle(world, ctx);
5018
5452
  if (resizeHit) {
5019
5453
  const el = ctx.store.getById(resizeHit.elementId);
5020
- if (el) {
5454
+ if (el && "size" in el) {
5455
+ this.resizeAspectRatio = el.size.h > 0 ? el.size.w / el.size.h : 0;
5021
5456
  this.mode = {
5022
5457
  type: "resizing",
5023
5458
  elementId: resizeHit.elementId,
@@ -5027,13 +5462,27 @@ var SelectTool = class {
5027
5462
  return;
5028
5463
  }
5029
5464
  }
5465
+ this.pendingSingleSelectId = null;
5466
+ this.hasDragged = false;
5030
5467
  const hit = this.hitTest(world, ctx);
5031
5468
  if (hit) {
5032
5469
  const alreadySelected = this._selectedIds.includes(hit.id);
5033
- if (!alreadySelected) {
5034
- this._selectedIds = [hit.id];
5470
+ if (state.shiftKey) {
5471
+ if (alreadySelected) {
5472
+ this._selectedIds = this._selectedIds.filter((id) => id !== hit.id);
5473
+ this.mode = { type: "idle" };
5474
+ } else {
5475
+ this._selectedIds = [...this._selectedIds, hit.id];
5476
+ this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
5477
+ }
5478
+ } else {
5479
+ if (!alreadySelected) {
5480
+ this._selectedIds = [hit.id];
5481
+ } else if (this._selectedIds.length > 1) {
5482
+ this.pendingSingleSelectId = hit.id;
5483
+ }
5484
+ this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
5035
5485
  }
5036
- this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
5037
5486
  } else {
5038
5487
  this._selectedIds = [];
5039
5488
  this.mode = { type: "marquee", start: world };
@@ -5055,10 +5504,11 @@ var SelectTool = class {
5055
5504
  }
5056
5505
  if (this.mode.type === "resizing") {
5057
5506
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
5058
- this.handleResize(world, ctx);
5507
+ this.handleResize(world, ctx, state.shiftKey);
5059
5508
  return;
5060
5509
  }
5061
5510
  if (this.mode.type === "dragging" && this._selectedIds.length > 0) {
5511
+ this.hasDragged = true;
5062
5512
  ctx.setCursor?.("move");
5063
5513
  const snapped = this.snap(world, ctx);
5064
5514
  const dx = snapped.x - this.lastWorld.x;
@@ -5127,6 +5577,11 @@ var SelectTool = class {
5127
5577
  }
5128
5578
  ctx.requestRender();
5129
5579
  }
5580
+ if (!this.hasDragged && this.pendingSingleSelectId !== null) {
5581
+ this._selectedIds = [this.pendingSingleSelectId];
5582
+ }
5583
+ this.pendingSingleSelectId = null;
5584
+ this.hasDragged = false;
5130
5585
  this.mode = { type: "idle" };
5131
5586
  ctx.setCursor?.("default");
5132
5587
  }
@@ -5173,7 +5628,7 @@ var SelectTool = class {
5173
5628
  const hit = this.hitTest(world, ctx);
5174
5629
  ctx.setCursor?.(hit ? "move" : "default");
5175
5630
  }
5176
- handleResize(world, ctx) {
5631
+ handleResize(world, ctx, shiftKey = false) {
5177
5632
  if (this.mode.type !== "resizing") return;
5178
5633
  const el = ctx.store.getById(this.mode.elementId);
5179
5634
  if (!el || !("size" in el) || el.locked) return;
@@ -5204,6 +5659,21 @@ var SelectTool = class {
5204
5659
  h -= dy;
5205
5660
  break;
5206
5661
  }
5662
+ if (shiftKey && this.resizeAspectRatio > 0) {
5663
+ const absDw = Math.abs(w - el.size.w);
5664
+ const absDh = Math.abs(h - el.size.h);
5665
+ if (absDw >= absDh) {
5666
+ h = w / this.resizeAspectRatio;
5667
+ } else {
5668
+ w = h * this.resizeAspectRatio;
5669
+ }
5670
+ if (handle === "nw" || handle === "sw") {
5671
+ x = el.position.x + el.size.w - w;
5672
+ }
5673
+ if (handle === "nw" || handle === "ne") {
5674
+ y = el.position.y + el.size.h - h;
5675
+ }
5676
+ }
5207
5677
  if (w < MIN_ELEMENT_SIZE) {
5208
5678
  if (handle === "nw" || handle === "sw") x = el.position.x + el.size.w - MIN_ELEMENT_SIZE;
5209
5679
  w = MIN_ELEMENT_SIZE;
@@ -6307,7 +6777,7 @@ var UpdateLayerCommand = class {
6307
6777
  };
6308
6778
 
6309
6779
  // src/index.ts
6310
- var VERSION = "0.11.0";
6780
+ var VERSION = "0.15.0";
6311
6781
  // Annotate the CommonJS export names for ESM import in node:
6312
6782
  0 && (module.exports = {
6313
6783
  AddElementCommand,
@@ -6319,6 +6789,7 @@ var VERSION = "0.11.0";
6319
6789
  CreateLayerCommand,
6320
6790
  DEFAULT_FONT_SIZE_PRESETS,
6321
6791
  DEFAULT_NOTE_FONT_SIZE,
6792
+ DoubleTapDetector,
6322
6793
  ElementRenderer,
6323
6794
  ElementStore,
6324
6795
  EraserTool,
@@ -6327,6 +6798,7 @@ var VERSION = "0.11.0";
6327
6798
  HistoryRecorder,
6328
6799
  HistoryStack,
6329
6800
  ImageTool,
6801
+ InputFilter,
6330
6802
  InputHandler,
6331
6803
  LayerManager,
6332
6804
  MeasureTool,
@@ -6372,6 +6844,7 @@ var VERSION = "0.11.0";
6372
6844
  getEdgeIntersection,
6373
6845
  getElementBounds,
6374
6846
  getElementCenter,
6847
+ getElementsBoundingBox,
6375
6848
  getHexCellsInCone,
6376
6849
  getHexCellsInLine,
6377
6850
  getHexCellsInRadius,