@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.js CHANGED
@@ -22,6 +22,153 @@ var EventBus = class {
22
22
  }
23
23
  };
24
24
 
25
+ // src/core/quadtree.ts
26
+ var MAX_ITEMS = 8;
27
+ var MAX_DEPTH = 8;
28
+ function intersects(a, b) {
29
+ 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;
30
+ }
31
+ var QuadNode = class _QuadNode {
32
+ constructor(bounds, depth) {
33
+ this.bounds = bounds;
34
+ this.depth = depth;
35
+ }
36
+ items = [];
37
+ children = null;
38
+ insert(entry) {
39
+ if (this.children) {
40
+ const idx = this.getChildIndex(entry.bounds);
41
+ if (idx !== -1) {
42
+ const child = this.children[idx];
43
+ if (child) child.insert(entry);
44
+ return;
45
+ }
46
+ this.items.push(entry);
47
+ return;
48
+ }
49
+ this.items.push(entry);
50
+ if (this.items.length > MAX_ITEMS && this.depth < MAX_DEPTH) {
51
+ this.split();
52
+ }
53
+ }
54
+ remove(id) {
55
+ const idx = this.items.findIndex((e) => e.id === id);
56
+ if (idx !== -1) {
57
+ this.items.splice(idx, 1);
58
+ return true;
59
+ }
60
+ if (this.children) {
61
+ for (const child of this.children) {
62
+ if (child.remove(id)) {
63
+ this.collapseIfEmpty();
64
+ return true;
65
+ }
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+ query(rect, result) {
71
+ if (!intersects(this.bounds, rect)) return;
72
+ for (const item of this.items) {
73
+ if (intersects(item.bounds, rect)) {
74
+ result.push(item.id);
75
+ }
76
+ }
77
+ if (this.children) {
78
+ for (const child of this.children) {
79
+ child.query(rect, result);
80
+ }
81
+ }
82
+ }
83
+ getChildIndex(itemBounds) {
84
+ const midX = this.bounds.x + this.bounds.w / 2;
85
+ const midY = this.bounds.y + this.bounds.h / 2;
86
+ const left = itemBounds.x >= this.bounds.x && itemBounds.x + itemBounds.w <= midX;
87
+ const right = itemBounds.x >= midX && itemBounds.x + itemBounds.w <= this.bounds.x + this.bounds.w;
88
+ const top = itemBounds.y >= this.bounds.y && itemBounds.y + itemBounds.h <= midY;
89
+ const bottom = itemBounds.y >= midY && itemBounds.y + itemBounds.h <= this.bounds.y + this.bounds.h;
90
+ if (left && top) return 0;
91
+ if (right && top) return 1;
92
+ if (left && bottom) return 2;
93
+ if (right && bottom) return 3;
94
+ return -1;
95
+ }
96
+ split() {
97
+ const { x, y, w, h } = this.bounds;
98
+ const halfW = w / 2;
99
+ const halfH = h / 2;
100
+ const d = this.depth + 1;
101
+ this.children = [
102
+ new _QuadNode({ x, y, w: halfW, h: halfH }, d),
103
+ new _QuadNode({ x: x + halfW, y, w: halfW, h: halfH }, d),
104
+ new _QuadNode({ x, y: y + halfH, w: halfW, h: halfH }, d),
105
+ new _QuadNode({ x: x + halfW, y: y + halfH, w: halfW, h: halfH }, d)
106
+ ];
107
+ const remaining = [];
108
+ for (const item of this.items) {
109
+ const idx = this.getChildIndex(item.bounds);
110
+ if (idx !== -1) {
111
+ const target = this.children[idx];
112
+ if (target) target.insert(item);
113
+ } else {
114
+ remaining.push(item);
115
+ }
116
+ }
117
+ this.items = remaining;
118
+ }
119
+ collapseIfEmpty() {
120
+ if (!this.children) return;
121
+ let totalItems = this.items.length;
122
+ for (const child of this.children) {
123
+ if (child.children) return;
124
+ totalItems += child.items.length;
125
+ }
126
+ if (totalItems <= MAX_ITEMS) {
127
+ for (const child of this.children) {
128
+ this.items.push(...child.items);
129
+ }
130
+ this.children = null;
131
+ }
132
+ }
133
+ };
134
+ var Quadtree = class {
135
+ root;
136
+ _size = 0;
137
+ worldBounds;
138
+ constructor(worldBounds) {
139
+ this.worldBounds = worldBounds;
140
+ this.root = new QuadNode(worldBounds, 0);
141
+ }
142
+ get size() {
143
+ return this._size;
144
+ }
145
+ insert(id, bounds) {
146
+ this.root.insert({ id, bounds });
147
+ this._size++;
148
+ }
149
+ remove(id) {
150
+ if (this.root.remove(id)) {
151
+ this._size--;
152
+ }
153
+ }
154
+ update(id, newBounds) {
155
+ this.remove(id);
156
+ this.insert(id, newBounds);
157
+ }
158
+ query(rect) {
159
+ const result = [];
160
+ this.root.query(rect, result);
161
+ return result;
162
+ }
163
+ queryPoint(point) {
164
+ return this.query({ x: point.x, y: point.y, w: 0, h: 0 });
165
+ }
166
+ clear() {
167
+ this.root = new QuadNode(this.worldBounds, 0);
168
+ this._size = 0;
169
+ }
170
+ };
171
+
25
172
  // src/core/state-serializer.ts
26
173
  var CURRENT_VERSION = 2;
27
174
  function exportState(elements, camera, layers = []) {
@@ -236,16 +383,16 @@ var Camera = class {
236
383
  pan(dx, dy) {
237
384
  this.x += dx;
238
385
  this.y += dy;
239
- this.notifyChange();
386
+ this.notifyPan();
240
387
  }
241
388
  moveTo(x, y) {
242
389
  this.x = x;
243
390
  this.y = y;
244
- this.notifyChange();
391
+ this.notifyPan();
245
392
  }
246
393
  setZoom(level) {
247
394
  this.z = Math.min(this.maxZoom, Math.max(this.minZoom, level));
248
- this.notifyChange();
395
+ this.notifyZoom();
249
396
  }
250
397
  zoomAt(level, screenPoint) {
251
398
  const before = this.screenToWorld(screenPoint);
@@ -253,7 +400,7 @@ var Camera = class {
253
400
  const after = this.screenToWorld(screenPoint);
254
401
  this.x += (after.x - before.x) * this.z;
255
402
  this.y += (after.y - before.y) * this.z;
256
- this.notifyChange();
403
+ this.notifyPanAndZoom();
257
404
  }
258
405
  screenToWorld(screen) {
259
406
  return {
@@ -267,6 +414,16 @@ var Camera = class {
267
414
  y: world.y * this.z + this.y
268
415
  };
269
416
  }
417
+ getVisibleRect(canvasWidth, canvasHeight) {
418
+ const topLeft = this.screenToWorld({ x: 0, y: 0 });
419
+ const bottomRight = this.screenToWorld({ x: canvasWidth, y: canvasHeight });
420
+ return {
421
+ x: topLeft.x,
422
+ y: topLeft.y,
423
+ w: bottomRight.x - topLeft.x,
424
+ h: bottomRight.y - topLeft.y
425
+ };
426
+ }
270
427
  toCSSTransform() {
271
428
  return `translate3d(${this.x}px, ${this.y}px, 0) scale(${this.z})`;
272
429
  }
@@ -274,8 +431,14 @@ var Camera = class {
274
431
  this.changeListeners.add(listener);
275
432
  return () => this.changeListeners.delete(listener);
276
433
  }
277
- notifyChange() {
278
- this.changeListeners.forEach((fn) => fn());
434
+ notifyPan() {
435
+ this.changeListeners.forEach((fn) => fn({ panned: true, zoomed: false }));
436
+ }
437
+ notifyZoom() {
438
+ this.changeListeners.forEach((fn) => fn({ panned: false, zoomed: true }));
439
+ }
440
+ notifyPanAndZoom() {
441
+ this.changeListeners.forEach((fn) => fn({ panned: true, zoomed: true }));
279
442
  }
280
443
  };
281
444
 
@@ -581,77 +744,6 @@ var InputHandler = class {
581
744
  }
582
745
  };
583
746
 
584
- // src/elements/element-store.ts
585
- var ElementStore = class {
586
- elements = /* @__PURE__ */ new Map();
587
- bus = new EventBus();
588
- layerOrderMap = /* @__PURE__ */ new Map();
589
- get count() {
590
- return this.elements.size;
591
- }
592
- setLayerOrder(order) {
593
- this.layerOrderMap = new Map(order);
594
- }
595
- getAll() {
596
- return [...this.elements.values()].sort((a, b) => {
597
- const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
598
- const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
599
- if (layerA !== layerB) return layerA - layerB;
600
- return a.zIndex - b.zIndex;
601
- });
602
- }
603
- getById(id) {
604
- return this.elements.get(id);
605
- }
606
- getElementsByType(type) {
607
- return this.getAll().filter(
608
- (el) => el.type === type
609
- );
610
- }
611
- add(element) {
612
- this.elements.set(element.id, element);
613
- this.bus.emit("add", element);
614
- }
615
- update(id, partial) {
616
- const existing = this.elements.get(id);
617
- if (!existing) return;
618
- const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
619
- this.elements.set(id, updated);
620
- this.bus.emit("update", { previous: existing, current: updated });
621
- }
622
- remove(id) {
623
- const element = this.elements.get(id);
624
- if (!element) return;
625
- this.elements.delete(id);
626
- this.bus.emit("remove", element);
627
- }
628
- clear() {
629
- this.elements.clear();
630
- this.bus.emit("clear", null);
631
- }
632
- snapshot() {
633
- return this.getAll().map((el) => ({ ...el }));
634
- }
635
- loadSnapshot(elements) {
636
- this.elements.clear();
637
- for (const el of elements) {
638
- this.elements.set(el.id, el);
639
- }
640
- }
641
- on(event, listener) {
642
- return this.bus.on(event, listener);
643
- }
644
- onChange(listener) {
645
- const unsubs = [
646
- this.bus.on("add", listener),
647
- this.bus.on("remove", listener),
648
- this.bus.on("update", listener),
649
- this.bus.on("clear", listener)
650
- ];
651
- return () => unsubs.forEach((fn) => fn());
652
- }
653
- };
654
-
655
747
  // src/elements/arrow-geometry.ts
656
748
  function getArrowControlPoint(from, to, bend) {
657
749
  const midX = (from.x + to.x) / 2;
@@ -752,6 +844,185 @@ function isNearLine(point, a, b, threshold) {
752
844
  return Math.hypot(point.x - projX, point.y - projY) <= threshold;
753
845
  }
754
846
 
847
+ // src/elements/element-bounds.ts
848
+ var strokeBoundsCache = /* @__PURE__ */ new WeakMap();
849
+ function getElementBounds(element) {
850
+ if (element.type === "grid") return null;
851
+ if ("size" in element) {
852
+ return {
853
+ x: element.position.x,
854
+ y: element.position.y,
855
+ w: element.size.w,
856
+ h: element.size.h
857
+ };
858
+ }
859
+ if (element.type === "stroke") {
860
+ if (element.points.length === 0) return null;
861
+ const cached = strokeBoundsCache.get(element);
862
+ if (cached) return cached;
863
+ let minX = Infinity;
864
+ let minY = Infinity;
865
+ let maxX = -Infinity;
866
+ let maxY = -Infinity;
867
+ for (const p of element.points) {
868
+ const px = p.x + element.position.x;
869
+ const py = p.y + element.position.y;
870
+ if (px < minX) minX = px;
871
+ if (py < minY) minY = py;
872
+ if (px > maxX) maxX = px;
873
+ if (py > maxY) maxY = py;
874
+ }
875
+ const bounds = { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
876
+ strokeBoundsCache.set(element, bounds);
877
+ return bounds;
878
+ }
879
+ if (element.type === "arrow") {
880
+ return getArrowBoundsAnalytical(element.from, element.to, element.bend);
881
+ }
882
+ return null;
883
+ }
884
+ function getArrowBoundsAnalytical(from, to, bend) {
885
+ if (bend === 0) {
886
+ const minX2 = Math.min(from.x, to.x);
887
+ const minY2 = Math.min(from.y, to.y);
888
+ return {
889
+ x: minX2,
890
+ y: minY2,
891
+ w: Math.abs(to.x - from.x),
892
+ h: Math.abs(to.y - from.y)
893
+ };
894
+ }
895
+ const cp = getArrowControlPoint(from, to, bend);
896
+ let minX = Math.min(from.x, to.x);
897
+ let maxX = Math.max(from.x, to.x);
898
+ let minY = Math.min(from.y, to.y);
899
+ let maxY = Math.max(from.y, to.y);
900
+ const tx = from.x - 2 * cp.x + to.x;
901
+ if (tx !== 0) {
902
+ const t = (from.x - cp.x) / tx;
903
+ if (t > 0 && t < 1) {
904
+ const mt = 1 - t;
905
+ const x = mt * mt * from.x + 2 * mt * t * cp.x + t * t * to.x;
906
+ if (x < minX) minX = x;
907
+ if (x > maxX) maxX = x;
908
+ }
909
+ }
910
+ const ty = from.y - 2 * cp.y + to.y;
911
+ if (ty !== 0) {
912
+ const t = (from.y - cp.y) / ty;
913
+ if (t > 0 && t < 1) {
914
+ const mt = 1 - t;
915
+ const y = mt * mt * from.y + 2 * mt * t * cp.y + t * t * to.y;
916
+ if (y < minY) minY = y;
917
+ if (y > maxY) maxY = y;
918
+ }
919
+ }
920
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
921
+ }
922
+ function boundsIntersect(a, b) {
923
+ 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;
924
+ }
925
+
926
+ // src/elements/element-store.ts
927
+ var ElementStore = class {
928
+ elements = /* @__PURE__ */ new Map();
929
+ bus = new EventBus();
930
+ layerOrderMap = /* @__PURE__ */ new Map();
931
+ spatialIndex = new Quadtree({ x: -1e5, y: -1e5, w: 2e5, h: 2e5 });
932
+ get count() {
933
+ return this.elements.size;
934
+ }
935
+ setLayerOrder(order) {
936
+ this.layerOrderMap = new Map(order);
937
+ }
938
+ getAll() {
939
+ return [...this.elements.values()].sort((a, b) => {
940
+ const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
941
+ const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
942
+ if (layerA !== layerB) return layerA - layerB;
943
+ return a.zIndex - b.zIndex;
944
+ });
945
+ }
946
+ getById(id) {
947
+ return this.elements.get(id);
948
+ }
949
+ getElementsByType(type) {
950
+ return this.getAll().filter(
951
+ (el) => el.type === type
952
+ );
953
+ }
954
+ add(element) {
955
+ this.elements.set(element.id, element);
956
+ const bounds = getElementBounds(element);
957
+ if (bounds) this.spatialIndex.insert(element.id, bounds);
958
+ this.bus.emit("add", element);
959
+ }
960
+ update(id, partial) {
961
+ const existing = this.elements.get(id);
962
+ if (!existing) return;
963
+ const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
964
+ this.elements.set(id, updated);
965
+ const newBounds = getElementBounds(updated);
966
+ if (newBounds) {
967
+ this.spatialIndex.update(id, newBounds);
968
+ }
969
+ this.bus.emit("update", { previous: existing, current: updated });
970
+ }
971
+ remove(id) {
972
+ const element = this.elements.get(id);
973
+ if (!element) return;
974
+ this.elements.delete(id);
975
+ this.spatialIndex.remove(id);
976
+ this.bus.emit("remove", element);
977
+ }
978
+ clear() {
979
+ this.elements.clear();
980
+ this.spatialIndex.clear();
981
+ this.bus.emit("clear", null);
982
+ }
983
+ snapshot() {
984
+ return this.getAll().map((el) => ({ ...el }));
985
+ }
986
+ loadSnapshot(elements) {
987
+ this.elements.clear();
988
+ this.spatialIndex.clear();
989
+ for (const el of elements) {
990
+ this.elements.set(el.id, el);
991
+ const bounds = getElementBounds(el);
992
+ if (bounds) this.spatialIndex.insert(el.id, bounds);
993
+ }
994
+ }
995
+ queryRect(rect) {
996
+ const ids = this.spatialIndex.query(rect);
997
+ const elements = [];
998
+ for (const id of ids) {
999
+ const el = this.elements.get(id);
1000
+ if (el) elements.push(el);
1001
+ }
1002
+ return elements.sort((a, b) => {
1003
+ const layerA = this.layerOrderMap.get(a.layerId) ?? 0;
1004
+ const layerB = this.layerOrderMap.get(b.layerId) ?? 0;
1005
+ if (layerA !== layerB) return layerA - layerB;
1006
+ return a.zIndex - b.zIndex;
1007
+ });
1008
+ }
1009
+ queryPoint(point) {
1010
+ return this.queryRect({ x: point.x, y: point.y, w: 0, h: 0 });
1011
+ }
1012
+ on(event, listener) {
1013
+ return this.bus.on(event, listener);
1014
+ }
1015
+ onChange(listener) {
1016
+ const unsubs = [
1017
+ this.bus.on("add", listener),
1018
+ this.bus.on("remove", listener),
1019
+ this.bus.on("update", listener),
1020
+ this.bus.on("clear", listener)
1021
+ ];
1022
+ return () => unsubs.forEach((fn) => fn());
1023
+ }
1024
+ };
1025
+
755
1026
  // src/elements/arrow-binding.ts
756
1027
  var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape"]);
757
1028
  function isBindable(element) {
@@ -766,15 +1037,6 @@ function getElementCenter(element) {
766
1037
  y: element.position.y + element.size.h / 2
767
1038
  };
768
1039
  }
769
- function getElementBounds(element) {
770
- if (!("size" in element)) return null;
771
- return {
772
- x: element.position.x,
773
- y: element.position.y,
774
- w: element.size.w,
775
- h: element.size.h
776
- };
777
- }
778
1040
  function getEdgeIntersection(bounds, outsidePoint) {
779
1041
  const cx = bounds.x + bounds.w / 2;
780
1042
  const cy = bounds.y + bounds.h / 2;
@@ -2365,6 +2627,41 @@ var DomNodeManager = class {
2365
2627
  }
2366
2628
  };
2367
2629
 
2630
+ // src/canvas/render-stats.ts
2631
+ var SAMPLE_SIZE = 60;
2632
+ var RenderStats = class {
2633
+ frameTimes = [];
2634
+ frameCount = 0;
2635
+ recordFrame(durationMs) {
2636
+ this.frameCount++;
2637
+ this.frameTimes.push(durationMs);
2638
+ if (this.frameTimes.length > SAMPLE_SIZE) {
2639
+ this.frameTimes.shift();
2640
+ }
2641
+ }
2642
+ getSnapshot() {
2643
+ const times = this.frameTimes;
2644
+ if (times.length === 0) {
2645
+ return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, frameCount: 0 };
2646
+ }
2647
+ const avg = times.reduce((a, b) => a + b, 0) / times.length;
2648
+ const sorted = [...times].sort((a, b) => a - b);
2649
+ const p95Index = Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1);
2650
+ const lastFrame = times[times.length - 1] ?? 0;
2651
+ return {
2652
+ fps: avg > 0 ? Math.round(1e3 / avg) : 0,
2653
+ avgFrameMs: Math.round(avg * 100) / 100,
2654
+ p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
2655
+ lastFrameMs: Math.round(lastFrame * 100) / 100,
2656
+ frameCount: this.frameCount
2657
+ };
2658
+ }
2659
+ reset() {
2660
+ this.frameTimes = [];
2661
+ this.frameCount = 0;
2662
+ }
2663
+ };
2664
+
2368
2665
  // src/canvas/render-loop.ts
2369
2666
  var RenderLoop = class {
2370
2667
  needsRender = false;
@@ -2377,6 +2674,12 @@ var RenderLoop = class {
2377
2674
  toolManager;
2378
2675
  layerManager;
2379
2676
  domNodeManager;
2677
+ layerCache;
2678
+ activeDrawingLayerId = null;
2679
+ lastZoom;
2680
+ lastCamX;
2681
+ lastCamY;
2682
+ stats = new RenderStats();
2380
2683
  constructor(deps) {
2381
2684
  this.canvasEl = deps.canvasEl;
2382
2685
  this.camera = deps.camera;
@@ -2386,6 +2689,10 @@ var RenderLoop = class {
2386
2689
  this.toolManager = deps.toolManager;
2387
2690
  this.layerManager = deps.layerManager;
2388
2691
  this.domNodeManager = deps.domNodeManager;
2692
+ this.layerCache = deps.layerCache;
2693
+ this.lastZoom = deps.camera.zoom;
2694
+ this.lastCamX = deps.camera.position.x;
2695
+ this.lastCamY = deps.camera.position.y;
2389
2696
  }
2390
2697
  requestRender() {
2391
2698
  this.needsRender = true;
@@ -2412,19 +2719,63 @@ var RenderLoop = class {
2412
2719
  setCanvasSize(width, height) {
2413
2720
  this.canvasEl.width = width;
2414
2721
  this.canvasEl.height = height;
2722
+ this.layerCache.resize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
2723
+ }
2724
+ setActiveDrawingLayer(layerId) {
2725
+ this.activeDrawingLayerId = layerId;
2726
+ }
2727
+ markLayerDirty(layerId) {
2728
+ this.layerCache.markDirty(layerId);
2729
+ }
2730
+ markAllLayersDirty() {
2731
+ this.layerCache.markAllDirty();
2732
+ }
2733
+ getStats() {
2734
+ return this.stats.getSnapshot();
2735
+ }
2736
+ compositeLayerCache(ctx, layerId, dpr) {
2737
+ const cached = this.layerCache.getCanvas(layerId);
2738
+ ctx.save();
2739
+ ctx.scale(1 / this.camera.zoom, 1 / this.camera.zoom);
2740
+ ctx.translate(-this.camera.position.x, -this.camera.position.y);
2741
+ ctx.scale(1 / dpr, 1 / dpr);
2742
+ ctx.drawImage(cached, 0, 0);
2743
+ ctx.restore();
2415
2744
  }
2416
2745
  render() {
2746
+ const t0 = performance.now();
2417
2747
  const ctx = this.canvasEl.getContext("2d");
2418
2748
  if (!ctx) return;
2419
2749
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2750
+ const cssWidth = this.canvasEl.clientWidth;
2751
+ const cssHeight = this.canvasEl.clientHeight;
2752
+ const currentZoom = this.camera.zoom;
2753
+ const currentCamX = this.camera.position.x;
2754
+ const currentCamY = this.camera.position.y;
2755
+ if (currentZoom !== this.lastZoom || currentCamX !== this.lastCamX || currentCamY !== this.lastCamY) {
2756
+ this.layerCache.markAllDirty();
2757
+ this.lastZoom = currentZoom;
2758
+ this.lastCamX = currentCamX;
2759
+ this.lastCamY = currentCamY;
2760
+ }
2420
2761
  ctx.save();
2421
2762
  ctx.scale(dpr, dpr);
2422
- this.renderer.setCanvasSize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
2763
+ this.renderer.setCanvasSize(cssWidth, cssHeight);
2423
2764
  this.background.render(ctx, this.camera);
2424
2765
  ctx.save();
2425
2766
  ctx.translate(this.camera.position.x, this.camera.position.y);
2426
2767
  ctx.scale(this.camera.zoom, this.camera.zoom);
2768
+ const visibleRect = this.camera.getVisibleRect(cssWidth, cssHeight);
2769
+ const margin = Math.max(visibleRect.w, visibleRect.h) * 0.1;
2770
+ const cullingRect = {
2771
+ x: visibleRect.x - margin,
2772
+ y: visibleRect.y - margin,
2773
+ w: visibleRect.w + margin * 2,
2774
+ h: visibleRect.h + margin * 2
2775
+ };
2427
2776
  const allElements = this.store.getAll();
2777
+ const layerElements = /* @__PURE__ */ new Map();
2778
+ const gridElements = [];
2428
2779
  let domZIndex = 0;
2429
2780
  for (const element of allElements) {
2430
2781
  if (!this.layerManager.isLayerVisible(element.layerId)) {
@@ -2434,9 +2785,54 @@ var RenderLoop = class {
2434
2785
  continue;
2435
2786
  }
2436
2787
  if (this.renderer.isDomElement(element)) {
2437
- this.domNodeManager.syncDomNode(element, domZIndex++);
2438
- } else {
2439
- this.renderer.renderCanvasElement(ctx, element);
2788
+ const elBounds = getElementBounds(element);
2789
+ if (elBounds && !boundsIntersect(elBounds, cullingRect)) {
2790
+ this.domNodeManager.hideDomNode(element.id);
2791
+ } else {
2792
+ this.domNodeManager.syncDomNode(element, domZIndex++);
2793
+ }
2794
+ continue;
2795
+ }
2796
+ if (element.type === "grid") {
2797
+ gridElements.push(element);
2798
+ continue;
2799
+ }
2800
+ let group = layerElements.get(element.layerId);
2801
+ if (!group) {
2802
+ group = [];
2803
+ layerElements.set(element.layerId, group);
2804
+ }
2805
+ group.push(element);
2806
+ }
2807
+ for (const grid of gridElements) {
2808
+ this.renderer.renderCanvasElement(ctx, grid);
2809
+ }
2810
+ for (const [layerId, elements] of layerElements) {
2811
+ const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
2812
+ if (!this.layerCache.isDirty(layerId)) {
2813
+ this.compositeLayerCache(ctx, layerId, dpr);
2814
+ continue;
2815
+ }
2816
+ if (isActiveDrawingLayer) {
2817
+ this.compositeLayerCache(ctx, layerId, dpr);
2818
+ continue;
2819
+ }
2820
+ const offCtx = this.layerCache.getContext(layerId);
2821
+ if (offCtx) {
2822
+ const offCanvas = this.layerCache.getCanvas(layerId);
2823
+ offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
2824
+ offCtx.save();
2825
+ offCtx.scale(dpr, dpr);
2826
+ offCtx.translate(this.camera.position.x, this.camera.position.y);
2827
+ offCtx.scale(this.camera.zoom, this.camera.zoom);
2828
+ for (const element of elements) {
2829
+ const elBounds = getElementBounds(element);
2830
+ if (elBounds && !boundsIntersect(elBounds, cullingRect)) continue;
2831
+ this.renderer.renderCanvasElement(offCtx, element);
2832
+ }
2833
+ offCtx.restore();
2834
+ this.layerCache.markClean(layerId);
2835
+ this.compositeLayerCache(ctx, layerId, dpr);
2440
2836
  }
2441
2837
  }
2442
2838
  const activeTool = this.toolManager.activeTool;
@@ -2445,6 +2841,70 @@ var RenderLoop = class {
2445
2841
  }
2446
2842
  ctx.restore();
2447
2843
  ctx.restore();
2844
+ this.stats.recordFrame(performance.now() - t0);
2845
+ }
2846
+ };
2847
+
2848
+ // src/canvas/layer-cache.ts
2849
+ function createOffscreenCanvas(width, height) {
2850
+ if (typeof OffscreenCanvas !== "undefined") {
2851
+ return new OffscreenCanvas(width, height);
2852
+ }
2853
+ const canvas = document.createElement("canvas");
2854
+ canvas.width = width;
2855
+ canvas.height = height;
2856
+ return canvas;
2857
+ }
2858
+ var LayerCache = class {
2859
+ canvases = /* @__PURE__ */ new Map();
2860
+ dirtyFlags = /* @__PURE__ */ new Map();
2861
+ width;
2862
+ height;
2863
+ constructor(width, height) {
2864
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2865
+ this.width = Math.round(width * dpr);
2866
+ this.height = Math.round(height * dpr);
2867
+ }
2868
+ isDirty(layerId) {
2869
+ return this.dirtyFlags.get(layerId) !== false;
2870
+ }
2871
+ markDirty(layerId) {
2872
+ this.dirtyFlags.set(layerId, true);
2873
+ }
2874
+ markClean(layerId) {
2875
+ this.dirtyFlags.set(layerId, false);
2876
+ }
2877
+ markAllDirty() {
2878
+ for (const [id] of this.dirtyFlags) {
2879
+ this.dirtyFlags.set(id, true);
2880
+ }
2881
+ }
2882
+ getCanvas(layerId) {
2883
+ let canvas = this.canvases.get(layerId);
2884
+ if (!canvas) {
2885
+ canvas = createOffscreenCanvas(this.width, this.height);
2886
+ this.canvases.set(layerId, canvas);
2887
+ this.dirtyFlags.set(layerId, true);
2888
+ }
2889
+ return canvas;
2890
+ }
2891
+ getContext(layerId) {
2892
+ const canvas = this.getCanvas(layerId);
2893
+ return canvas.getContext("2d");
2894
+ }
2895
+ resize(width, height) {
2896
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2897
+ this.width = Math.round(width * dpr);
2898
+ this.height = Math.round(height * dpr);
2899
+ for (const [id, canvas] of this.canvases) {
2900
+ canvas.width = this.width;
2901
+ canvas.height = this.height;
2902
+ this.dirtyFlags.set(id, true);
2903
+ }
2904
+ }
2905
+ clear() {
2906
+ this.canvases.clear();
2907
+ this.dirtyFlags.clear();
2448
2908
  }
2449
2909
  };
2450
2910
 
@@ -2501,6 +2961,10 @@ var Viewport = class {
2501
2961
  this.interactMode = new InteractMode({
2502
2962
  getNode: (id) => this.domNodeManager.getNode(id)
2503
2963
  });
2964
+ const layerCache = new LayerCache(
2965
+ this.canvasEl.clientWidth || 800,
2966
+ this.canvasEl.clientHeight || 600
2967
+ );
2504
2968
  this.renderLoop = new RenderLoop({
2505
2969
  canvasEl: this.canvasEl,
2506
2970
  camera: this.camera,
@@ -2509,22 +2973,34 @@ var Viewport = class {
2509
2973
  renderer: this.renderer,
2510
2974
  toolManager: this.toolManager,
2511
2975
  layerManager: this.layerManager,
2512
- domNodeManager: this.domNodeManager
2976
+ domNodeManager: this.domNodeManager,
2977
+ layerCache
2513
2978
  });
2514
2979
  this.unsubCamera = this.camera.onChange(() => {
2515
2980
  this.applyCameraTransform();
2516
2981
  this.requestRender();
2517
2982
  });
2518
2983
  this.unsubStore = [
2519
- this.store.on("add", () => this.requestRender()),
2984
+ this.store.on("add", (el) => {
2985
+ this.renderLoop.markLayerDirty(el.layerId);
2986
+ this.requestRender();
2987
+ }),
2520
2988
  this.store.on("remove", (el) => {
2521
2989
  this.unbindArrowsFrom(el);
2522
2990
  this.domNodeManager.removeDomNode(el.id);
2991
+ this.renderLoop.markLayerDirty(el.layerId);
2992
+ this.requestRender();
2993
+ }),
2994
+ this.store.on("update", ({ previous, current }) => {
2995
+ this.renderLoop.markLayerDirty(current.layerId);
2996
+ if (previous.layerId !== current.layerId) {
2997
+ this.renderLoop.markLayerDirty(previous.layerId);
2998
+ }
2523
2999
  this.requestRender();
2524
3000
  }),
2525
- this.store.on("update", () => this.requestRender()),
2526
3001
  this.store.on("clear", () => {
2527
3002
  this.domNodeManager.clearDomNodes();
3003
+ this.renderLoop.markAllLayersDirty();
2528
3004
  this.requestRender();
2529
3005
  })
2530
3006
  ];
@@ -2730,8 +3206,8 @@ var Viewport = class {
2730
3206
  }
2731
3207
  };
2732
3208
  hitTestWorld(world) {
2733
- const elements = this.store.getAll().reverse();
2734
- for (const el of elements) {
3209
+ const candidates = this.store.queryPoint(world).reverse();
3210
+ for (const el of candidates) {
2735
3211
  if (!("size" in el)) continue;
2736
3212
  const { x, y } = el.position;
2737
3213
  const { w, h } = el.size;
@@ -2882,6 +3358,9 @@ var HandTool = class {
2882
3358
  var MIN_POINTS_FOR_STROKE = 2;
2883
3359
  var DEFAULT_SMOOTHING = 1.5;
2884
3360
  var DEFAULT_PRESSURE = 0.5;
3361
+ var DEFAULT_MIN_POINT_DISTANCE = 3;
3362
+ var DEFAULT_PROGRESSIVE_THRESHOLD = 200;
3363
+ var PROGRESSIVE_HOT_ZONE = 30;
2885
3364
  var PencilTool = class {
2886
3365
  name = "pencil";
2887
3366
  drawing = false;
@@ -2889,11 +3368,17 @@ var PencilTool = class {
2889
3368
  color;
2890
3369
  width;
2891
3370
  smoothing;
3371
+ minPointDistance;
3372
+ progressiveThreshold;
3373
+ nextSimplifyAt;
2892
3374
  optionListeners = /* @__PURE__ */ new Set();
2893
3375
  constructor(options = {}) {
2894
3376
  this.color = options.color ?? "#000000";
2895
3377
  this.width = options.width ?? 2;
2896
3378
  this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
3379
+ this.minPointDistance = options.minPointDistance ?? DEFAULT_MIN_POINT_DISTANCE;
3380
+ this.progressiveThreshold = options.progressiveSimplifyThreshold ?? DEFAULT_PROGRESSIVE_THRESHOLD;
3381
+ this.nextSimplifyAt = this.progressiveThreshold;
2897
3382
  }
2898
3383
  onActivate(ctx) {
2899
3384
  ctx.setCursor?.("crosshair");
@@ -2902,7 +3387,13 @@ var PencilTool = class {
2902
3387
  ctx.setCursor?.("default");
2903
3388
  }
2904
3389
  getOptions() {
2905
- return { color: this.color, width: this.width, smoothing: this.smoothing };
3390
+ return {
3391
+ color: this.color,
3392
+ width: this.width,
3393
+ smoothing: this.smoothing,
3394
+ minPointDistance: this.minPointDistance,
3395
+ progressiveSimplifyThreshold: this.progressiveThreshold
3396
+ };
2906
3397
  }
2907
3398
  onOptionsChange(listener) {
2908
3399
  this.optionListeners.add(listener);
@@ -2912,6 +3403,9 @@ var PencilTool = class {
2912
3403
  if (options.color !== void 0) this.color = options.color;
2913
3404
  if (options.width !== void 0) this.width = options.width;
2914
3405
  if (options.smoothing !== void 0) this.smoothing = options.smoothing;
3406
+ if (options.minPointDistance !== void 0) this.minPointDistance = options.minPointDistance;
3407
+ if (options.progressiveSimplifyThreshold !== void 0)
3408
+ this.progressiveThreshold = options.progressiveSimplifyThreshold;
2915
3409
  this.notifyOptionsChange();
2916
3410
  }
2917
3411
  onPointerDown(state, ctx) {
@@ -2919,12 +3413,26 @@ var PencilTool = class {
2919
3413
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2920
3414
  const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
2921
3415
  this.points = [{ x: world.x, y: world.y, pressure }];
3416
+ this.nextSimplifyAt = this.progressiveThreshold;
2922
3417
  }
2923
3418
  onPointerMove(state, ctx) {
2924
3419
  if (!this.drawing) return;
2925
3420
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2926
3421
  const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
3422
+ const last = this.points[this.points.length - 1];
3423
+ if (last) {
3424
+ const dx = world.x - last.x;
3425
+ const dy = world.y - last.y;
3426
+ if (dx * dx + dy * dy < this.minPointDistance * this.minPointDistance) return;
3427
+ }
2927
3428
  this.points.push({ x: world.x, y: world.y, pressure });
3429
+ if (this.points.length > this.nextSimplifyAt) {
3430
+ const hotZone = this.points.slice(-PROGRESSIVE_HOT_ZONE);
3431
+ const coldZone = this.points.slice(0, -PROGRESSIVE_HOT_ZONE);
3432
+ const simplified = simplifyPoints(coldZone, this.smoothing * 2);
3433
+ this.points = [...simplified, ...hotZone];
3434
+ this.nextSimplifyAt = this.points.length + this.progressiveThreshold;
3435
+ }
2928
3436
  ctx.requestRender();
2929
3437
  }
2930
3438
  onPointerUp(_state, ctx) {
@@ -3006,13 +3514,20 @@ var EraserTool = class {
3006
3514
  }
3007
3515
  eraseAt(state, ctx) {
3008
3516
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
3009
- const strokes = ctx.store.getElementsByType("stroke");
3517
+ const queryBounds = {
3518
+ x: world.x - this.radius,
3519
+ y: world.y - this.radius,
3520
+ w: this.radius * 2,
3521
+ h: this.radius * 2
3522
+ };
3523
+ const candidates = ctx.store.queryRect(queryBounds);
3010
3524
  let erased = false;
3011
- for (const stroke of strokes) {
3012
- if (ctx.isLayerVisible && !ctx.isLayerVisible(stroke.layerId)) continue;
3013
- if (ctx.isLayerLocked && ctx.isLayerLocked(stroke.layerId)) continue;
3014
- if (this.strokeIntersects(stroke, world)) {
3015
- ctx.store.remove(stroke.id);
3525
+ for (const el of candidates) {
3526
+ if (el.type !== "stroke") continue;
3527
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
3528
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3529
+ if (this.strokeIntersects(el, world)) {
3530
+ ctx.store.remove(el.id);
3016
3531
  erased = true;
3017
3532
  }
3018
3533
  }
@@ -3389,7 +3904,7 @@ var SelectTool = class {
3389
3904
  for (const id of this._selectedIds) {
3390
3905
  const el = ctx.store.getById(id);
3391
3906
  if (!el || !("size" in el)) continue;
3392
- const bounds = this.getElementBounds(el);
3907
+ const bounds = getElementBounds(el);
3393
3908
  if (!bounds) continue;
3394
3909
  const corners = this.getHandlePositions(bounds);
3395
3910
  for (const [handle, pos] of corners) {
@@ -3437,7 +3952,7 @@ var SelectTool = class {
3437
3952
  this.renderBindingHighlights(canvasCtx, el, zoom);
3438
3953
  continue;
3439
3954
  }
3440
- const bounds = this.getElementBounds(el);
3955
+ const bounds = getElementBounds(el);
3441
3956
  if (!bounds) continue;
3442
3957
  const pad = SELECTION_PAD / zoom;
3443
3958
  canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
@@ -3496,12 +4011,13 @@ var SelectTool = class {
3496
4011
  return { x, y, w, h };
3497
4012
  }
3498
4013
  findElementsInRect(marquee, ctx) {
4014
+ const candidates = ctx.store.queryRect(marquee);
3499
4015
  const ids = [];
3500
- for (const el of ctx.store.getAll()) {
4016
+ for (const el of candidates) {
3501
4017
  if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
3502
4018
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3503
4019
  if (el.type === "grid") continue;
3504
- const bounds = this.getElementBounds(el);
4020
+ const bounds = getElementBounds(el);
3505
4021
  if (bounds && this.rectsOverlap(marquee, bounds)) {
3506
4022
  ids.push(el.id);
3507
4023
  }
@@ -3511,30 +4027,10 @@ var SelectTool = class {
3511
4027
  rectsOverlap(a, b) {
3512
4028
  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;
3513
4029
  }
3514
- getElementBounds(el) {
3515
- if ("size" in el) {
3516
- return { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
3517
- }
3518
- if (el.type === "stroke" && el.points.length > 0) {
3519
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
3520
- for (const p of el.points) {
3521
- const px = p.x + el.position.x;
3522
- const py = p.y + el.position.y;
3523
- if (px < minX) minX = px;
3524
- if (py < minY) minY = py;
3525
- if (px > maxX) maxX = px;
3526
- if (py > maxY) maxY = py;
3527
- }
3528
- return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
3529
- }
3530
- if (el.type === "arrow") {
3531
- return getArrowBounds(el.from, el.to, el.bend);
3532
- }
3533
- return null;
3534
- }
3535
4030
  hitTest(world, ctx) {
3536
- const elements = ctx.store.getAll().reverse();
3537
- for (const el of elements) {
4031
+ const r = 10;
4032
+ const candidates = ctx.store.queryRect({ x: world.x - r, y: world.y - r, w: r * 2, h: r * 2 }).reverse();
4033
+ for (const el of candidates) {
3538
4034
  if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
3539
4035
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3540
4036
  if (el.type === "grid") continue;
@@ -4028,7 +4524,7 @@ var UpdateLayerCommand = class {
4028
4524
  };
4029
4525
 
4030
4526
  // src/index.ts
4031
- var VERSION = "0.8.6";
4527
+ var VERSION = "0.8.7";
4032
4528
  export {
4033
4529
  AddElementCommand,
4034
4530
  ArrowTool,
@@ -4050,6 +4546,7 @@ export {
4050
4546
  NoteEditor,
4051
4547
  NoteTool,
4052
4548
  PencilTool,
4549
+ Quadtree,
4053
4550
  RemoveElementCommand,
4054
4551
  RemoveLayerCommand,
4055
4552
  SelectTool,
@@ -4060,6 +4557,7 @@ export {
4060
4557
  UpdateLayerCommand,
4061
4558
  VERSION,
4062
4559
  Viewport,
4560
+ boundsIntersect,
4063
4561
  clearStaleBindings,
4064
4562
  createArrow,
4065
4563
  createGrid,