@fieldnotes/core 0.8.6 → 0.8.7

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
@@ -40,6 +40,7 @@ __export(index_exports, {
40
40
  NoteEditor: () => NoteEditor,
41
41
  NoteTool: () => NoteTool,
42
42
  PencilTool: () => PencilTool,
43
+ Quadtree: () => Quadtree,
43
44
  RemoveElementCommand: () => RemoveElementCommand,
44
45
  RemoveLayerCommand: () => RemoveLayerCommand,
45
46
  SelectTool: () => SelectTool,
@@ -50,6 +51,7 @@ __export(index_exports, {
50
51
  UpdateLayerCommand: () => UpdateLayerCommand,
51
52
  VERSION: () => VERSION,
52
53
  Viewport: () => Viewport,
54
+ boundsIntersect: () => boundsIntersect,
53
55
  clearStaleBindings: () => clearStaleBindings,
54
56
  createArrow: () => createArrow,
55
57
  createGrid: () => createGrid,
@@ -105,6 +107,153 @@ var EventBus = class {
105
107
  }
106
108
  };
107
109
 
110
+ // src/core/quadtree.ts
111
+ var MAX_ITEMS = 8;
112
+ var MAX_DEPTH = 8;
113
+ function intersects(a, b) {
114
+ return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
115
+ }
116
+ var QuadNode = class _QuadNode {
117
+ constructor(bounds, depth) {
118
+ this.bounds = bounds;
119
+ this.depth = depth;
120
+ }
121
+ items = [];
122
+ children = null;
123
+ insert(entry) {
124
+ if (this.children) {
125
+ const idx = this.getChildIndex(entry.bounds);
126
+ if (idx !== -1) {
127
+ const child = this.children[idx];
128
+ if (child) child.insert(entry);
129
+ return;
130
+ }
131
+ this.items.push(entry);
132
+ return;
133
+ }
134
+ this.items.push(entry);
135
+ if (this.items.length > MAX_ITEMS && this.depth < MAX_DEPTH) {
136
+ this.split();
137
+ }
138
+ }
139
+ remove(id) {
140
+ const idx = this.items.findIndex((e) => e.id === id);
141
+ if (idx !== -1) {
142
+ this.items.splice(idx, 1);
143
+ return true;
144
+ }
145
+ if (this.children) {
146
+ for (const child of this.children) {
147
+ if (child.remove(id)) {
148
+ this.collapseIfEmpty();
149
+ return true;
150
+ }
151
+ }
152
+ }
153
+ return false;
154
+ }
155
+ query(rect, result) {
156
+ if (!intersects(this.bounds, rect)) return;
157
+ for (const item of this.items) {
158
+ if (intersects(item.bounds, rect)) {
159
+ result.push(item.id);
160
+ }
161
+ }
162
+ if (this.children) {
163
+ for (const child of this.children) {
164
+ child.query(rect, result);
165
+ }
166
+ }
167
+ }
168
+ getChildIndex(itemBounds) {
169
+ const midX = this.bounds.x + this.bounds.w / 2;
170
+ const midY = this.bounds.y + this.bounds.h / 2;
171
+ const left = itemBounds.x >= this.bounds.x && itemBounds.x + itemBounds.w <= midX;
172
+ const right = itemBounds.x >= midX && itemBounds.x + itemBounds.w <= this.bounds.x + this.bounds.w;
173
+ const top = itemBounds.y >= this.bounds.y && itemBounds.y + itemBounds.h <= midY;
174
+ const bottom = itemBounds.y >= midY && itemBounds.y + itemBounds.h <= this.bounds.y + this.bounds.h;
175
+ if (left && top) return 0;
176
+ if (right && top) return 1;
177
+ if (left && bottom) return 2;
178
+ if (right && bottom) return 3;
179
+ return -1;
180
+ }
181
+ split() {
182
+ const { x, y, w, h } = this.bounds;
183
+ const halfW = w / 2;
184
+ const halfH = h / 2;
185
+ const d = this.depth + 1;
186
+ this.children = [
187
+ new _QuadNode({ x, y, w: halfW, h: halfH }, d),
188
+ new _QuadNode({ x: x + halfW, y, w: halfW, h: halfH }, d),
189
+ new _QuadNode({ x, y: y + halfH, w: halfW, h: halfH }, d),
190
+ new _QuadNode({ x: x + halfW, y: y + halfH, w: halfW, h: halfH }, d)
191
+ ];
192
+ const remaining = [];
193
+ for (const item of this.items) {
194
+ const idx = this.getChildIndex(item.bounds);
195
+ if (idx !== -1) {
196
+ const target = this.children[idx];
197
+ if (target) target.insert(item);
198
+ } else {
199
+ remaining.push(item);
200
+ }
201
+ }
202
+ this.items = remaining;
203
+ }
204
+ collapseIfEmpty() {
205
+ if (!this.children) return;
206
+ let totalItems = this.items.length;
207
+ for (const child of this.children) {
208
+ if (child.children) return;
209
+ totalItems += child.items.length;
210
+ }
211
+ if (totalItems <= MAX_ITEMS) {
212
+ for (const child of this.children) {
213
+ this.items.push(...child.items);
214
+ }
215
+ this.children = null;
216
+ }
217
+ }
218
+ };
219
+ var Quadtree = class {
220
+ root;
221
+ _size = 0;
222
+ worldBounds;
223
+ constructor(worldBounds) {
224
+ this.worldBounds = worldBounds;
225
+ this.root = new QuadNode(worldBounds, 0);
226
+ }
227
+ get size() {
228
+ return this._size;
229
+ }
230
+ insert(id, bounds) {
231
+ this.root.insert({ id, bounds });
232
+ this._size++;
233
+ }
234
+ remove(id) {
235
+ if (this.root.remove(id)) {
236
+ this._size--;
237
+ }
238
+ }
239
+ update(id, newBounds) {
240
+ this.remove(id);
241
+ this.insert(id, newBounds);
242
+ }
243
+ query(rect) {
244
+ const result = [];
245
+ this.root.query(rect, result);
246
+ return result;
247
+ }
248
+ queryPoint(point) {
249
+ return this.query({ x: point.x, y: point.y, w: 0, h: 0 });
250
+ }
251
+ clear() {
252
+ this.root = new QuadNode(this.worldBounds, 0);
253
+ this._size = 0;
254
+ }
255
+ };
256
+
108
257
  // src/core/state-serializer.ts
109
258
  var CURRENT_VERSION = 2;
110
259
  function exportState(elements, camera, layers = []) {
@@ -319,16 +468,16 @@ var Camera = class {
319
468
  pan(dx, dy) {
320
469
  this.x += dx;
321
470
  this.y += dy;
322
- this.notifyChange();
471
+ this.notifyPan();
323
472
  }
324
473
  moveTo(x, y) {
325
474
  this.x = x;
326
475
  this.y = y;
327
- this.notifyChange();
476
+ this.notifyPan();
328
477
  }
329
478
  setZoom(level) {
330
479
  this.z = Math.min(this.maxZoom, Math.max(this.minZoom, level));
331
- this.notifyChange();
480
+ this.notifyZoom();
332
481
  }
333
482
  zoomAt(level, screenPoint) {
334
483
  const before = this.screenToWorld(screenPoint);
@@ -336,7 +485,7 @@ var Camera = class {
336
485
  const after = this.screenToWorld(screenPoint);
337
486
  this.x += (after.x - before.x) * this.z;
338
487
  this.y += (after.y - before.y) * this.z;
339
- this.notifyChange();
488
+ this.notifyPanAndZoom();
340
489
  }
341
490
  screenToWorld(screen) {
342
491
  return {
@@ -350,6 +499,16 @@ var Camera = class {
350
499
  y: world.y * this.z + this.y
351
500
  };
352
501
  }
502
+ getVisibleRect(canvasWidth, canvasHeight) {
503
+ const topLeft = this.screenToWorld({ x: 0, y: 0 });
504
+ const bottomRight = this.screenToWorld({ x: canvasWidth, y: canvasHeight });
505
+ return {
506
+ x: topLeft.x,
507
+ y: topLeft.y,
508
+ w: bottomRight.x - topLeft.x,
509
+ h: bottomRight.y - topLeft.y
510
+ };
511
+ }
353
512
  toCSSTransform() {
354
513
  return `translate3d(${this.x}px, ${this.y}px, 0) scale(${this.z})`;
355
514
  }
@@ -357,8 +516,14 @@ var Camera = class {
357
516
  this.changeListeners.add(listener);
358
517
  return () => this.changeListeners.delete(listener);
359
518
  }
360
- notifyChange() {
361
- this.changeListeners.forEach((fn) => fn());
519
+ notifyPan() {
520
+ this.changeListeners.forEach((fn) => fn({ panned: true, zoomed: false }));
521
+ }
522
+ notifyZoom() {
523
+ this.changeListeners.forEach((fn) => fn({ panned: false, zoomed: true }));
524
+ }
525
+ notifyPanAndZoom() {
526
+ this.changeListeners.forEach((fn) => fn({ panned: true, zoomed: true }));
362
527
  }
363
528
  };
364
529
 
@@ -664,77 +829,6 @@ var InputHandler = class {
664
829
  }
665
830
  };
666
831
 
667
- // src/elements/element-store.ts
668
- var ElementStore = class {
669
- elements = /* @__PURE__ */ new Map();
670
- bus = new EventBus();
671
- layerOrderMap = /* @__PURE__ */ new Map();
672
- get count() {
673
- return this.elements.size;
674
- }
675
- setLayerOrder(order) {
676
- this.layerOrderMap = new Map(order);
677
- }
678
- getAll() {
679
- return [...this.elements.values()].sort((a, b) => {
680
- const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
681
- const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
682
- if (layerA !== layerB) return layerA - layerB;
683
- return a.zIndex - b.zIndex;
684
- });
685
- }
686
- getById(id) {
687
- return this.elements.get(id);
688
- }
689
- getElementsByType(type) {
690
- return this.getAll().filter(
691
- (el) => el.type === type
692
- );
693
- }
694
- add(element) {
695
- this.elements.set(element.id, element);
696
- this.bus.emit("add", element);
697
- }
698
- update(id, partial) {
699
- const existing = this.elements.get(id);
700
- if (!existing) return;
701
- const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
702
- this.elements.set(id, updated);
703
- this.bus.emit("update", { previous: existing, current: updated });
704
- }
705
- remove(id) {
706
- const element = this.elements.get(id);
707
- if (!element) return;
708
- this.elements.delete(id);
709
- this.bus.emit("remove", element);
710
- }
711
- clear() {
712
- this.elements.clear();
713
- this.bus.emit("clear", null);
714
- }
715
- snapshot() {
716
- return this.getAll().map((el) => ({ ...el }));
717
- }
718
- loadSnapshot(elements) {
719
- this.elements.clear();
720
- for (const el of elements) {
721
- this.elements.set(el.id, el);
722
- }
723
- }
724
- on(event, listener) {
725
- return this.bus.on(event, listener);
726
- }
727
- onChange(listener) {
728
- const unsubs = [
729
- this.bus.on("add", listener),
730
- this.bus.on("remove", listener),
731
- this.bus.on("update", listener),
732
- this.bus.on("clear", listener)
733
- ];
734
- return () => unsubs.forEach((fn) => fn());
735
- }
736
- };
737
-
738
832
  // src/elements/arrow-geometry.ts
739
833
  function getArrowControlPoint(from, to, bend) {
740
834
  const midX = (from.x + to.x) / 2;
@@ -835,6 +929,185 @@ function isNearLine(point, a, b, threshold) {
835
929
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
836
930
  }
837
931
 
932
+ // src/elements/element-bounds.ts
933
+ var strokeBoundsCache = /* @__PURE__ */ new WeakMap();
934
+ function getElementBounds(element) {
935
+ if (element.type === "grid") return null;
936
+ if ("size" in element) {
937
+ return {
938
+ x: element.position.x,
939
+ y: element.position.y,
940
+ w: element.size.w,
941
+ h: element.size.h
942
+ };
943
+ }
944
+ if (element.type === "stroke") {
945
+ if (element.points.length === 0) return null;
946
+ const cached = strokeBoundsCache.get(element);
947
+ if (cached) return cached;
948
+ let minX = Infinity;
949
+ let minY = Infinity;
950
+ let maxX = -Infinity;
951
+ let maxY = -Infinity;
952
+ for (const p of element.points) {
953
+ const px = p.x + element.position.x;
954
+ const py = p.y + element.position.y;
955
+ if (px < minX) minX = px;
956
+ if (py < minY) minY = py;
957
+ if (px > maxX) maxX = px;
958
+ if (py > maxY) maxY = py;
959
+ }
960
+ const bounds = { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
961
+ strokeBoundsCache.set(element, bounds);
962
+ return bounds;
963
+ }
964
+ if (element.type === "arrow") {
965
+ return getArrowBoundsAnalytical(element.from, element.to, element.bend);
966
+ }
967
+ return null;
968
+ }
969
+ function getArrowBoundsAnalytical(from, to, bend) {
970
+ if (bend === 0) {
971
+ const minX2 = Math.min(from.x, to.x);
972
+ const minY2 = Math.min(from.y, to.y);
973
+ return {
974
+ x: minX2,
975
+ y: minY2,
976
+ w: Math.abs(to.x - from.x),
977
+ h: Math.abs(to.y - from.y)
978
+ };
979
+ }
980
+ const cp = getArrowControlPoint(from, to, bend);
981
+ let minX = Math.min(from.x, to.x);
982
+ let maxX = Math.max(from.x, to.x);
983
+ let minY = Math.min(from.y, to.y);
984
+ let maxY = Math.max(from.y, to.y);
985
+ const tx = from.x - 2 * cp.x + to.x;
986
+ if (tx !== 0) {
987
+ const t = (from.x - cp.x) / tx;
988
+ if (t > 0 && t < 1) {
989
+ const mt = 1 - t;
990
+ const x = mt * mt * from.x + 2 * mt * t * cp.x + t * t * to.x;
991
+ if (x < minX) minX = x;
992
+ if (x > maxX) maxX = x;
993
+ }
994
+ }
995
+ const ty = from.y - 2 * cp.y + to.y;
996
+ if (ty !== 0) {
997
+ const t = (from.y - cp.y) / ty;
998
+ if (t > 0 && t < 1) {
999
+ const mt = 1 - t;
1000
+ const y = mt * mt * from.y + 2 * mt * t * cp.y + t * t * to.y;
1001
+ if (y < minY) minY = y;
1002
+ if (y > maxY) maxY = y;
1003
+ }
1004
+ }
1005
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1006
+ }
1007
+ function boundsIntersect(a, b) {
1008
+ return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
1009
+ }
1010
+
1011
+ // src/elements/element-store.ts
1012
+ var ElementStore = class {
1013
+ elements = /* @__PURE__ */ new Map();
1014
+ bus = new EventBus();
1015
+ layerOrderMap = /* @__PURE__ */ new Map();
1016
+ spatialIndex = new Quadtree({ x: -1e5, y: -1e5, w: 2e5, h: 2e5 });
1017
+ get count() {
1018
+ return this.elements.size;
1019
+ }
1020
+ setLayerOrder(order) {
1021
+ this.layerOrderMap = new Map(order);
1022
+ }
1023
+ getAll() {
1024
+ return [...this.elements.values()].sort((a, b) => {
1025
+ const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
1026
+ const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
1027
+ if (layerA !== layerB) return layerA - layerB;
1028
+ return a.zIndex - b.zIndex;
1029
+ });
1030
+ }
1031
+ getById(id) {
1032
+ return this.elements.get(id);
1033
+ }
1034
+ getElementsByType(type) {
1035
+ return this.getAll().filter(
1036
+ (el) => el.type === type
1037
+ );
1038
+ }
1039
+ add(element) {
1040
+ this.elements.set(element.id, element);
1041
+ const bounds = getElementBounds(element);
1042
+ if (bounds) this.spatialIndex.insert(element.id, bounds);
1043
+ this.bus.emit("add", element);
1044
+ }
1045
+ update(id, partial) {
1046
+ const existing = this.elements.get(id);
1047
+ if (!existing) return;
1048
+ const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
1049
+ this.elements.set(id, updated);
1050
+ const newBounds = getElementBounds(updated);
1051
+ if (newBounds) {
1052
+ this.spatialIndex.update(id, newBounds);
1053
+ }
1054
+ this.bus.emit("update", { previous: existing, current: updated });
1055
+ }
1056
+ remove(id) {
1057
+ const element = this.elements.get(id);
1058
+ if (!element) return;
1059
+ this.elements.delete(id);
1060
+ this.spatialIndex.remove(id);
1061
+ this.bus.emit("remove", element);
1062
+ }
1063
+ clear() {
1064
+ this.elements.clear();
1065
+ this.spatialIndex.clear();
1066
+ this.bus.emit("clear", null);
1067
+ }
1068
+ snapshot() {
1069
+ return this.getAll().map((el) => ({ ...el }));
1070
+ }
1071
+ loadSnapshot(elements) {
1072
+ this.elements.clear();
1073
+ this.spatialIndex.clear();
1074
+ for (const el of elements) {
1075
+ this.elements.set(el.id, el);
1076
+ const bounds = getElementBounds(el);
1077
+ if (bounds) this.spatialIndex.insert(el.id, bounds);
1078
+ }
1079
+ }
1080
+ queryRect(rect) {
1081
+ const ids = this.spatialIndex.query(rect);
1082
+ const elements = [];
1083
+ for (const id of ids) {
1084
+ const el = this.elements.get(id);
1085
+ if (el) elements.push(el);
1086
+ }
1087
+ return elements.sort((a, b) => {
1088
+ const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
1089
+ const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
1090
+ if (layerA !== layerB) return layerA - layerB;
1091
+ return a.zIndex - b.zIndex;
1092
+ });
1093
+ }
1094
+ queryPoint(point) {
1095
+ return this.queryRect({ x: point.x, y: point.y, w: 0, h: 0 });
1096
+ }
1097
+ on(event, listener) {
1098
+ return this.bus.on(event, listener);
1099
+ }
1100
+ onChange(listener) {
1101
+ const unsubs = [
1102
+ this.bus.on("add", listener),
1103
+ this.bus.on("remove", listener),
1104
+ this.bus.on("update", listener),
1105
+ this.bus.on("clear", listener)
1106
+ ];
1107
+ return () => unsubs.forEach((fn) => fn());
1108
+ }
1109
+ };
1110
+
838
1111
  // src/elements/arrow-binding.ts
839
1112
  var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape"]);
840
1113
  function isBindable(element) {
@@ -849,15 +1122,6 @@ function getElementCenter(element) {
849
1122
  y: element.position.y + element.size.h / 2
850
1123
  };
851
1124
  }
852
- function getElementBounds(element) {
853
- if (!("size" in element)) return null;
854
- return {
855
- x: element.position.x,
856
- y: element.position.y,
857
- w: element.size.w,
858
- h: element.size.h
859
- };
860
- }
861
1125
  function getEdgeIntersection(bounds, outsidePoint) {
862
1126
  const cx = bounds.x + bounds.w / 2;
863
1127
  const cy = bounds.y + bounds.h / 2;
@@ -2448,6 +2712,41 @@ var DomNodeManager = class {
2448
2712
  }
2449
2713
  };
2450
2714
 
2715
+ // src/canvas/render-stats.ts
2716
+ var SAMPLE_SIZE = 60;
2717
+ var RenderStats = class {
2718
+ frameTimes = [];
2719
+ frameCount = 0;
2720
+ recordFrame(durationMs) {
2721
+ this.frameCount++;
2722
+ this.frameTimes.push(durationMs);
2723
+ if (this.frameTimes.length > SAMPLE_SIZE) {
2724
+ this.frameTimes.shift();
2725
+ }
2726
+ }
2727
+ getSnapshot() {
2728
+ const times = this.frameTimes;
2729
+ if (times.length === 0) {
2730
+ return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, frameCount: 0 };
2731
+ }
2732
+ const avg = times.reduce((a, b) => a + b, 0) / times.length;
2733
+ const sorted = [...times].sort((a, b) => a - b);
2734
+ const p95Index = Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1);
2735
+ const lastFrame = times[times.length - 1] ?? 0;
2736
+ return {
2737
+ fps: avg > 0 ? Math.round(1e3 / avg) : 0,
2738
+ avgFrameMs: Math.round(avg * 100) / 100,
2739
+ p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
2740
+ lastFrameMs: Math.round(lastFrame * 100) / 100,
2741
+ frameCount: this.frameCount
2742
+ };
2743
+ }
2744
+ reset() {
2745
+ this.frameTimes = [];
2746
+ this.frameCount = 0;
2747
+ }
2748
+ };
2749
+
2451
2750
  // src/canvas/render-loop.ts
2452
2751
  var RenderLoop = class {
2453
2752
  needsRender = false;
@@ -2460,6 +2759,12 @@ var RenderLoop = class {
2460
2759
  toolManager;
2461
2760
  layerManager;
2462
2761
  domNodeManager;
2762
+ layerCache;
2763
+ activeDrawingLayerId = null;
2764
+ lastZoom;
2765
+ lastCamX;
2766
+ lastCamY;
2767
+ stats = new RenderStats();
2463
2768
  constructor(deps) {
2464
2769
  this.canvasEl = deps.canvasEl;
2465
2770
  this.camera = deps.camera;
@@ -2469,6 +2774,10 @@ var RenderLoop = class {
2469
2774
  this.toolManager = deps.toolManager;
2470
2775
  this.layerManager = deps.layerManager;
2471
2776
  this.domNodeManager = deps.domNodeManager;
2777
+ this.layerCache = deps.layerCache;
2778
+ this.lastZoom = deps.camera.zoom;
2779
+ this.lastCamX = deps.camera.position.x;
2780
+ this.lastCamY = deps.camera.position.y;
2472
2781
  }
2473
2782
  requestRender() {
2474
2783
  this.needsRender = true;
@@ -2495,19 +2804,63 @@ var RenderLoop = class {
2495
2804
  setCanvasSize(width, height) {
2496
2805
  this.canvasEl.width = width;
2497
2806
  this.canvasEl.height = height;
2807
+ this.layerCache.resize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
2808
+ }
2809
+ setActiveDrawingLayer(layerId) {
2810
+ this.activeDrawingLayerId = layerId;
2811
+ }
2812
+ markLayerDirty(layerId) {
2813
+ this.layerCache.markDirty(layerId);
2814
+ }
2815
+ markAllLayersDirty() {
2816
+ this.layerCache.markAllDirty();
2817
+ }
2818
+ getStats() {
2819
+ return this.stats.getSnapshot();
2820
+ }
2821
+ compositeLayerCache(ctx, layerId, dpr) {
2822
+ const cached = this.layerCache.getCanvas(layerId);
2823
+ ctx.save();
2824
+ ctx.scale(1 / this.camera.zoom, 1 / this.camera.zoom);
2825
+ ctx.translate(-this.camera.position.x, -this.camera.position.y);
2826
+ ctx.scale(1 / dpr, 1 / dpr);
2827
+ ctx.drawImage(cached, 0, 0);
2828
+ ctx.restore();
2498
2829
  }
2499
2830
  render() {
2831
+ const t0 = performance.now();
2500
2832
  const ctx = this.canvasEl.getContext("2d");
2501
2833
  if (!ctx) return;
2502
2834
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2835
+ const cssWidth = this.canvasEl.clientWidth;
2836
+ const cssHeight = this.canvasEl.clientHeight;
2837
+ const currentZoom = this.camera.zoom;
2838
+ const currentCamX = this.camera.position.x;
2839
+ const currentCamY = this.camera.position.y;
2840
+ if (currentZoom !== this.lastZoom || currentCamX !== this.lastCamX || currentCamY !== this.lastCamY) {
2841
+ this.layerCache.markAllDirty();
2842
+ this.lastZoom = currentZoom;
2843
+ this.lastCamX = currentCamX;
2844
+ this.lastCamY = currentCamY;
2845
+ }
2503
2846
  ctx.save();
2504
2847
  ctx.scale(dpr, dpr);
2505
- this.renderer.setCanvasSize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
2848
+ this.renderer.setCanvasSize(cssWidth, cssHeight);
2506
2849
  this.background.render(ctx, this.camera);
2507
2850
  ctx.save();
2508
2851
  ctx.translate(this.camera.position.x, this.camera.position.y);
2509
2852
  ctx.scale(this.camera.zoom, this.camera.zoom);
2853
+ const visibleRect = this.camera.getVisibleRect(cssWidth, cssHeight);
2854
+ const margin = Math.max(visibleRect.w, visibleRect.h) * 0.1;
2855
+ const cullingRect = {
2856
+ x: visibleRect.x - margin,
2857
+ y: visibleRect.y - margin,
2858
+ w: visibleRect.w + margin * 2,
2859
+ h: visibleRect.h + margin * 2
2860
+ };
2510
2861
  const allElements = this.store.getAll();
2862
+ const layerElements = /* @__PURE__ */ new Map();
2863
+ const gridElements = [];
2511
2864
  let domZIndex = 0;
2512
2865
  for (const element of allElements) {
2513
2866
  if (!this.layerManager.isLayerVisible(element.layerId)) {
@@ -2517,9 +2870,54 @@ var RenderLoop = class {
2517
2870
  continue;
2518
2871
  }
2519
2872
  if (this.renderer.isDomElement(element)) {
2520
- this.domNodeManager.syncDomNode(element, domZIndex++);
2521
- } else {
2522
- this.renderer.renderCanvasElement(ctx, element);
2873
+ const elBounds = getElementBounds(element);
2874
+ if (elBounds && !boundsIntersect(elBounds, cullingRect)) {
2875
+ this.domNodeManager.hideDomNode(element.id);
2876
+ } else {
2877
+ this.domNodeManager.syncDomNode(element, domZIndex++);
2878
+ }
2879
+ continue;
2880
+ }
2881
+ if (element.type === "grid") {
2882
+ gridElements.push(element);
2883
+ continue;
2884
+ }
2885
+ let group = layerElements.get(element.layerId);
2886
+ if (!group) {
2887
+ group = [];
2888
+ layerElements.set(element.layerId, group);
2889
+ }
2890
+ group.push(element);
2891
+ }
2892
+ for (const grid of gridElements) {
2893
+ this.renderer.renderCanvasElement(ctx, grid);
2894
+ }
2895
+ for (const [layerId, elements] of layerElements) {
2896
+ const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
2897
+ if (!this.layerCache.isDirty(layerId)) {
2898
+ this.compositeLayerCache(ctx, layerId, dpr);
2899
+ continue;
2900
+ }
2901
+ if (isActiveDrawingLayer) {
2902
+ this.compositeLayerCache(ctx, layerId, dpr);
2903
+ continue;
2904
+ }
2905
+ const offCtx = this.layerCache.getContext(layerId);
2906
+ if (offCtx) {
2907
+ const offCanvas = this.layerCache.getCanvas(layerId);
2908
+ offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
2909
+ offCtx.save();
2910
+ offCtx.scale(dpr, dpr);
2911
+ offCtx.translate(this.camera.position.x, this.camera.position.y);
2912
+ offCtx.scale(this.camera.zoom, this.camera.zoom);
2913
+ for (const element of elements) {
2914
+ const elBounds = getElementBounds(element);
2915
+ if (elBounds && !boundsIntersect(elBounds, cullingRect)) continue;
2916
+ this.renderer.renderCanvasElement(offCtx, element);
2917
+ }
2918
+ offCtx.restore();
2919
+ this.layerCache.markClean(layerId);
2920
+ this.compositeLayerCache(ctx, layerId, dpr);
2523
2921
  }
2524
2922
  }
2525
2923
  const activeTool = this.toolManager.activeTool;
@@ -2528,6 +2926,70 @@ var RenderLoop = class {
2528
2926
  }
2529
2927
  ctx.restore();
2530
2928
  ctx.restore();
2929
+ this.stats.recordFrame(performance.now() - t0);
2930
+ }
2931
+ };
2932
+
2933
+ // src/canvas/layer-cache.ts
2934
+ function createOffscreenCanvas(width, height) {
2935
+ if (typeof OffscreenCanvas !== "undefined") {
2936
+ return new OffscreenCanvas(width, height);
2937
+ }
2938
+ const canvas = document.createElement("canvas");
2939
+ canvas.width = width;
2940
+ canvas.height = height;
2941
+ return canvas;
2942
+ }
2943
+ var LayerCache = class {
2944
+ canvases = /* @__PURE__ */ new Map();
2945
+ dirtyFlags = /* @__PURE__ */ new Map();
2946
+ width;
2947
+ height;
2948
+ constructor(width, height) {
2949
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2950
+ this.width = Math.round(width * dpr);
2951
+ this.height = Math.round(height * dpr);
2952
+ }
2953
+ isDirty(layerId) {
2954
+ return this.dirtyFlags.get(layerId) !== false;
2955
+ }
2956
+ markDirty(layerId) {
2957
+ this.dirtyFlags.set(layerId, true);
2958
+ }
2959
+ markClean(layerId) {
2960
+ this.dirtyFlags.set(layerId, false);
2961
+ }
2962
+ markAllDirty() {
2963
+ for (const [id] of this.dirtyFlags) {
2964
+ this.dirtyFlags.set(id, true);
2965
+ }
2966
+ }
2967
+ getCanvas(layerId) {
2968
+ let canvas = this.canvases.get(layerId);
2969
+ if (!canvas) {
2970
+ canvas = createOffscreenCanvas(this.width, this.height);
2971
+ this.canvases.set(layerId, canvas);
2972
+ this.dirtyFlags.set(layerId, true);
2973
+ }
2974
+ return canvas;
2975
+ }
2976
+ getContext(layerId) {
2977
+ const canvas = this.getCanvas(layerId);
2978
+ return canvas.getContext("2d");
2979
+ }
2980
+ resize(width, height) {
2981
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2982
+ this.width = Math.round(width * dpr);
2983
+ this.height = Math.round(height * dpr);
2984
+ for (const [id, canvas] of this.canvases) {
2985
+ canvas.width = this.width;
2986
+ canvas.height = this.height;
2987
+ this.dirtyFlags.set(id, true);
2988
+ }
2989
+ }
2990
+ clear() {
2991
+ this.canvases.clear();
2992
+ this.dirtyFlags.clear();
2531
2993
  }
2532
2994
  };
2533
2995
 
@@ -2584,6 +3046,10 @@ var Viewport = class {
2584
3046
  this.interactMode = new InteractMode({
2585
3047
  getNode: (id) => this.domNodeManager.getNode(id)
2586
3048
  });
3049
+ const layerCache = new LayerCache(
3050
+ this.canvasEl.clientWidth || 800,
3051
+ this.canvasEl.clientHeight || 600
3052
+ );
2587
3053
  this.renderLoop = new RenderLoop({
2588
3054
  canvasEl: this.canvasEl,
2589
3055
  camera: this.camera,
@@ -2592,22 +3058,34 @@ var Viewport = class {
2592
3058
  renderer: this.renderer,
2593
3059
  toolManager: this.toolManager,
2594
3060
  layerManager: this.layerManager,
2595
- domNodeManager: this.domNodeManager
3061
+ domNodeManager: this.domNodeManager,
3062
+ layerCache
2596
3063
  });
2597
3064
  this.unsubCamera = this.camera.onChange(() => {
2598
3065
  this.applyCameraTransform();
2599
3066
  this.requestRender();
2600
3067
  });
2601
3068
  this.unsubStore = [
2602
- this.store.on("add", () => this.requestRender()),
3069
+ this.store.on("add", (el) => {
3070
+ this.renderLoop.markLayerDirty(el.layerId);
3071
+ this.requestRender();
3072
+ }),
2603
3073
  this.store.on("remove", (el) => {
2604
3074
  this.unbindArrowsFrom(el);
2605
3075
  this.domNodeManager.removeDomNode(el.id);
3076
+ this.renderLoop.markLayerDirty(el.layerId);
3077
+ this.requestRender();
3078
+ }),
3079
+ this.store.on("update", ({ previous, current }) => {
3080
+ this.renderLoop.markLayerDirty(current.layerId);
3081
+ if (previous.layerId !== current.layerId) {
3082
+ this.renderLoop.markLayerDirty(previous.layerId);
3083
+ }
2606
3084
  this.requestRender();
2607
3085
  }),
2608
- this.store.on("update", () => this.requestRender()),
2609
3086
  this.store.on("clear", () => {
2610
3087
  this.domNodeManager.clearDomNodes();
3088
+ this.renderLoop.markAllLayersDirty();
2611
3089
  this.requestRender();
2612
3090
  })
2613
3091
  ];
@@ -2813,8 +3291,8 @@ var Viewport = class {
2813
3291
  }
2814
3292
  };
2815
3293
  hitTestWorld(world) {
2816
- const elements = this.store.getAll().reverse();
2817
- for (const el of elements) {
3294
+ const candidates = this.store.queryPoint(world).reverse();
3295
+ for (const el of candidates) {
2818
3296
  if (!("size" in el)) continue;
2819
3297
  const { x, y } = el.position;
2820
3298
  const { w, h } = el.size;
@@ -2965,6 +3443,9 @@ var HandTool = class {
2965
3443
  var MIN_POINTS_FOR_STROKE = 2;
2966
3444
  var DEFAULT_SMOOTHING = 1.5;
2967
3445
  var DEFAULT_PRESSURE = 0.5;
3446
+ var DEFAULT_MIN_POINT_DISTANCE = 3;
3447
+ var DEFAULT_PROGRESSIVE_THRESHOLD = 200;
3448
+ var PROGRESSIVE_HOT_ZONE = 30;
2968
3449
  var PencilTool = class {
2969
3450
  name = "pencil";
2970
3451
  drawing = false;
@@ -2972,11 +3453,17 @@ var PencilTool = class {
2972
3453
  color;
2973
3454
  width;
2974
3455
  smoothing;
3456
+ minPointDistance;
3457
+ progressiveThreshold;
3458
+ nextSimplifyAt;
2975
3459
  optionListeners = /* @__PURE__ */ new Set();
2976
3460
  constructor(options = {}) {
2977
3461
  this.color = options.color ?? "#000000";
2978
3462
  this.width = options.width ?? 2;
2979
3463
  this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
3464
+ this.minPointDistance = options.minPointDistance ?? DEFAULT_MIN_POINT_DISTANCE;
3465
+ this.progressiveThreshold = options.progressiveSimplifyThreshold ?? DEFAULT_PROGRESSIVE_THRESHOLD;
3466
+ this.nextSimplifyAt = this.progressiveThreshold;
2980
3467
  }
2981
3468
  onActivate(ctx) {
2982
3469
  ctx.setCursor?.("crosshair");
@@ -2985,7 +3472,13 @@ var PencilTool = class {
2985
3472
  ctx.setCursor?.("default");
2986
3473
  }
2987
3474
  getOptions() {
2988
- return { color: this.color, width: this.width, smoothing: this.smoothing };
3475
+ return {
3476
+ color: this.color,
3477
+ width: this.width,
3478
+ smoothing: this.smoothing,
3479
+ minPointDistance: this.minPointDistance,
3480
+ progressiveSimplifyThreshold: this.progressiveThreshold
3481
+ };
2989
3482
  }
2990
3483
  onOptionsChange(listener) {
2991
3484
  this.optionListeners.add(listener);
@@ -2995,6 +3488,9 @@ var PencilTool = class {
2995
3488
  if (options.color !== void 0) this.color = options.color;
2996
3489
  if (options.width !== void 0) this.width = options.width;
2997
3490
  if (options.smoothing !== void 0) this.smoothing = options.smoothing;
3491
+ if (options.minPointDistance !== void 0) this.minPointDistance = options.minPointDistance;
3492
+ if (options.progressiveSimplifyThreshold !== void 0)
3493
+ this.progressiveThreshold = options.progressiveSimplifyThreshold;
2998
3494
  this.notifyOptionsChange();
2999
3495
  }
3000
3496
  onPointerDown(state, ctx) {
@@ -3002,12 +3498,26 @@ var PencilTool = class {
3002
3498
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
3003
3499
  const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
3004
3500
  this.points = [{ x: world.x, y: world.y, pressure }];
3501
+ this.nextSimplifyAt = this.progressiveThreshold;
3005
3502
  }
3006
3503
  onPointerMove(state, ctx) {
3007
3504
  if (!this.drawing) return;
3008
3505
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
3009
3506
  const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
3507
+ const last = this.points[this.points.length - 1];
3508
+ if (last) {
3509
+ const dx = world.x - last.x;
3510
+ const dy = world.y - last.y;
3511
+ if (dx * dx + dy * dy < this.minPointDistance * this.minPointDistance) return;
3512
+ }
3010
3513
  this.points.push({ x: world.x, y: world.y, pressure });
3514
+ if (this.points.length > this.nextSimplifyAt) {
3515
+ const hotZone = this.points.slice(-PROGRESSIVE_HOT_ZONE);
3516
+ const coldZone = this.points.slice(0, -PROGRESSIVE_HOT_ZONE);
3517
+ const simplified = simplifyPoints(coldZone, this.smoothing * 2);
3518
+ this.points = [...simplified, ...hotZone];
3519
+ this.nextSimplifyAt = this.points.length + this.progressiveThreshold;
3520
+ }
3011
3521
  ctx.requestRender();
3012
3522
  }
3013
3523
  onPointerUp(_state, ctx) {
@@ -3089,13 +3599,20 @@ var EraserTool = class {
3089
3599
  }
3090
3600
  eraseAt(state, ctx) {
3091
3601
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
3092
- const strokes = ctx.store.getElementsByType("stroke");
3602
+ const queryBounds = {
3603
+ x: world.x - this.radius,
3604
+ y: world.y - this.radius,
3605
+ w: this.radius * 2,
3606
+ h: this.radius * 2
3607
+ };
3608
+ const candidates = ctx.store.queryRect(queryBounds);
3093
3609
  let erased = false;
3094
- for (const stroke of strokes) {
3095
- if (ctx.isLayerVisible && !ctx.isLayerVisible(stroke.layerId)) continue;
3096
- if (ctx.isLayerLocked && ctx.isLayerLocked(stroke.layerId)) continue;
3097
- if (this.strokeIntersects(stroke, world)) {
3098
- ctx.store.remove(stroke.id);
3610
+ for (const el of candidates) {
3611
+ if (el.type !== "stroke") continue;
3612
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
3613
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3614
+ if (this.strokeIntersects(el, world)) {
3615
+ ctx.store.remove(el.id);
3099
3616
  erased = true;
3100
3617
  }
3101
3618
  }
@@ -3472,7 +3989,7 @@ var SelectTool = class {
3472
3989
  for (const id of this._selectedIds) {
3473
3990
  const el = ctx.store.getById(id);
3474
3991
  if (!el || !("size" in el)) continue;
3475
- const bounds = this.getElementBounds(el);
3992
+ const bounds = getElementBounds(el);
3476
3993
  if (!bounds) continue;
3477
3994
  const corners = this.getHandlePositions(bounds);
3478
3995
  for (const [handle, pos] of corners) {
@@ -3520,7 +4037,7 @@ var SelectTool = class {
3520
4037
  this.renderBindingHighlights(canvasCtx, el, zoom);
3521
4038
  continue;
3522
4039
  }
3523
- const bounds = this.getElementBounds(el);
4040
+ const bounds = getElementBounds(el);
3524
4041
  if (!bounds) continue;
3525
4042
  const pad = SELECTION_PAD / zoom;
3526
4043
  canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
@@ -3579,12 +4096,13 @@ var SelectTool = class {
3579
4096
  return { x, y, w, h };
3580
4097
  }
3581
4098
  findElementsInRect(marquee, ctx) {
4099
+ const candidates = ctx.store.queryRect(marquee);
3582
4100
  const ids = [];
3583
- for (const el of ctx.store.getAll()) {
4101
+ for (const el of candidates) {
3584
4102
  if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
3585
4103
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3586
4104
  if (el.type === "grid") continue;
3587
- const bounds = this.getElementBounds(el);
4105
+ const bounds = getElementBounds(el);
3588
4106
  if (bounds && this.rectsOverlap(marquee, bounds)) {
3589
4107
  ids.push(el.id);
3590
4108
  }
@@ -3594,30 +4112,10 @@ var SelectTool = class {
3594
4112
  rectsOverlap(a, b) {
3595
4113
  return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
3596
4114
  }
3597
- getElementBounds(el) {
3598
- if ("size" in el) {
3599
- return { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
3600
- }
3601
- if (el.type === "stroke" && el.points.length > 0) {
3602
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
3603
- for (const p of el.points) {
3604
- const px = p.x + el.position.x;
3605
- const py = p.y + el.position.y;
3606
- if (px < minX) minX = px;
3607
- if (py < minY) minY = py;
3608
- if (px > maxX) maxX = px;
3609
- if (py > maxY) maxY = py;
3610
- }
3611
- return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
3612
- }
3613
- if (el.type === "arrow") {
3614
- return getArrowBounds(el.from, el.to, el.bend);
3615
- }
3616
- return null;
3617
- }
3618
4115
  hitTest(world, ctx) {
3619
- const elements = ctx.store.getAll().reverse();
3620
- for (const el of elements) {
4116
+ const r = 10;
4117
+ const candidates = ctx.store.queryRect({ x: world.x - r, y: world.y - r, w: r * 2, h: r * 2 }).reverse();
4118
+ for (const el of candidates) {
3621
4119
  if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
3622
4120
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3623
4121
  if (el.type === "grid") continue;
@@ -4111,7 +4609,7 @@ var UpdateLayerCommand = class {
4111
4609
  };
4112
4610
 
4113
4611
  // src/index.ts
4114
- var VERSION = "0.8.6";
4612
+ var VERSION = "0.8.7";
4115
4613
  // Annotate the CommonJS export names for ESM import in node:
4116
4614
  0 && (module.exports = {
4117
4615
  AddElementCommand,
@@ -4134,6 +4632,7 @@ var VERSION = "0.8.6";
4134
4632
  NoteEditor,
4135
4633
  NoteTool,
4136
4634
  PencilTool,
4635
+ Quadtree,
4137
4636
  RemoveElementCommand,
4138
4637
  RemoveLayerCommand,
4139
4638
  SelectTool,
@@ -4144,6 +4643,7 @@ var VERSION = "0.8.6";
4144
4643
  UpdateLayerCommand,
4145
4644
  VERSION,
4146
4645
  Viewport,
4646
+ boundsIntersect,
4147
4647
  clearStaleBindings,
4148
4648
  createArrow,
4149
4649
  createGrid,