@fieldnotes/core 0.64.0 → 0.66.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
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ AWARENESS_MAX_SELECTION: () => AWARENESS_MAX_SELECTION,
24
+ AWARENESS_PRESENCE_KIND: () => AWARENESS_PRESENCE_KIND,
23
25
  ArrowTool: () => ArrowTool,
24
26
  AutoSave: () => AutoSave,
25
27
  Camera: () => Camera,
@@ -29,6 +31,12 @@ __export(index_exports, {
29
31
  ElementStore: () => ElementStore,
30
32
  EraserTool: () => EraserTool,
31
33
  FOCUS_PRESENCE_KIND: () => FOCUS_PRESENCE_KIND,
34
+ FOG_MAX_TILES: () => FOG_MAX_TILES,
35
+ FOG_STATE_VERSION: () => FOG_STATE_VERSION,
36
+ FOG_TILE_CELLS: () => FOG_TILE_CELLS,
37
+ FogManager: () => FogManager,
38
+ FogRenderer: () => FogRenderer,
39
+ FogTool: () => FogTool,
32
40
  HandTool: () => HandTool,
33
41
  HistoryStack: () => HistoryStack,
34
42
  HtmlPainterMissingError: () => HtmlPainterMissingError,
@@ -38,6 +46,7 @@ __export(index_exports, {
38
46
  LASER_TRAIL_PRESENCE_KIND: () => LASER_TRAIL_PRESENCE_KIND,
39
47
  LaserTool: () => LaserTool,
40
48
  LayerManager: () => LayerManager,
49
+ LocalAwareness: () => LocalAwareness,
41
50
  LocalStorageAdapter: () => LocalStorageAdapter,
42
51
  MEASURE_PRESENCE_KIND: () => MEASURE_PRESENCE_KIND,
43
52
  MeasureTool: () => MeasureTool,
@@ -46,16 +55,20 @@ __export(index_exports, {
46
55
  NoteTool: () => NoteTool,
47
56
  PATH_PRESENCE_KIND: () => PATH_PRESENCE_KIND,
48
57
  PATH_PRESENCE_MAX_POINTS: () => PATH_PRESENCE_MAX_POINTS,
58
+ PEER_COLORS: () => PEER_COLORS,
49
59
  PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
50
60
  PathTool: () => PathTool,
61
+ PeerRoster: () => PeerRoster,
51
62
  PencilTool: () => PencilTool,
52
63
  PingInput: () => PingInput,
53
64
  PingTool: () => PingTool,
65
+ RemoteCursorOverlay: () => RemoteCursorOverlay,
54
66
  RemoteFocusReceiver: () => RemoteFocusReceiver,
55
67
  RemoteLaserOverlay: () => RemoteLaserOverlay,
56
68
  RemoteMeasureOverlay: () => RemoteMeasureOverlay,
57
69
  RemotePathOverlay: () => RemotePathOverlay,
58
70
  RemotePingOverlay: () => RemotePingOverlay,
71
+ RemoteSelectionOverlay: () => RemoteSelectionOverlay,
59
72
  SelectTool: () => SelectTool,
60
73
  ShapeTool: () => ShapeTool,
61
74
  TemplateTool: () => TemplateTool,
@@ -64,8 +77,10 @@ __export(index_exports, {
64
77
  VERSION: () => VERSION,
65
78
  Viewport: () => Viewport,
66
79
  applyCameraView: () => applyCameraView,
80
+ attachAwareness: () => attachAwareness,
67
81
  boundsIntersect: () => boundsIntersect,
68
82
  cameraOriginForView: () => cameraOriginForView,
83
+ canonicalizeFogTile: () => canonicalizeFogTile,
69
84
  captureCameraView: () => captureCameraView,
70
85
  computeElementRects: () => computeElementRects,
71
86
  createArrow: () => createArrow,
@@ -77,11 +92,14 @@ __export(index_exports, {
77
92
  createStroke: () => createStroke,
78
93
  createTemplate: () => createTemplate,
79
94
  createText: () => createText,
95
+ defaultPeerColor: () => defaultPeerColor,
80
96
  drawHexPath: () => drawHexPath,
81
97
  elementRectsEqual: () => elementRectsEqual,
82
98
  exportImage: () => exportImage,
83
99
  exportSvg: () => exportSvg,
84
100
  fitZoomForView: () => fitZoomForView,
101
+ fogDecodeBase64: () => decodeBase64,
102
+ fogEncodeBase64: () => encodeBase64,
85
103
  footprintFromSize: () => footprintFromSize,
86
104
  getActiveFormats: () => getActiveFormats,
87
105
  getArrowBounds: () => getArrowBounds,
@@ -99,6 +117,7 @@ __export(index_exports, {
99
117
  getHexCellsInSquare: () => getHexCellsInSquare,
100
118
  getHexDistance: () => getHexDistance,
101
119
  gridDistanceCells: () => gridDistanceCells,
120
+ isAwarenessPresence: () => isAwarenessPresence,
102
121
  isFocusPresence: () => isFocusPresence,
103
122
  isLaserTrailPresence: () => isLaserTrailPresence,
104
123
  isMeasurePresence: () => isMeasurePresence,
@@ -106,6 +125,7 @@ __export(index_exports, {
106
125
  isPathPresence: () => isPathPresence,
107
126
  isPingPresence: () => isPingPresence,
108
127
  pathDistanceCells: () => pathDistanceCells,
128
+ recommendedFogCellSize: () => recommendedFogCellSize,
109
129
  resolveHtmlRouting: () => resolveHtmlRouting,
110
130
  setFontSize: () => setFontSize,
111
131
  smartSnap: () => smartSnap,
@@ -122,7 +142,10 @@ __export(index_exports, {
122
142
  toggleBold: () => toggleBold,
123
143
  toggleItalic: () => toggleItalic,
124
144
  toggleStrikethrough: () => toggleStrikethrough,
125
- toggleUnderline: () => toggleUnderline
145
+ toggleUnderline: () => toggleUnderline,
146
+ validateFogDefinition: () => validateFogDefinition,
147
+ validateFogState: () => validateFogState,
148
+ validateFogTile: () => validateFogTile
126
149
  });
127
150
  module.exports = __toCommonJS(index_exports);
128
151
 
@@ -582,8 +605,464 @@ function sanitizeAttributes(el, tag) {
582
605
  }
583
606
  }
584
607
 
608
+ // src/fog/types.ts
609
+ var FOG_STATE_VERSION = 1;
610
+ var FOG_TILE_CELLS = 128;
611
+ var FOG_MAX_TILES = 256;
612
+
613
+ // src/fog/tile-codec.ts
614
+ var TILE_BYTES = FOG_TILE_CELLS * FOG_TILE_CELLS / 8;
615
+ var CANONICAL_B64_LENGTH = Math.ceil(TILE_BYTES / 3) * 4;
616
+ var B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
617
+ var B64_LOOKUP = new Uint8Array(128);
618
+ for (let i = 0; i < B64_CHARS.length; i++) B64_LOOKUP[B64_CHARS.charCodeAt(i)] = i;
619
+ function encodeBase64(bytes) {
620
+ let result = "";
621
+ const len = bytes.length;
622
+ for (let i = 0; i < len; i += 3) {
623
+ const a = bytes[i];
624
+ const b = i + 1 < len ? bytes[i + 1] : 0;
625
+ const c = i + 2 < len ? bytes[i + 2] : 0;
626
+ result += B64_CHARS[a >> 2 & 63];
627
+ result += B64_CHARS[(a << 4 | b >> 4) & 63];
628
+ result += i + 1 < len ? B64_CHARS[(b << 2 | c >> 6) & 63] : "=";
629
+ result += i + 2 < len ? B64_CHARS[c & 63] : "=";
630
+ }
631
+ return result;
632
+ }
633
+ function decodeBase64(str) {
634
+ if (str.length % 4 !== 0) throw new Error("Invalid base64 length");
635
+ let padCount = 0;
636
+ if (str.length >= 2 && str[str.length - 1] === "=") {
637
+ padCount++;
638
+ if (str[str.length - 2] === "=") padCount++;
639
+ }
640
+ const byteLen = str.length / 4 * 3 - padCount;
641
+ const bytes = new Uint8Array(byteLen);
642
+ let j = 0;
643
+ for (let i = 0; i < str.length; i += 4) {
644
+ const a = B64_LOOKUP[str.charCodeAt(i)];
645
+ const b = B64_LOOKUP[str.charCodeAt(i + 1)];
646
+ const c = str[i + 2] === "=" ? 0 : B64_LOOKUP[str.charCodeAt(i + 2)];
647
+ const d = str[i + 3] === "=" ? 0 : B64_LOOKUP[str.charCodeAt(i + 3)];
648
+ bytes[j++] = a << 2 | b >> 4;
649
+ if (j < byteLen) bytes[j++] = (b << 4 | c >> 2) & 255;
650
+ if (j < byteLen) bytes[j++] = (c << 6 | d) & 255;
651
+ }
652
+ return bytes;
653
+ }
654
+ function createTileBytes(fill) {
655
+ const bytes = new Uint8Array(TILE_BYTES);
656
+ if (fill) bytes.fill(255);
657
+ return bytes;
658
+ }
659
+ function setBit(bytes, col, row, value) {
660
+ const index = row * FOG_TILE_CELLS + col;
661
+ const byteIndex = index >> 3;
662
+ const bitIndex = 7 - (index & 7);
663
+ if (value) {
664
+ bytes[byteIndex] = bytes[byteIndex] | 1 << bitIndex;
665
+ } else {
666
+ bytes[byteIndex] = bytes[byteIndex] & ~(1 << bitIndex);
667
+ }
668
+ }
669
+ function isTileAllValue(bytes, value) {
670
+ const expected = value ? 255 : 0;
671
+ for (let i = 0; i < TILE_BYTES; i++) {
672
+ if (bytes[i] !== expected) return false;
673
+ }
674
+ return true;
675
+ }
676
+ function isBaseValue(base) {
677
+ return base === "revealed";
678
+ }
679
+ function isTileBase(bytes, base) {
680
+ return isTileAllValue(bytes, isBaseValue(base));
681
+ }
682
+ function canonicalizeEdgePadding(bytes, def, tileX, tileY) {
683
+ const baseVal = isBaseValue(def.base);
684
+ const worldX = tileX * FOG_TILE_CELLS * def.cellSize;
685
+ const worldY = tileY * FOG_TILE_CELLS * def.cellSize;
686
+ const boundsRight = def.bounds.x + def.bounds.w;
687
+ const boundsBottom = def.bounds.y + def.bounds.h;
688
+ for (let row = 0; row < FOG_TILE_CELLS; row++) {
689
+ for (let col = 0; col < FOG_TILE_CELLS; col++) {
690
+ const cellWorldX = worldX + col * def.cellSize;
691
+ const cellWorldY = worldY + row * def.cellSize;
692
+ const outside = cellWorldX < def.bounds.x || cellWorldY < def.bounds.y || cellWorldX >= boundsRight || cellWorldY >= boundsBottom;
693
+ if (outside) {
694
+ setBit(bytes, col, row, baseVal);
695
+ }
696
+ }
697
+ }
698
+ }
699
+ function canonicalizeFogTile(tile, def) {
700
+ const bytes = decodeBase64(tile.data);
701
+ canonicalizeEdgePadding(bytes, def, tile.x, tile.y);
702
+ if (isTileBase(bytes, def.base)) return null;
703
+ return { x: tile.x, y: tile.y, data: encodeBase64(bytes) };
704
+ }
705
+ function isCanonicalBase64(str) {
706
+ if (str.length !== CANONICAL_B64_LENGTH) return false;
707
+ if (str[str.length - 1] !== "=" || str[str.length - 2] === "=") return false;
708
+ for (let i = 0; i < str.length - 1; i++) {
709
+ if (!B64_CHARS.includes(str[i])) return false;
710
+ }
711
+ const finalSextet = B64_CHARS.indexOf(str[str.length - 2]);
712
+ return finalSextet >= 0 && (finalSextet & 3) === 0;
713
+ }
714
+ function isSafeInteger(n2) {
715
+ return typeof n2 === "number" && Number.isSafeInteger(n2);
716
+ }
717
+ function isFinitePositive(n2) {
718
+ return typeof n2 === "number" && Number.isFinite(n2) && n2 > 0;
719
+ }
720
+ function isFiniteBounds(b) {
721
+ if (typeof b !== "object" || b === null) return false;
722
+ const r = b;
723
+ return typeof r["x"] === "number" && Number.isFinite(r["x"]) && typeof r["y"] === "number" && Number.isFinite(r["y"]) && isFinitePositive(r["w"]) && isFinitePositive(r["h"]);
724
+ }
725
+ function tileIntersectsBounds(x, y, def) {
726
+ const tileWorldX = x * FOG_TILE_CELLS * def.cellSize;
727
+ const tileWorldY = y * FOG_TILE_CELLS * def.cellSize;
728
+ const tileWorldW = FOG_TILE_CELLS * def.cellSize;
729
+ const tileWorldH = FOG_TILE_CELLS * def.cellSize;
730
+ return !(tileWorldX + tileWorldW <= def.bounds.x || tileWorldY + tileWorldH <= def.bounds.y || tileWorldX >= def.bounds.x + def.bounds.w || tileWorldY >= def.bounds.y + def.bounds.h);
731
+ }
732
+ function validateFogDefinition(def) {
733
+ if (typeof def !== "object" || def === null) {
734
+ throw new Error("Invalid fog definition: expected an object");
735
+ }
736
+ const d = def;
737
+ if (d["version"] !== 1) throw new Error("Invalid fog definition: unsupported version");
738
+ if (typeof d["generation"] !== "string" || d["generation"].length === 0 || d["generation"].length > 128 || !/^[\x20-\x7e]+$/.test(d["generation"])) {
739
+ throw new Error("Invalid fog definition: invalid generation");
740
+ }
741
+ if (!isFiniteBounds(d["bounds"])) {
742
+ throw new Error("Invalid fog definition: invalid bounds");
743
+ }
744
+ if (!isFinitePositive(d["cellSize"])) {
745
+ throw new Error("Invalid fog definition: invalid cellSize");
746
+ }
747
+ if (d["tileCells"] !== 128) {
748
+ throw new Error("Invalid fog definition: tileCells must be 128");
749
+ }
750
+ if (d["base"] !== "covered" && d["base"] !== "revealed") {
751
+ throw new Error("Invalid fog definition: invalid base");
752
+ }
753
+ }
754
+ function validateFogTile(tile, def) {
755
+ if (typeof tile !== "object" || tile === null) {
756
+ throw new Error("Invalid fog tile: expected an object");
757
+ }
758
+ const t = tile;
759
+ if (!isSafeInteger(t["x"]) || !isSafeInteger(t["y"])) {
760
+ throw new Error("Invalid fog tile: coordinates must be safe integers");
761
+ }
762
+ if (!tileIntersectsBounds(t["x"], t["y"], def)) {
763
+ throw new Error("Invalid fog tile: coordinates outside bounds");
764
+ }
765
+ if (typeof t["data"] !== "string" || !isCanonicalBase64(t["data"])) {
766
+ throw new Error("Invalid fog tile: invalid data");
767
+ }
768
+ const decoded = decodeBase64(t["data"]);
769
+ if (decoded.length !== TILE_BYTES) {
770
+ throw new Error("Invalid fog tile: decoded data wrong length");
771
+ }
772
+ if (isTileBase(decoded, def.base)) {
773
+ throw new Error("Invalid fog tile: base-value tiles must be omitted");
774
+ }
775
+ const canonical = new Uint8Array(decoded);
776
+ canonicalizeEdgePadding(canonical, def, t["x"], t["y"]);
777
+ for (let i = 0; i < decoded.length; i++) {
778
+ if (decoded[i] !== canonical[i]) {
779
+ throw new Error("Invalid fog tile: non-canonical edge padding");
780
+ }
781
+ }
782
+ }
783
+ function validateFogState(state) {
784
+ if (typeof state !== "object" || state === null) {
785
+ throw new Error("Invalid fog state: expected an object");
786
+ }
787
+ const s = state;
788
+ validateFogDefinition(s["definition"]);
789
+ const def = s["definition"];
790
+ if (!Array.isArray(s["tiles"])) {
791
+ throw new Error("Invalid fog state: tiles must be an array");
792
+ }
793
+ const tiles = s["tiles"];
794
+ if (tiles.length > FOG_MAX_TILES) {
795
+ throw new Error(`Invalid fog state: too many tiles (${tiles.length} > ${FOG_MAX_TILES})`);
796
+ }
797
+ const seen = /* @__PURE__ */ new Set();
798
+ for (const tile of tiles) {
799
+ validateFogTile(tile, def);
800
+ const t = tile;
801
+ const key = `${t.x},${t.y}`;
802
+ if (seen.has(key)) {
803
+ throw new Error(`Invalid fog state: duplicate tile at (${t.x}, ${t.y})`);
804
+ }
805
+ seen.add(key);
806
+ }
807
+ }
808
+ function recommendedFogCellSize(bounds) {
809
+ let cellSize = 1;
810
+ while (true) {
811
+ const tileWorldSize = FOG_TILE_CELLS * cellSize;
812
+ const minTX = Math.floor(bounds.x / tileWorldSize);
813
+ const minTY = Math.floor(bounds.y / tileWorldSize);
814
+ const maxTX = Math.ceil((bounds.x + bounds.w) / tileWorldSize) - 1;
815
+ const maxTY = Math.ceil((bounds.y + bounds.h) / tileWorldSize) - 1;
816
+ const tw = maxTX - minTX + 1;
817
+ const th = maxTY - minTY + 1;
818
+ if (tw * th <= FOG_MAX_TILES) return cellSize;
819
+ cellSize++;
820
+ if (cellSize > 1e4) return cellSize;
821
+ }
822
+ }
823
+ function tileKey(x, y) {
824
+ return `${x},${y}`;
825
+ }
826
+ function worldToCell(worldX, worldY, cellSize) {
827
+ const cellX = Math.floor(worldX / cellSize);
828
+ const cellY = Math.floor(worldY / cellSize);
829
+ const tx = Math.floor(cellX / FOG_TILE_CELLS);
830
+ const ty = Math.floor(cellY / FOG_TILE_CELLS);
831
+ let col = cellX - tx * FOG_TILE_CELLS;
832
+ let row = cellY - ty * FOG_TILE_CELLS;
833
+ if (col < 0) col += FOG_TILE_CELLS;
834
+ if (row < 0) row += FOG_TILE_CELLS;
835
+ return { tx, ty, col, row };
836
+ }
837
+ function rasterizeRegion(state, region, operation) {
838
+ const { definition } = state;
839
+ const bitValue = operation === "reveal";
840
+ const tileMap = /* @__PURE__ */ new Map();
841
+ for (const tile of state.tiles) {
842
+ tileMap.set(tileKey(tile.x, tile.y), decodeBase64(tile.data));
843
+ }
844
+ const baseFill = isBaseValue(definition.base);
845
+ const affectedTiles = /* @__PURE__ */ new Set();
846
+ const setCellIfInBounds = (worldX, worldY) => {
847
+ if (worldX < definition.bounds.x || worldY < definition.bounds.y || worldX >= definition.bounds.x + definition.bounds.w || worldY >= definition.bounds.y + definition.bounds.h) {
848
+ return;
849
+ }
850
+ const { tx, ty, col, row } = worldToCell(worldX, worldY, definition.cellSize);
851
+ if (!tileIntersectsBounds(tx, ty, definition)) return;
852
+ const key = tileKey(tx, ty);
853
+ let bytes = tileMap.get(key);
854
+ if (!bytes) {
855
+ bytes = createTileBytes(baseFill);
856
+ tileMap.set(key, bytes);
857
+ } else if (!affectedTiles.has(key)) {
858
+ const clone = new Uint8Array(bytes);
859
+ tileMap.set(key, clone);
860
+ bytes = clone;
861
+ }
862
+ affectedTiles.add(key);
863
+ setBit(bytes, col, row, bitValue);
864
+ };
865
+ switch (region.kind) {
866
+ case "brush":
867
+ rasterizeBrush(region.points, region.radius, definition, setCellIfInBounds);
868
+ break;
869
+ case "rectangle":
870
+ rasterizeRectangle(region.from, region.to, definition, setCellIfInBounds);
871
+ break;
872
+ case "polygon":
873
+ rasterizePolygon(region.points, definition, setCellIfInBounds);
874
+ break;
875
+ }
876
+ if (affectedTiles.size === 0) return { changed: [], noop: true };
877
+ const changedTiles = [];
878
+ let hasChange = false;
879
+ for (const key of affectedTiles) {
880
+ const bytes = tileMap.get(key);
881
+ const [txStr, tyStr] = key.split(",");
882
+ const tx = Number(txStr);
883
+ const ty = Number(tyStr);
884
+ canonicalizeEdgePadding(bytes, definition, tx, ty);
885
+ const newData = encodeBase64(bytes);
886
+ const originalTile = state.tiles.find((t) => t.x === tx && t.y === ty);
887
+ if (originalTile) {
888
+ if (originalTile.data !== newData) {
889
+ hasChange = true;
890
+ changedTiles.push({ x: tx, y: ty, data: newData });
891
+ }
892
+ } else if (!isTileBase(bytes, definition.base)) {
893
+ hasChange = true;
894
+ changedTiles.push({ x: tx, y: ty, data: newData });
895
+ }
896
+ }
897
+ if (!hasChange) return { changed: [], noop: true };
898
+ return { changed: changedTiles, noop: false };
899
+ }
900
+ function applyRasterResult(state, result) {
901
+ if (result.noop) return state;
902
+ const changedMap = /* @__PURE__ */ new Map();
903
+ for (const t of result.changed) changedMap.set(tileKey(t.x, t.y), t);
904
+ const tiles = [];
905
+ const seen = /* @__PURE__ */ new Set();
906
+ for (const tile of state.tiles) {
907
+ const key = tileKey(tile.x, tile.y);
908
+ seen.add(key);
909
+ const changed = changedMap.get(key);
910
+ if (changed) {
911
+ const bytes = decodeBase64(changed.data);
912
+ if (!isTileBase(bytes, state.definition.base)) {
913
+ tiles.push(changed);
914
+ }
915
+ } else {
916
+ tiles.push(tile);
917
+ }
918
+ }
919
+ for (const t of result.changed) {
920
+ const key = tileKey(t.x, t.y);
921
+ if (seen.has(key)) continue;
922
+ const bytes = decodeBase64(t.data);
923
+ if (!isTileBase(bytes, state.definition.base)) {
924
+ tiles.push(t);
925
+ }
926
+ }
927
+ if (tiles.length > FOG_MAX_TILES) {
928
+ throw new Error(`Fog tile cap exceeded: ${tiles.length} > ${FOG_MAX_TILES}`);
929
+ }
930
+ return { definition: state.definition, tiles };
931
+ }
932
+ function sampleAndSimplify(points, tolerance) {
933
+ if (points.length <= 2) return [...points];
934
+ const sampled = [points[0]];
935
+ let lastSampled = points[0];
936
+ for (let i = 1; i < points.length - 1; i++) {
937
+ const p = points[i];
938
+ const dx = p.x - lastSampled.x;
939
+ const dy = p.y - lastSampled.y;
940
+ if (dx * dx + dy * dy >= tolerance * tolerance) {
941
+ sampled.push(p);
942
+ lastSampled = p;
943
+ }
944
+ }
945
+ sampled.push(points[points.length - 1]);
946
+ return sampled;
947
+ }
948
+ function rasterizeBrush(points, radius, def, setCell) {
949
+ if (points.length === 0) return;
950
+ const simplified = sampleAndSimplify(points, def.cellSize * 0.5);
951
+ for (let i = 0; i < simplified.length; i++) {
952
+ const p = simplified[i];
953
+ rasterizeDisc(p.x, p.y, radius, def, setCell);
954
+ if (i > 0) {
955
+ const prev = simplified[i - 1];
956
+ rasterizeCapsuleSegment(prev.x, prev.y, p.x, p.y, radius, def, setCell);
957
+ }
958
+ }
959
+ }
960
+ function rasterizeDisc(cx, cy, radius, def, setCell) {
961
+ const minX = Math.max(def.bounds.x, cx - radius);
962
+ const maxX = Math.min(def.bounds.x + def.bounds.w - 1, cx + radius);
963
+ const minY = Math.max(def.bounds.y, cy - radius);
964
+ const maxY = Math.min(def.bounds.y + def.bounds.h - 1, cy + radius);
965
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
966
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
967
+ const r2 = radius * radius;
968
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
969
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
970
+ const cellCenterX = wx + def.cellSize * 0.5;
971
+ const cellCenterY = wy + def.cellSize * 0.5;
972
+ const dx = cellCenterX - cx;
973
+ const dy = cellCenterY - cy;
974
+ if (dx * dx + dy * dy <= r2) {
975
+ setCell(wx, wy);
976
+ }
977
+ }
978
+ }
979
+ }
980
+ function rasterizeCapsuleSegment(x1, y1, x2, y2, radius, def, setCell) {
981
+ const segDx = x2 - x1;
982
+ const segDy = y2 - y1;
983
+ const segLen2 = segDx * segDx + segDy * segDy;
984
+ if (segLen2 === 0) return;
985
+ const minX = Math.max(def.bounds.x, Math.min(x1, x2) - radius);
986
+ const maxX = Math.min(def.bounds.x + def.bounds.w - 1, Math.max(x1, x2) + radius);
987
+ const minY = Math.max(def.bounds.y, Math.min(y1, y2) - radius);
988
+ const maxY = Math.min(def.bounds.y + def.bounds.h - 1, Math.max(y1, y2) + radius);
989
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
990
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
991
+ const r2 = radius * radius;
992
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
993
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
994
+ const cellCenterX = wx + def.cellSize * 0.5;
995
+ const cellCenterY = wy + def.cellSize * 0.5;
996
+ const t = Math.max(
997
+ 0,
998
+ Math.min(1, ((cellCenterX - x1) * segDx + (cellCenterY - y1) * segDy) / segLen2)
999
+ );
1000
+ const projX = x1 + t * segDx;
1001
+ const projY = y1 + t * segDy;
1002
+ const dx = cellCenterX - projX;
1003
+ const dy = cellCenterY - projY;
1004
+ if (dx * dx + dy * dy <= r2) {
1005
+ setCell(wx, wy);
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ function rasterizeRectangle(from, to, def, setCell) {
1011
+ const minX = Math.max(def.bounds.x, Math.min(from.x, to.x));
1012
+ const maxX = Math.min(def.bounds.x + def.bounds.w - 1, Math.max(from.x, to.x));
1013
+ const minY = Math.max(def.bounds.y, Math.min(from.y, to.y));
1014
+ const maxY = Math.min(def.bounds.y + def.bounds.h - 1, Math.max(from.y, to.y));
1015
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
1016
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
1017
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
1018
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
1019
+ setCell(wx, wy);
1020
+ }
1021
+ }
1022
+ }
1023
+ function rasterizePolygon(points, def, setCell) {
1024
+ if (points.length < 3) return;
1025
+ let minX = Infinity;
1026
+ let maxX = -Infinity;
1027
+ let minY = Infinity;
1028
+ let maxY = -Infinity;
1029
+ for (const p of points) {
1030
+ if (p.x < minX) minX = p.x;
1031
+ if (p.x > maxX) maxX = p.x;
1032
+ if (p.y < minY) minY = p.y;
1033
+ if (p.y > maxY) maxY = p.y;
1034
+ }
1035
+ minX = Math.max(def.bounds.x, minX);
1036
+ maxX = Math.min(def.bounds.x + def.bounds.w - 1, maxX);
1037
+ minY = Math.max(def.bounds.y, minY);
1038
+ maxY = Math.min(def.bounds.y + def.bounds.h - 1, maxY);
1039
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
1040
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
1041
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
1042
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
1043
+ const cx = wx + def.cellSize * 0.5;
1044
+ const cy = wy + def.cellSize * 0.5;
1045
+ if (pointInPolygon(cx, cy, points)) {
1046
+ setCell(wx, wy);
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ function pointInPolygon(x, y, polygon) {
1052
+ let inside = false;
1053
+ const n2 = polygon.length;
1054
+ for (let i = 0, j = n2 - 1; i < n2; j = i++) {
1055
+ const pi = polygon[i];
1056
+ const pj = polygon[j];
1057
+ if (pi.y > y !== pj.y > y && x < (pj.x - pi.x) * (y - pi.y) / (pj.y - pi.y) + pi.x) {
1058
+ inside = !inside;
1059
+ }
1060
+ }
1061
+ return inside;
1062
+ }
1063
+
585
1064
  // src/core/state-serializer.ts
586
- var CURRENT_VERSION = 2;
1065
+ var CURRENT_VERSION = 3;
587
1066
  var ELEMENT_TYPES = [
588
1067
  "stroke",
589
1068
  "note",
@@ -595,7 +1074,7 @@ var ELEMENT_TYPES = [
595
1074
  "grid",
596
1075
  "template"
597
1076
  ];
598
- function exportState(elements, camera, layers = [], activeLayerId) {
1077
+ function exportState(elements, camera, layers = [], activeLayerId, fog) {
599
1078
  const state = {
600
1079
  version: CURRENT_VERSION,
601
1080
  camera: {
@@ -612,6 +1091,7 @@ function exportState(elements, camera, layers = [], activeLayerId) {
612
1091
  layers: layers.map((l) => ({ ...l }))
613
1092
  };
614
1093
  if (activeLayerId) state.activeLayerId = activeLayerId;
1094
+ if (fog) state.fog = structuredClone(fog);
615
1095
  return state;
616
1096
  }
617
1097
  function parseState(json) {
@@ -689,6 +1169,9 @@ function validateState(data) {
689
1169
  }
690
1170
  }
691
1171
  cleanBindings(elements);
1172
+ if (obj["fog"] !== void 0 && obj["fog"] !== null) {
1173
+ validateFogState(obj["fog"]);
1174
+ }
692
1175
  }
693
1176
  function validateElement(el) {
694
1177
  if (!isRecord(el)) {
@@ -834,12 +1317,14 @@ var AutoSave = class {
834
1317
  this.key = options.key ?? DEFAULT_KEY;
835
1318
  this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
836
1319
  this.layerManager = options.layerManager;
1320
+ this.fogManager = options.fogManager;
837
1321
  this.adapter = options.adapter ?? new LocalStorageAdapter();
838
1322
  this.onError = options.onError;
839
1323
  }
840
1324
  key;
841
1325
  debounceMs;
842
1326
  layerManager;
1327
+ fogManager;
843
1328
  adapter;
844
1329
  timerId = null;
845
1330
  unsubscribers = [];
@@ -857,6 +1342,9 @@ var AutoSave = class {
857
1342
  if (this.layerManager) {
858
1343
  this.unsubscribers.push(this.layerManager.on("change", schedule));
859
1344
  }
1345
+ if (this.fogManager) {
1346
+ this.unsubscribers.push(this.fogManager.on("change", schedule));
1347
+ }
860
1348
  }
861
1349
  stop() {
862
1350
  this.cancelPending();
@@ -893,7 +1381,8 @@ var AutoSave = class {
893
1381
  this.saving = true;
894
1382
  try {
895
1383
  const layers = this.layerManager?.snapshot() ?? [];
896
- const state = exportState(this.store.snapshot(), this.camera, layers);
1384
+ const fog = this.fogManager?.getState() ?? void 0;
1385
+ const state = exportState(this.store.snapshot(), this.camera, layers, void 0, fog);
897
1386
  await this.adapter.save(this.key, JSON.stringify(state));
898
1387
  } catch (e) {
899
1388
  this.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5225,6 +5714,8 @@ var MinimapController = class {
5225
5714
  // read the `viewport` parameter. Assigned in the constructor body instead.
5226
5715
  htmlPainters;
5227
5716
  scene = null;
5717
+ fogRenderer = null;
5718
+ fogUnsub = null;
5228
5719
  frameId = null;
5229
5720
  debounceTimer = null;
5230
5721
  dragging = false;
@@ -5239,6 +5730,15 @@ var MinimapController = class {
5239
5730
  this.renderScene();
5240
5731
  this.requestDraw();
5241
5732
  }
5733
+ setFogRenderer(renderer) {
5734
+ if (this.disposed) return;
5735
+ if (this.fogUnsub) {
5736
+ this.fogUnsub();
5737
+ this.fogUnsub = null;
5738
+ }
5739
+ this.fogRenderer = renderer;
5740
+ this.invalidateScene();
5741
+ }
5242
5742
  requestDraw() {
5243
5743
  if (this.disposed || this.frameId !== null) return;
5244
5744
  this.frameId = this.requestFrame(this.draw);
@@ -5300,8 +5800,15 @@ var MinimapController = class {
5300
5800
  }
5301
5801
  currentMapping() {
5302
5802
  const viewportRect = this.viewport.getVisibleRect();
5303
- const content = getElementsBoundingBox(this.sceneElements());
5304
- return content ? unionBounds(content, viewportRect) : viewportRect;
5803
+ let mapping = getElementsBoundingBox(this.sceneElements());
5804
+ mapping = mapping ? unionBounds(mapping, viewportRect) : viewportRect;
5805
+ if (this.fogRenderer?.isVisible()) {
5806
+ const fogState = this.fogRenderer.getState();
5807
+ if (fogState) {
5808
+ mapping = unionBounds(mapping, fogState.definition.bounds);
5809
+ }
5810
+ }
5811
+ return mapping;
5305
5812
  }
5306
5813
  onViewChanged() {
5307
5814
  if (this.disposed) return;
@@ -5359,6 +5866,23 @@ var MinimapController = class {
5359
5866
  ctx.drawImage(layerCanvas, 0, 0);
5360
5867
  ctx.restore();
5361
5868
  }
5869
+ if (this.fogRenderer?.isVisible()) {
5870
+ const fogState = this.fogRenderer.getState();
5871
+ const fogMode = this.fogRenderer.getViewMode();
5872
+ if (fogState && (fogMode === "editor" || fogMode === "player")) {
5873
+ ctx.save();
5874
+ ctx.setTransform(
5875
+ dpr * transform.scale,
5876
+ 0,
5877
+ 0,
5878
+ dpr * transform.scale,
5879
+ dpr * transform.offsetX,
5880
+ dpr * transform.offsetY
5881
+ );
5882
+ this.fogRenderer.renderForExport(ctx, fogState, fogMode);
5883
+ ctx.restore();
5884
+ }
5885
+ }
5362
5886
  this.scene = { canvas: sceneCanvas, transform, mapping };
5363
5887
  }
5364
5888
  renderLayerElements(ctx, elements, t, dpr) {
@@ -5471,9 +5995,15 @@ var Minimap = class {
5471
5995
  this.canvas = canvas;
5472
5996
  this.controller = new MinimapController(viewport, canvas, { width: WIDTH, height: HEIGHT });
5473
5997
  }
5998
+ setFogRenderer(renderer) {
5999
+ this.controller.setFogRenderer(renderer);
6000
+ }
5474
6001
  scheduleDraw() {
5475
6002
  this.controller.requestDraw();
5476
6003
  }
6004
+ invalidateScene() {
6005
+ this.controller.invalidateScene();
6006
+ }
5477
6007
  destroy() {
5478
6008
  this.controller.dispose();
5479
6009
  this.canvas.remove();
@@ -6795,6 +7325,220 @@ async function renderHtmlElements(elements, options) {
6795
7325
  return sources;
6796
7326
  }
6797
7327
 
7328
+ // src/fog/fog-renderer.ts
7329
+ var DEFAULT_EDITOR_COLOR = "rgba(30, 40, 60, 0.45)";
7330
+ var DEFAULT_PLAYER_COLOR = "#0b1020";
7331
+ var FogRenderer = class {
7332
+ tileCache = /* @__PURE__ */ new Map();
7333
+ state = null;
7334
+ viewMode = "off";
7335
+ dirty = true;
7336
+ editorColor;
7337
+ playerColor;
7338
+ constructor(options = {}) {
7339
+ this.editorColor = options.editorColor ?? DEFAULT_EDITOR_COLOR;
7340
+ this.playerColor = options.playerColor ?? DEFAULT_PLAYER_COLOR;
7341
+ }
7342
+ setState(state) {
7343
+ this.state = state;
7344
+ this.dirty = true;
7345
+ }
7346
+ setViewMode(mode) {
7347
+ if (mode === this.viewMode) return;
7348
+ this.viewMode = mode;
7349
+ this.dirty = true;
7350
+ }
7351
+ getState() {
7352
+ return this.state;
7353
+ }
7354
+ getViewMode() {
7355
+ return this.viewMode;
7356
+ }
7357
+ markDirty() {
7358
+ this.dirty = true;
7359
+ }
7360
+ isDirty() {
7361
+ return this.dirty;
7362
+ }
7363
+ isVisible() {
7364
+ return this.viewMode !== "off" && this.state !== null;
7365
+ }
7366
+ render(ctx, camera, viewportWidth, viewportHeight, _dpr) {
7367
+ if (!this.state || this.viewMode === "off") return;
7368
+ const def = this.state.definition;
7369
+ const cellSize = def.cellSize;
7370
+ const tileWorldSize = FOG_TILE_CELLS * cellSize;
7371
+ const color = this.viewMode === "editor" ? this.editorColor : this.playerColor;
7372
+ const worldBounds = getVisibleWorld(camera, viewportWidth, viewportHeight);
7373
+ const minTX = Math.floor(Math.max(def.bounds.x, worldBounds.x) / tileWorldSize);
7374
+ const minTY = Math.floor(Math.max(def.bounds.y, worldBounds.y) / tileWorldSize);
7375
+ const maxTX = Math.floor(
7376
+ Math.min(def.bounds.x + def.bounds.w - 1, worldBounds.x + worldBounds.w) / tileWorldSize
7377
+ );
7378
+ const maxTY = Math.floor(
7379
+ Math.min(def.bounds.y + def.bounds.h - 1, worldBounds.y + worldBounds.h) / tileWorldSize
7380
+ );
7381
+ ctx.save();
7382
+ ctx.translate(camera.position.x, camera.position.y);
7383
+ ctx.scale(camera.zoom, camera.zoom);
7384
+ const tileMap = /* @__PURE__ */ new Map();
7385
+ for (const tile of this.state.tiles) {
7386
+ tileMap.set(`${tile.x},${tile.y}`, tile.data);
7387
+ }
7388
+ const baseCovered = def.base === "covered";
7389
+ for (let ty = minTY; ty <= maxTY; ty++) {
7390
+ for (let tx = minTX; tx <= maxTX; tx++) {
7391
+ const key = `${tx},${ty}`;
7392
+ const data = tileMap.get(key);
7393
+ const tileWorldX = tx * tileWorldSize;
7394
+ const tileWorldY = ty * tileWorldSize;
7395
+ if (!data && baseCovered) {
7396
+ ctx.fillStyle = color;
7397
+ const clipX = Math.max(tileWorldX, def.bounds.x);
7398
+ const clipY = Math.max(tileWorldY, def.bounds.y);
7399
+ const clipR = Math.min(tileWorldX + tileWorldSize, def.bounds.x + def.bounds.w);
7400
+ const clipB = Math.min(tileWorldY + tileWorldSize, def.bounds.y + def.bounds.h);
7401
+ if (clipR > clipX && clipB > clipY) {
7402
+ ctx.fillRect(clipX, clipY, clipR - clipX, clipB - clipY);
7403
+ }
7404
+ continue;
7405
+ }
7406
+ if (!data && !baseCovered) {
7407
+ continue;
7408
+ }
7409
+ if (data) {
7410
+ this.renderTile(ctx, data, tx, ty, def, color);
7411
+ }
7412
+ }
7413
+ }
7414
+ ctx.restore();
7415
+ this.dirty = false;
7416
+ }
7417
+ renderForExport(ctx, state, mode, color) {
7418
+ const def = state.definition;
7419
+ const cellSize = def.cellSize;
7420
+ const tileWorldSize = FOG_TILE_CELLS * cellSize;
7421
+ const fogColor = color ?? (mode === "editor" ? this.editorColor : this.playerColor);
7422
+ const baseCovered = def.base === "covered";
7423
+ const tileMap = /* @__PURE__ */ new Map();
7424
+ for (const tile of state.tiles) {
7425
+ tileMap.set(`${tile.x},${tile.y}`, tile.data);
7426
+ }
7427
+ const minTX = Math.floor(def.bounds.x / tileWorldSize);
7428
+ const minTY = Math.floor(def.bounds.y / tileWorldSize);
7429
+ const maxTX = Math.floor((def.bounds.x + def.bounds.w - 1) / tileWorldSize);
7430
+ const maxTY = Math.floor((def.bounds.y + def.bounds.h - 1) / tileWorldSize);
7431
+ for (let ty = minTY; ty <= maxTY; ty++) {
7432
+ for (let tx = minTX; tx <= maxTX; tx++) {
7433
+ const key = `${tx},${ty}`;
7434
+ const data = tileMap.get(key);
7435
+ const tileWorldX = tx * tileWorldSize;
7436
+ const tileWorldY = ty * tileWorldSize;
7437
+ if (!data && baseCovered) {
7438
+ ctx.fillStyle = fogColor;
7439
+ const clipX = Math.max(tileWorldX, def.bounds.x);
7440
+ const clipY = Math.max(tileWorldY, def.bounds.y);
7441
+ const clipR = Math.min(tileWorldX + tileWorldSize, def.bounds.x + def.bounds.w);
7442
+ const clipB = Math.min(tileWorldY + tileWorldSize, def.bounds.y + def.bounds.h);
7443
+ if (clipR > clipX && clipB > clipY) {
7444
+ ctx.fillRect(clipX, clipY, clipR - clipX, clipB - clipY);
7445
+ }
7446
+ continue;
7447
+ }
7448
+ if (data) {
7449
+ this.renderTileForExport(ctx, data, tx, ty, def, fogColor);
7450
+ }
7451
+ }
7452
+ }
7453
+ }
7454
+ dispose() {
7455
+ this.tileCache.clear();
7456
+ this.state = null;
7457
+ }
7458
+ tileRaster(data, color) {
7459
+ const key = `${color}\0${data}`;
7460
+ const cached = this.tileCache.get(key);
7461
+ if (cached) return cached;
7462
+ if (typeof document === "undefined") return null;
7463
+ const canvas = document.createElement("canvas");
7464
+ canvas.width = FOG_TILE_CELLS;
7465
+ canvas.height = FOG_TILE_CELLS;
7466
+ const ctx = canvas.getContext("2d");
7467
+ if (!ctx) return null;
7468
+ const bytes = decodeBase64(data);
7469
+ ctx.fillStyle = color;
7470
+ for (let row = 0; row < FOG_TILE_CELLS; row++) {
7471
+ for (let col = 0; col < FOG_TILE_CELLS; col++) {
7472
+ const index = row * FOG_TILE_CELLS + col;
7473
+ const byteIndex = index >> 3;
7474
+ const bitIndex = 7 - (index & 7);
7475
+ const revealed = (bytes[byteIndex] >> bitIndex & 1) === 1;
7476
+ if (!revealed) ctx.fillRect(col, row, 1, 1);
7477
+ }
7478
+ }
7479
+ if (this.tileCache.size >= 256) {
7480
+ const oldest = this.tileCache.keys().next().value;
7481
+ if (oldest !== void 0) this.tileCache.delete(oldest);
7482
+ }
7483
+ this.tileCache.set(key, canvas);
7484
+ return canvas;
7485
+ }
7486
+ renderTile(ctx, data, tx, ty, def, color) {
7487
+ const cellSize = def.cellSize;
7488
+ const tileWorldX = tx * FOG_TILE_CELLS * cellSize;
7489
+ const tileWorldY = ty * FOG_TILE_CELLS * cellSize;
7490
+ const raster = this.tileRaster(data, color);
7491
+ if (raster) {
7492
+ ctx.save();
7493
+ ctx.beginPath();
7494
+ ctx.rect(def.bounds.x, def.bounds.y, def.bounds.w, def.bounds.h);
7495
+ ctx.clip();
7496
+ ctx.imageSmoothingEnabled = false;
7497
+ ctx.drawImage(
7498
+ raster,
7499
+ tileWorldX,
7500
+ tileWorldY,
7501
+ FOG_TILE_CELLS * cellSize,
7502
+ FOG_TILE_CELLS * cellSize
7503
+ );
7504
+ ctx.restore();
7505
+ return;
7506
+ }
7507
+ const bytes = decodeBase64(data);
7508
+ ctx.fillStyle = color;
7509
+ for (let row = 0; row < FOG_TILE_CELLS; row++) {
7510
+ for (let col = 0; col < FOG_TILE_CELLS; col++) {
7511
+ const cellWorldX = tileWorldX + col * cellSize;
7512
+ const cellWorldY = tileWorldY + row * cellSize;
7513
+ if (cellWorldX < def.bounds.x || cellWorldY < def.bounds.y || cellWorldX >= def.bounds.x + def.bounds.w || cellWorldY >= def.bounds.y + def.bounds.h) {
7514
+ continue;
7515
+ }
7516
+ const index = row * FOG_TILE_CELLS + col;
7517
+ const byteIndex = index >> 3;
7518
+ const bitIndex = 7 - (index & 7);
7519
+ const revealed = (bytes[byteIndex] >> bitIndex & 1) === 1;
7520
+ const covered = !revealed;
7521
+ if (covered) {
7522
+ ctx.fillRect(cellWorldX, cellWorldY, cellSize, cellSize);
7523
+ }
7524
+ }
7525
+ }
7526
+ }
7527
+ renderTileForExport(ctx, data, tx, ty, def, color) {
7528
+ this.renderTile(ctx, data, tx, ty, def, color);
7529
+ }
7530
+ };
7531
+ function getVisibleWorld(camera, viewportWidth, viewportHeight) {
7532
+ const topLeft = camera.screenToWorld({ x: 0, y: 0 });
7533
+ const bottomRight = camera.screenToWorld({ x: viewportWidth, y: viewportHeight });
7534
+ return {
7535
+ x: topLeft.x,
7536
+ y: topLeft.y,
7537
+ w: bottomRight.x - topLeft.x,
7538
+ h: bottomRight.y - topLeft.y
7539
+ };
7540
+ }
7541
+
6798
7542
  // src/canvas/export-image.ts
6799
7543
  var DEFAULT_IMAGE_TIMEOUT_MS = 1e4;
6800
7544
  var DEFAULT_MAX_DIMENSION = 16384;
@@ -7207,6 +7951,10 @@ async function exportImage(store, options = {}, layerManager) {
7207
7951
  renderGridForBounds(ctx, grid, bounds);
7208
7952
  ctx.restore();
7209
7953
  }
7954
+ if (options.fog) {
7955
+ const fogRenderer = new FogRenderer();
7956
+ fogRenderer.renderForExport(ctx, options.fog.state, options.fog.mode, options.fog.color);
7957
+ }
7210
7958
  const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
7211
7959
  return new Promise((resolve) => {
7212
7960
  canvas.toBlob((blob) => resolve(blob), mimeType, options.quality);
@@ -7588,6 +8336,27 @@ async function exportSvg(store, options = {}, layerManager) {
7588
8336
  const opacity = layerManager?.getLayer?.(grid.layerId)?.opacity ?? 1;
7589
8337
  body += opacity === 1 ? emitted : `<g opacity="${n(opacity)}">${emitted}</g>`;
7590
8338
  }
8339
+ if (options.fog && typeof document !== "undefined") {
8340
+ const fogState = options.fog.state;
8341
+ const fogW = Math.max(1, Math.ceil(bounds.w));
8342
+ const fogH = Math.max(1, Math.ceil(bounds.h));
8343
+ const fogCanvas = document.createElement("canvas");
8344
+ fogCanvas.width = fogW;
8345
+ fogCanvas.height = fogH;
8346
+ const fogCtx = fogCanvas.getContext("2d");
8347
+ if (fogCtx) {
8348
+ fogCtx.translate(-bounds.x, -bounds.y);
8349
+ const fogRenderer = new FogRenderer();
8350
+ fogRenderer.renderForExport(fogCtx, fogState, options.fog.mode, options.fog.color);
8351
+ try {
8352
+ const fogDataUri = fogCanvas.toDataURL("image/png");
8353
+ if (fogDataUri.startsWith("data:")) {
8354
+ body += `<image href="${esc(fogDataUri)}" x="${n(bounds.x)}" y="${n(bounds.y)}" width="${n(bounds.w)}" height="${n(bounds.h)}" />`;
8355
+ }
8356
+ } catch {
8357
+ }
8358
+ }
8359
+ }
7591
8360
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${n(bounds.w)}" height="${n(bounds.h)}" viewBox="${n(bounds.x)} ${n(bounds.y)} ${n(bounds.w)} ${n(bounds.h)}">${body}</svg>`;
7592
8361
  }
7593
8362
  function emitElement(el, imageDataUris, htmlDataUris, rasterScale, firstGrid, store, resourceOptions) {
@@ -8425,6 +9194,7 @@ var RenderLoop = class {
8425
9194
  layerCache;
8426
9195
  marginViewport;
8427
9196
  hybridSurface;
9197
+ fogRenderer;
8428
9198
  activeDrawingLayerId = null;
8429
9199
  gridCacheDirty = true;
8430
9200
  // set on recenter/viewport-change; consumed by the grid block
@@ -8448,6 +9218,7 @@ var RenderLoop = class {
8448
9218
  this.layerCache = deps.layerCache;
8449
9219
  this.marginViewport = deps.marginViewport;
8450
9220
  this.hybridSurface = deps.hybridSurface;
9221
+ this.fogRenderer = deps.fogRenderer;
8451
9222
  }
8452
9223
  requestRender() {
8453
9224
  this.needsRender = true;
@@ -8757,9 +9528,12 @@ var RenderLoop = class {
8757
9528
  group.push(element);
8758
9529
  }
8759
9530
  const activeTool = this.toolManager.activeTool;
8760
- const overlayOrder = visibleElements.length + 1;
9531
+ const fogVisible = this.fogRenderer?.isVisible() ?? false;
9532
+ const fogOrder = visibleElements.length + 1;
9533
+ const overlayOrder = fogVisible ? fogOrder + 1 : visibleElements.length + 1;
8761
9534
  const hasOverlay = activeTool?.renderOverlay !== void 0 || this.overlays.size > 0;
8762
- if (hybridActive && hasOverlay) hybridOrders.add(overlayOrder);
9535
+ if (fogVisible) hybridOrders.add(fogOrder);
9536
+ if (hasOverlay && (hybridActive || fogVisible)) hybridOrders.add(overlayOrder);
8763
9537
  this.hybridSurface.beginFrame(hybridOrders, this.canvasEl.width, this.canvasEl.height);
8764
9538
  for (const [layerId, elements] of this.layerGroups) {
8765
9539
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
@@ -8873,8 +9647,18 @@ var RenderLoop = class {
8873
9647
  }
8874
9648
  hybridCtx.restore();
8875
9649
  }
9650
+ if (fogVisible && this.fogRenderer) {
9651
+ const fogCtx = this.hybridSurface.getContext(fogOrder);
9652
+ if (fogCtx) {
9653
+ fogCtx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
9654
+ fogCtx.save();
9655
+ fogCtx.scale(dpr, dpr);
9656
+ this.fogRenderer.render(fogCtx, this.camera, cssWidth, cssHeight, dpr);
9657
+ fogCtx.restore();
9658
+ }
9659
+ }
8876
9660
  const overlayT0 = performance.now();
8877
- if (hybridActive && hasOverlay) {
9661
+ if ((hybridActive || fogVisible) && hasOverlay) {
8878
9662
  const overlayCtx = this.hybridSurface.getContext(overlayOrder);
8879
9663
  if (overlayCtx) {
8880
9664
  overlayCtx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
@@ -9885,6 +10669,248 @@ var ElementActivation = class {
9885
10669
  }
9886
10670
  };
9887
10671
 
10672
+ // src/fog/fog-command.ts
10673
+ var FogRegionCommand = class {
10674
+ constructor(manager, before, after) {
10675
+ this.manager = manager;
10676
+ this.before = before;
10677
+ this.after = after;
10678
+ }
10679
+ execute(_store) {
10680
+ this.manager.applyTilesDirect(this.after);
10681
+ }
10682
+ undo(_store) {
10683
+ this.manager.applyTilesDirect(this.before);
10684
+ }
10685
+ };
10686
+ var FogResetCommand = class {
10687
+ constructor(manager, before, after) {
10688
+ this.manager = manager;
10689
+ this.before = before;
10690
+ this.after = after;
10691
+ }
10692
+ execute(_store) {
10693
+ this.manager.restoreHistoryState(this.after);
10694
+ }
10695
+ undo(_store) {
10696
+ this.manager.restoreHistoryState(this.before);
10697
+ }
10698
+ };
10699
+
10700
+ // src/fog/fog-manager.ts
10701
+ function defaultIdFactory() {
10702
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
10703
+ return crypto.randomUUID();
10704
+ }
10705
+ return `fog-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
10706
+ }
10707
+ var FogManager = class {
10708
+ state = null;
10709
+ viewMode = "off";
10710
+ idFactory;
10711
+ onCommand;
10712
+ changeListeners = /* @__PURE__ */ new Set();
10713
+ viewListeners = /* @__PURE__ */ new Set();
10714
+ constructor(options = {}) {
10715
+ this.idFactory = options.idFactory ?? defaultIdFactory;
10716
+ this.onCommand = options.onCommand;
10717
+ }
10718
+ getState() {
10719
+ if (!this.state) return null;
10720
+ return {
10721
+ definition: { ...this.state.definition, bounds: { ...this.state.definition.bounds } },
10722
+ tiles: this.state.tiles.map((t) => ({ ...t }))
10723
+ };
10724
+ }
10725
+ getViewMode() {
10726
+ return this.viewMode;
10727
+ }
10728
+ initialize(options) {
10729
+ const base = options.base ?? "covered";
10730
+ const cellSize = options.cellSize ?? recommendedFogCellSize(options.bounds);
10731
+ const generation = this.idFactory();
10732
+ const newState = {
10733
+ definition: {
10734
+ version: 1,
10735
+ generation,
10736
+ bounds: { ...options.bounds },
10737
+ cellSize,
10738
+ tileCells: FOG_TILE_CELLS,
10739
+ base
10740
+ },
10741
+ tiles: []
10742
+ };
10743
+ validateFogState(newState);
10744
+ const before = this.state;
10745
+ this.state = newState;
10746
+ const command = new FogResetCommand(this, before, newState);
10747
+ this.onCommand?.(command);
10748
+ this.notifyChange({ kind: "definition" });
10749
+ return structuredClone(newState);
10750
+ }
10751
+ loadState(state, meta) {
10752
+ if (state !== null) {
10753
+ validateFogState(state);
10754
+ this.state = structuredClone(state);
10755
+ } else {
10756
+ this.state = null;
10757
+ }
10758
+ this.notifyChange({
10759
+ kind: state === null ? "disable" : "definition",
10760
+ origin: meta?.origin
10761
+ });
10762
+ }
10763
+ /** Restores a historical visual state without reusing its causal generation id. */
10764
+ restoreHistoryState(state) {
10765
+ if (state === null) {
10766
+ this.loadState(null);
10767
+ return;
10768
+ }
10769
+ this.loadState({
10770
+ definition: { ...state.definition, generation: this.idFactory() },
10771
+ tiles: state.tiles
10772
+ });
10773
+ }
10774
+ setBounds(bounds) {
10775
+ if (!this.state) return;
10776
+ const def = this.state.definition;
10777
+ validateFogState({
10778
+ definition: { ...def, bounds: { ...bounds } },
10779
+ tiles: []
10780
+ });
10781
+ const shrinks = bounds.x > def.bounds.x || bounds.y > def.bounds.y || bounds.x + bounds.w < def.bounds.x + def.bounds.w || bounds.y + bounds.h < def.bounds.y + def.bounds.h;
10782
+ const nextDefinition = {
10783
+ ...def,
10784
+ bounds: { ...bounds },
10785
+ generation: shrinks ? this.idFactory() : def.generation
10786
+ };
10787
+ const tiles = this.state.tiles.flatMap((tile) => {
10788
+ const tileWorldX = tile.x * FOG_TILE_CELLS * def.cellSize;
10789
+ const tileWorldY = tile.y * FOG_TILE_CELLS * def.cellSize;
10790
+ const tileWorldW = FOG_TILE_CELLS * def.cellSize;
10791
+ const tileWorldH = FOG_TILE_CELLS * def.cellSize;
10792
+ const intersects2 = !(tileWorldX + tileWorldW <= bounds.x || tileWorldY + tileWorldH <= bounds.y || tileWorldX >= bounds.x + bounds.w || tileWorldY >= bounds.y + bounds.h);
10793
+ if (!intersects2) return [];
10794
+ const canonical = canonicalizeFogTile(tile, nextDefinition);
10795
+ return canonical ? [canonical] : [];
10796
+ });
10797
+ const before = this.state;
10798
+ this.state = {
10799
+ definition: nextDefinition,
10800
+ tiles
10801
+ };
10802
+ const command = new FogResetCommand(this, before, this.state);
10803
+ this.onCommand?.(command);
10804
+ this.notifyChange({ kind: "definition" });
10805
+ }
10806
+ reset(base) {
10807
+ if (!this.state) return;
10808
+ const before = this.state;
10809
+ const generation = this.idFactory();
10810
+ this.state = {
10811
+ definition: { ...this.state.definition, base, generation },
10812
+ tiles: []
10813
+ };
10814
+ const command = new FogResetCommand(this, before, this.state);
10815
+ this.onCommand?.(command);
10816
+ this.notifyChange({ kind: "reset" });
10817
+ }
10818
+ disable() {
10819
+ if (!this.state) return;
10820
+ const before = this.state;
10821
+ this.state = null;
10822
+ const command = new FogResetCommand(this, before, null);
10823
+ this.onCommand?.(command);
10824
+ this.notifyChange({ kind: "disable" });
10825
+ }
10826
+ setViewMode(mode) {
10827
+ if (mode === this.viewMode) return;
10828
+ this.viewMode = mode;
10829
+ this.notifyView({ mode });
10830
+ }
10831
+ applyRegion(region, operation) {
10832
+ if (!this.state) return;
10833
+ const result = rasterizeRegion(this.state, region, operation);
10834
+ if (result.noop) return;
10835
+ const before = this.collectTiles(result.changed);
10836
+ const newState = applyRasterResult(this.state, result);
10837
+ this.state = newState;
10838
+ const command = new FogRegionCommand(this, before, result.changed);
10839
+ this.onCommand?.(command);
10840
+ this.notifyChange({
10841
+ kind: "tiles",
10842
+ tiles: result.changed.map((t) => ({ x: t.x, y: t.y }))
10843
+ });
10844
+ }
10845
+ applyPatchDirect(patch, meta) {
10846
+ if (!this.state) return;
10847
+ const result = applyRasterResult(this.state, { changed: patch.tiles, noop: false });
10848
+ this.state = result;
10849
+ this.notifyChange({
10850
+ kind: "tiles",
10851
+ tiles: patch.tiles.map((t) => ({ x: t.x, y: t.y })),
10852
+ origin: meta?.origin
10853
+ });
10854
+ }
10855
+ applyTilesDirect(tiles) {
10856
+ if (!this.state) return;
10857
+ const result = applyRasterResult(this.state, { changed: tiles, noop: false });
10858
+ this.state = result;
10859
+ this.notifyChange({
10860
+ kind: "tiles",
10861
+ tiles: tiles.map((t) => ({ x: t.x, y: t.y }))
10862
+ });
10863
+ }
10864
+ on(event, listener) {
10865
+ if (event === "change") {
10866
+ const l2 = listener;
10867
+ this.changeListeners.add(l2);
10868
+ return () => this.changeListeners.delete(l2);
10869
+ }
10870
+ const l = listener;
10871
+ this.viewListeners.add(l);
10872
+ return () => this.viewListeners.delete(l);
10873
+ }
10874
+ dispose() {
10875
+ this.changeListeners.clear();
10876
+ this.viewListeners.clear();
10877
+ }
10878
+ collectTiles(changed) {
10879
+ if (!this.state) return [];
10880
+ const result = [];
10881
+ for (const c of changed) {
10882
+ const existing = this.state.tiles.find((t) => t.x === c.x && t.y === c.y);
10883
+ if (existing) {
10884
+ result.push(existing);
10885
+ } else {
10886
+ const baseVal = this.state.definition.base === "revealed";
10887
+ result.push({
10888
+ x: c.x,
10889
+ y: c.y,
10890
+ data: encodeBase64(createTileBytes(baseVal))
10891
+ });
10892
+ }
10893
+ }
10894
+ return result;
10895
+ }
10896
+ notifyChange(event) {
10897
+ for (const listener of this.changeListeners) {
10898
+ try {
10899
+ listener(event);
10900
+ } catch {
10901
+ }
10902
+ }
10903
+ }
10904
+ notifyView(event) {
10905
+ for (const listener of this.viewListeners) {
10906
+ try {
10907
+ listener(event);
10908
+ } catch {
10909
+ }
10910
+ }
10911
+ }
10912
+ };
10913
+
9888
10914
  // src/canvas/viewport.ts
9889
10915
  var EMPTY_IDS = [];
9890
10916
  function noop2() {
@@ -10011,8 +11037,13 @@ var Viewport = class _Viewport {
10011
11037
  });
10012
11038
  }
10013
11039
  this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
11040
+ this.fogManager = new FogManager({
11041
+ onCommand: (cmd) => this.history.push(cmd)
11042
+ });
11043
+ this.fogRenderer = new FogRenderer(options.fog);
10014
11044
  if (options.minimap) {
10015
11045
  this.minimap = new Minimap(this.wrapper, this);
11046
+ this.minimap.setFogRenderer(this.fogRenderer);
10016
11047
  }
10017
11048
  this.domNodeManager = new DomNodeManager({
10018
11049
  domLayer: this.paintStack,
@@ -10042,7 +11073,18 @@ var Viewport = class _Viewport {
10042
11073
  domNodeManager: this.domNodeManager,
10043
11074
  layerCache,
10044
11075
  marginViewport: this.marginViewport,
10045
- hybridSurface: new HybridRenderSurface(this.paintStack)
11076
+ hybridSurface: new HybridRenderSurface(this.paintStack),
11077
+ fogRenderer: this.fogRenderer
11078
+ });
11079
+ this.fogManager.on("change", () => {
11080
+ this.fogRenderer.setState(this.fogManager.getState());
11081
+ this.renderLoop.requestRender();
11082
+ this.minimap?.invalidateScene();
11083
+ });
11084
+ this.fogManager.on("view", () => {
11085
+ this.fogRenderer.setViewMode(this.fogManager.getViewMode());
11086
+ this.renderLoop.requestRender();
11087
+ this.minimap?.invalidateScene();
10046
11088
  });
10047
11089
  this.unsubHtmlPainters = this.htmlPainters.onChange(() => this.onHtmlRegistryChanged());
10048
11090
  this.unsubCamera = this.camera.onChange(() => {
@@ -10154,6 +11196,8 @@ var Viewport = class _Viewport {
10154
11196
  _smartGuides = false;
10155
11197
  _gridSize;
10156
11198
  renderLoop;
11199
+ fogManager;
11200
+ fogRenderer;
10157
11201
  domNodeManager;
10158
11202
  interactMode;
10159
11203
  onHtmlElementMount;
@@ -10189,6 +11233,9 @@ var Viewport = class _Viewport {
10189
11233
  get ctx() {
10190
11234
  return this.canvasEl.getContext("2d");
10191
11235
  }
11236
+ get fog() {
11237
+ return this.fogManager;
11238
+ }
10192
11239
  get snapToGrid() {
10193
11240
  return this._snapToGrid;
10194
11241
  }
@@ -10273,7 +11320,8 @@ var Viewport = class _Viewport {
10273
11320
  this.store.snapshot(),
10274
11321
  this.camera,
10275
11322
  this.layerManager.snapshot(),
10276
- this.layerManager.activeLayerId
11323
+ this.layerManager.activeLayerId,
11324
+ this.fogManager.getState()
10277
11325
  );
10278
11326
  }
10279
11327
  exportJSON() {
@@ -10295,12 +11343,31 @@ var Viewport = class _Viewport {
10295
11343
  return { ...base, htmlPainters: registry, expectedCanvasTypes: expected };
10296
11344
  }
10297
11345
  async exportImage(options) {
10298
- return exportImage(this.store, this.withHtmlDefaults(options), this.layerManager);
11346
+ const opts = this.withHtmlDefaults(options);
11347
+ if (opts.fog === void 0 && this.fogRenderer.isVisible()) {
11348
+ const state = this.fogManager.getState();
11349
+ if (state) {
11350
+ const mode = this.fogRenderer.getViewMode();
11351
+ opts.fog = { state, mode };
11352
+ }
11353
+ }
11354
+ return exportImage(this.store, opts, this.layerManager);
10299
11355
  }
10300
11356
  async exportSVG(options) {
10301
- return exportSvg(this.store, this.withHtmlDefaults(options), this.layerManager);
11357
+ const opts = this.withHtmlDefaults(options);
11358
+ if (opts.fog === void 0 && this.fogRenderer.isVisible()) {
11359
+ const state = this.fogManager.getState();
11360
+ if (state) {
11361
+ const mode = this.fogRenderer.getViewMode();
11362
+ opts.fog = { state, mode };
11363
+ }
11364
+ }
11365
+ return exportSvg(this.store, opts, this.layerManager);
10302
11366
  }
10303
11367
  loadState(state) {
11368
+ if (state.fog != null) {
11369
+ validateFogState(state.fog);
11370
+ }
10304
11371
  this.inputHandler.flushPendingHistory();
10305
11372
  this.historyRecorder.pause();
10306
11373
  this.noteEditor.destroy(this.store);
@@ -10335,6 +11402,7 @@ var Viewport = class _Viewport {
10335
11402
  }
10336
11403
  }
10337
11404
  }
11405
+ this.fogManager.loadState(state.fog ?? null);
10338
11406
  this.history.clear();
10339
11407
  this.historyRecorder.resume();
10340
11408
  this.camera.moveTo(state.camera.position.x, state.camera.position.y);
@@ -10746,6 +11814,8 @@ var Viewport = class _Viewport {
10746
11814
  this.unsubToolRegister();
10747
11815
  this.unsubRecorderEnd();
10748
11816
  this.unsubHtmlPainters();
11817
+ this.fogManager.dispose();
11818
+ this.fogRenderer.dispose();
10749
11819
  this.activation?.dispose();
10750
11820
  this.activation = null;
10751
11821
  this.activationListeners.clear();
@@ -12228,26 +13298,820 @@ var RemoteFocusReceiver = class {
12228
13298
  }
12229
13299
  };
12230
13300
 
12231
- // src/tools/hand-tool.ts
12232
- var HandTool = class {
12233
- name = "hand";
12234
- panning = false;
12235
- lastScreen = { x: 0, y: 0 };
12236
- onActivate(ctx) {
12237
- ctx.setCursor?.("grab");
12238
- }
12239
- onDeactivate(ctx) {
12240
- ctx.setCursor?.("default");
12241
- }
12242
- onPointerDown(state, ctx) {
12243
- this.panning = true;
12244
- this.lastScreen = { x: state.x, y: state.y };
12245
- ctx.setCursor?.("grabbing");
13301
+ // src/canvas/awareness-presence.ts
13302
+ var AWARENESS_PRESENCE_KIND = "awareness";
13303
+ var AWARENESS_MAX_SELECTION = 256;
13304
+ var MAX_ID_LENGTH = 128;
13305
+ var MAX_NAME_LENGTH = 64;
13306
+ var MAX_COLOR_LENGTH2 = 64;
13307
+ var MAX_ROLE_LENGTH = 32;
13308
+ var MAX_TOOL_LENGTH = 64;
13309
+ function isBoundedString(value, max) {
13310
+ return typeof value === "string" && value.length <= max;
13311
+ }
13312
+ function isOptionalBoundedString(value, max) {
13313
+ return value === void 0 || isBoundedString(value, max);
13314
+ }
13315
+ function isFinitePoint4(value) {
13316
+ if (typeof value !== "object" || value === null) return false;
13317
+ const point = value;
13318
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
13319
+ }
13320
+ function isAwarenessPresence(data) {
13321
+ if (typeof data !== "object" || data === null) return false;
13322
+ const payload = data;
13323
+ if (payload.kind !== AWARENESS_PRESENCE_KIND) return false;
13324
+ if (!isBoundedString(payload.id, MAX_ID_LENGTH) || payload.id.length === 0) return false;
13325
+ if ("cleared" in payload) return payload.cleared === true;
13326
+ if (!isOptionalBoundedString(payload.name, MAX_NAME_LENGTH)) return false;
13327
+ if (!isOptionalBoundedString(payload.color, MAX_COLOR_LENGTH2)) return false;
13328
+ if (!isOptionalBoundedString(payload.role, MAX_ROLE_LENGTH)) return false;
13329
+ if (!isOptionalBoundedString(payload.tool, MAX_TOOL_LENGTH)) return false;
13330
+ if (payload.cursor !== void 0 && !isFinitePoint4(payload.cursor)) return false;
13331
+ if (payload.selection !== void 0) {
13332
+ if (!Array.isArray(payload.selection)) return false;
13333
+ if (payload.selection.length > AWARENESS_MAX_SELECTION) return false;
13334
+ for (const id of payload.selection) {
13335
+ if (!isBoundedString(id, MAX_ID_LENGTH) || id.length === 0) return false;
13336
+ }
12246
13337
  }
12247
- onPointerMove(state, ctx) {
12248
- if (!this.panning) return;
12249
- const dx = state.x - this.lastScreen.x;
12250
- const dy = state.y - this.lastScreen.y;
13338
+ return true;
13339
+ }
13340
+
13341
+ // src/canvas/awareness-roster.ts
13342
+ var DEFAULT_STALE_MS = 45e3;
13343
+ var EMPTY_PEERS = Object.freeze([]);
13344
+ var EMPTY_SELECTION = Object.freeze([]);
13345
+ function sameSelection(a, b) {
13346
+ if (a.length !== b.length) return false;
13347
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
13348
+ return true;
13349
+ }
13350
+ function samePoint2(a, b) {
13351
+ if (a === null || b === null) return a === b;
13352
+ return a.x === b.x && a.y === b.y;
13353
+ }
13354
+ function toPeer(from, data, prev) {
13355
+ const cursor = data.cursor ? { x: data.cursor.x, y: data.cursor.y } : null;
13356
+ const incoming = data.selection ?? EMPTY_SELECTION;
13357
+ const selection = prev && sameSelection(prev.selection, incoming) ? prev.selection : incoming.length === 0 ? EMPTY_SELECTION : Object.freeze([...incoming]);
13358
+ const tool = data.tool ?? null;
13359
+ if (prev && prev.id === data.id && prev.name === data.name && prev.color === data.color && prev.role === data.role && prev.tool === tool && prev.selection === selection && samePoint2(prev.cursor, cursor)) {
13360
+ return prev;
13361
+ }
13362
+ const peer = {
13363
+ from,
13364
+ id: data.id,
13365
+ ...data.name === void 0 ? {} : { name: data.name },
13366
+ ...data.color === void 0 ? {} : { color: data.color },
13367
+ ...data.role === void 0 ? {} : { role: data.role },
13368
+ cursor,
13369
+ selection,
13370
+ tool
13371
+ };
13372
+ return peer;
13373
+ }
13374
+ var PeerRoster = class {
13375
+ staleMs;
13376
+ now;
13377
+ rows = /* @__PURE__ */ new Map();
13378
+ discovered = /* @__PURE__ */ new Map();
13379
+ changeListeners = /* @__PURE__ */ new Set();
13380
+ discoverListeners = /* @__PURE__ */ new Set();
13381
+ leaveListeners = /* @__PURE__ */ new Set();
13382
+ snapshot = EMPTY_PEERS;
13383
+ snapshotDirty = false;
13384
+ staleTimer = null;
13385
+ isDisposed = false;
13386
+ constructor(options = {}) {
13387
+ this.staleMs = options.staleMs ?? DEFAULT_STALE_MS;
13388
+ this.now = options.now ?? (() => Date.now());
13389
+ }
13390
+ get disposed() {
13391
+ return this.isDisposed;
13392
+ }
13393
+ /**
13394
+ * Applies a presence payload from `from`. Non-awareness or malformed payloads
13395
+ * return `false` untouched, so hosts can feed every presence frame through.
13396
+ */
13397
+ apply(from, data) {
13398
+ if (this.isDisposed || !isAwarenessPresence(data)) return false;
13399
+ const isNew = !this.discovered.has(from);
13400
+ this.discovered.set(from, this.now());
13401
+ if ("cleared" in data) {
13402
+ this.dropRow(from, "cleared");
13403
+ } else {
13404
+ const prev = this.rows.get(from);
13405
+ const next = toPeer(from, data, prev);
13406
+ if (next !== prev) {
13407
+ this.rows.set(from, next);
13408
+ this.changed();
13409
+ }
13410
+ }
13411
+ this.armStaleTimer();
13412
+ if (isNew) this.emit(this.discoverListeners, (l) => l(from));
13413
+ return true;
13414
+ }
13415
+ /** Server-authored presence-leave: drops the row AND the discovery entry. */
13416
+ remove(from) {
13417
+ if (this.isDisposed) return;
13418
+ const hadEntry = this.discovered.delete(from);
13419
+ this.dropRow(from, "left");
13420
+ if (hadEntry) this.armStaleTimer();
13421
+ }
13422
+ getPeers() {
13423
+ if (this.snapshotDirty) {
13424
+ this.snapshot = this.rows.size === 0 ? EMPTY_PEERS : Object.freeze([...this.rows.values()]);
13425
+ this.snapshotDirty = false;
13426
+ }
13427
+ return this.snapshot;
13428
+ }
13429
+ getPeer(from) {
13430
+ return this.rows.get(from);
13431
+ }
13432
+ /** Fires only when `getPeers()` would return a new reference. */
13433
+ onChange(listener) {
13434
+ this.changeListeners.add(listener);
13435
+ return () => this.changeListeners.delete(listener);
13436
+ }
13437
+ /** First valid frame from a sender since its discovery entry was last dropped. */
13438
+ onDiscover(listener) {
13439
+ this.discoverListeners.add(listener);
13440
+ return () => this.discoverListeners.delete(listener);
13441
+ }
13442
+ onLeave(listener) {
13443
+ this.leaveListeners.add(listener);
13444
+ return () => this.leaveListeners.delete(listener);
13445
+ }
13446
+ dispose() {
13447
+ if (this.isDisposed) return;
13448
+ this.isDisposed = true;
13449
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
13450
+ this.staleTimer = null;
13451
+ this.rows.clear();
13452
+ this.discovered.clear();
13453
+ this.snapshot = EMPTY_PEERS;
13454
+ this.snapshotDirty = false;
13455
+ this.changeListeners.clear();
13456
+ this.discoverListeners.clear();
13457
+ this.leaveListeners.clear();
13458
+ }
13459
+ dropRow(from, reason) {
13460
+ const row = this.rows.get(from);
13461
+ if (!row) return;
13462
+ this.rows.delete(from);
13463
+ this.changed();
13464
+ this.emit(this.leaveListeners, (l) => l(row, reason));
13465
+ }
13466
+ changed() {
13467
+ this.snapshotDirty = true;
13468
+ this.emit(this.changeListeners, (l) => l());
13469
+ }
13470
+ emit(listeners, call) {
13471
+ for (const listener of [...listeners]) {
13472
+ try {
13473
+ call(listener);
13474
+ } catch {
13475
+ }
13476
+ }
13477
+ }
13478
+ armStaleTimer() {
13479
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
13480
+ this.staleTimer = null;
13481
+ if (!Number.isFinite(this.staleMs) || this.staleMs <= 0 || this.isDisposed || this.discovered.size === 0) {
13482
+ return;
13483
+ }
13484
+ let earliest = Infinity;
13485
+ for (const seen of this.discovered.values()) if (seen < earliest) earliest = seen;
13486
+ const delay = Math.min(Math.max(0, earliest + this.staleMs - this.now()), 2 ** 31 - 1);
13487
+ this.staleTimer = setTimeout(() => {
13488
+ this.staleTimer = null;
13489
+ this.expireStale();
13490
+ }, delay);
13491
+ }
13492
+ expireStale() {
13493
+ const t = this.now();
13494
+ for (const from of [...this.discovered.keys()]) {
13495
+ const seen = this.discovered.get(from);
13496
+ if (seen === void 0 || t - seen < this.staleMs) continue;
13497
+ this.discovered.delete(from);
13498
+ this.dropRow(from, "stale");
13499
+ }
13500
+ this.armStaleTimer();
13501
+ }
13502
+ };
13503
+
13504
+ // src/canvas/awareness-publisher.ts
13505
+ var DEFAULT_FIELDS = Object.freeze({
13506
+ cursor: true,
13507
+ selection: false,
13508
+ tool: true
13509
+ });
13510
+ var DEFAULT_INTERVAL_MS = 50;
13511
+ var DEFAULT_HEARTBEAT_MS = 15e3;
13512
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
13513
+ var MAX_IDENTITY_ID_LENGTH = 128;
13514
+ var MAX_IDENTITY_NAME_LENGTH = 64;
13515
+ var MAX_IDENTITY_COLOR_LENGTH = 64;
13516
+ var MAX_IDENTITY_ROLE_LENGTH = 32;
13517
+ var MAX_TOOL_LENGTH2 = 64;
13518
+ var MAX_SELECTION_ID_LENGTH = 128;
13519
+ function normalizeIntervalMs(value) {
13520
+ return Number.isFinite(value) && value >= 0 ? value : 0;
13521
+ }
13522
+ function normalizeHeartbeatMs(value) {
13523
+ return Number.isFinite(value) && value > 0 ? value : 0;
13524
+ }
13525
+ var LocalAwareness = class {
13526
+ host;
13527
+ element;
13528
+ send;
13529
+ selectionFilter;
13530
+ onError;
13531
+ intervalMs;
13532
+ heartbeatMs;
13533
+ identity;
13534
+ fields;
13535
+ lastPointer = null;
13536
+ selection = [];
13537
+ selectionFailed = false;
13538
+ tool;
13539
+ dirty = false;
13540
+ lastSentAt = null;
13541
+ throttleTimer = null;
13542
+ heartbeatTimer = null;
13543
+ unsubscribers = [];
13544
+ isDisposed = false;
13545
+ handlePointerMove = (e) => this.onPointerMove(e);
13546
+ handlePointerEnd = (e) => this.onPointerEnd(e);
13547
+ constructor(host, options) {
13548
+ const element = options.element ?? host.domLayer.parentElement;
13549
+ if (!element) throw new Error("LocalAwareness: the viewport wrapper is not mounted");
13550
+ this.host = host;
13551
+ this.element = element;
13552
+ this.send = options.send;
13553
+ this.selectionFilter = options.selectionFilter;
13554
+ this.onError = options.onError;
13555
+ this.intervalMs = normalizeIntervalMs(options.intervalMs ?? DEFAULT_INTERVAL_MS);
13556
+ this.heartbeatMs = normalizeHeartbeatMs(options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
13557
+ this.identity = this.normalizeIdentity(options.identity);
13558
+ this.fields = mergeFields(DEFAULT_FIELDS, options.fields ?? {});
13559
+ this.tool = host.toolManager.activeTool?.name ?? null;
13560
+ if (this.fields.selection) this.refreshSelection();
13561
+ const opts = { passive: true };
13562
+ element.addEventListener("pointermove", this.handlePointerMove, opts);
13563
+ element.addEventListener("pointerleave", this.handlePointerEnd, opts);
13564
+ element.addEventListener("pointercancel", this.handlePointerEnd, opts);
13565
+ this.unsubscribers.push(
13566
+ host.onSelectionChange(() => {
13567
+ if (this.fields.selection) this.schedule();
13568
+ }),
13569
+ host.toolManager.onChange((name) => {
13570
+ this.tool = name;
13571
+ if (this.fields.tool) this.schedule();
13572
+ })
13573
+ );
13574
+ this.armHeartbeat();
13575
+ }
13576
+ get disposed() {
13577
+ return this.isDisposed;
13578
+ }
13579
+ getFields() {
13580
+ return this.fields;
13581
+ }
13582
+ setIdentity(identity) {
13583
+ this.identity = this.normalizeIdentity(identity);
13584
+ this.schedule();
13585
+ }
13586
+ /** Merges the given flags into the current policy; `undefined` keys are ignored. */
13587
+ setFields(fields) {
13588
+ this.fields = mergeFields(this.fields, fields);
13589
+ this.schedule();
13590
+ }
13591
+ /**
13592
+ * Requests a full frame: immediate when idle, otherwise folded into the
13593
+ * pending trailing frame (so N simultaneous requests cost one frame). Hosts
13594
+ * call it when the connection becomes live or reconnects.
13595
+ */
13596
+ announce() {
13597
+ this.schedule();
13598
+ }
13599
+ /**
13600
+ * The complete state a frame carries right now. Side-effecting when
13601
+ * selection publishing is on: re-reads `getSelectedIds()`, re-runs
13602
+ * `selectionFilter`, updates the fail-closed selection state, and may call
13603
+ * `onError`. A no-op with respect to selection while publishing is off.
13604
+ */
13605
+ getState() {
13606
+ const frame = { kind: AWARENESS_PRESENCE_KIND, id: this.identity.id };
13607
+ if (this.identity.name !== void 0) frame.name = this.identity.name;
13608
+ if (this.identity.color !== void 0) frame.color = this.identity.color;
13609
+ if (this.identity.role !== void 0) frame.role = this.identity.role;
13610
+ if (this.fields.cursor && this.lastPointer !== null) {
13611
+ frame.cursor = { x: this.lastPointer.x, y: this.lastPointer.y };
13612
+ }
13613
+ if (this.fields.selection) {
13614
+ this.refreshSelection();
13615
+ if (!this.selectionFailed && this.selection.length > 0) {
13616
+ frame.selection = [...this.selection];
13617
+ }
13618
+ }
13619
+ if (this.fields.tool && this.tool !== null && this.tool.length <= MAX_TOOL_LENGTH2) {
13620
+ frame.tool = this.tool;
13621
+ }
13622
+ return frame;
13623
+ }
13624
+ dispose() {
13625
+ if (this.isDisposed) return;
13626
+ this.isDisposed = true;
13627
+ if (this.throttleTimer !== null) clearTimeout(this.throttleTimer);
13628
+ this.throttleTimer = null;
13629
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
13630
+ this.heartbeatTimer = null;
13631
+ this.element.removeEventListener("pointermove", this.handlePointerMove);
13632
+ this.element.removeEventListener("pointerleave", this.handlePointerEnd);
13633
+ this.element.removeEventListener("pointercancel", this.handlePointerEnd);
13634
+ for (const unsub of this.unsubscribers) unsub();
13635
+ this.unsubscribers.length = 0;
13636
+ this.safeSend({ kind: AWARENESS_PRESENCE_KIND, id: this.identity.id, cleared: true });
13637
+ }
13638
+ now() {
13639
+ return Date.now();
13640
+ }
13641
+ onPointerMove(e) {
13642
+ if (!e.isPrimary) return;
13643
+ const rect = this.element.getBoundingClientRect();
13644
+ const world = this.host.camera.screenToWorld({
13645
+ x: e.clientX - rect.left,
13646
+ y: e.clientY - rect.top
13647
+ });
13648
+ this.lastPointer = Number.isFinite(world.x) && Number.isFinite(world.y) ? { x: world.x, y: world.y } : null;
13649
+ if (this.fields.cursor) this.schedule();
13650
+ }
13651
+ onPointerEnd(e) {
13652
+ if (!e.isPrimary || this.lastPointer === null) return;
13653
+ this.lastPointer = null;
13654
+ if (this.fields.cursor) this.schedule();
13655
+ }
13656
+ refreshSelection() {
13657
+ try {
13658
+ const raw = this.host.getSelectedIds();
13659
+ const ids = this.selectionFilter ? this.selectionFilter(raw) : raw;
13660
+ if (!Array.isArray(ids)) throw new TypeError("selectionFilter must return an array");
13661
+ for (const id of ids) {
13662
+ if (typeof id !== "string") throw new TypeError("selectionFilter must return strings");
13663
+ if (id.length === 0 || id.length > MAX_SELECTION_ID_LENGTH) {
13664
+ throw new TypeError("selectionFilter must return ids of 1..128 characters");
13665
+ }
13666
+ }
13667
+ this.selection = ids.slice(0, AWARENESS_MAX_SELECTION);
13668
+ this.selectionFailed = false;
13669
+ } catch (error) {
13670
+ this.selection = [];
13671
+ this.selectionFailed = true;
13672
+ this.report(error);
13673
+ }
13674
+ }
13675
+ schedule() {
13676
+ if (this.isDisposed) return;
13677
+ this.dirty = true;
13678
+ if (this.throttleTimer !== null) return;
13679
+ const elapsed = this.lastSentAt === null ? Infinity : this.now() - this.lastSentAt;
13680
+ if (elapsed >= this.intervalMs) {
13681
+ this.flush();
13682
+ return;
13683
+ }
13684
+ this.throttleTimer = setTimeout(
13685
+ () => {
13686
+ this.throttleTimer = null;
13687
+ if (this.dirty) this.flush();
13688
+ },
13689
+ Math.min(this.intervalMs - elapsed, MAX_TIMER_DELAY_MS)
13690
+ );
13691
+ }
13692
+ flush() {
13693
+ this.dirty = false;
13694
+ this.lastSentAt = this.now();
13695
+ this.safeSend(this.getState());
13696
+ this.armHeartbeat();
13697
+ }
13698
+ armHeartbeat() {
13699
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
13700
+ this.heartbeatTimer = null;
13701
+ if (this.heartbeatMs <= 0 || this.isDisposed) return;
13702
+ this.heartbeatTimer = setTimeout(
13703
+ () => {
13704
+ this.heartbeatTimer = null;
13705
+ this.flush();
13706
+ },
13707
+ Math.min(this.heartbeatMs, MAX_TIMER_DELAY_MS)
13708
+ );
13709
+ }
13710
+ safeSend(frame) {
13711
+ try {
13712
+ this.send(frame);
13713
+ } catch (error) {
13714
+ this.report(error);
13715
+ }
13716
+ }
13717
+ report(error) {
13718
+ try {
13719
+ this.onError?.(error);
13720
+ } catch {
13721
+ }
13722
+ }
13723
+ /**
13724
+ * Truncates identity strings to the wire caps and rejects an invalid id, so a
13725
+ * sender can never publish a frame that the wire guard would drop outright.
13726
+ * A truncated field is reported through `onError` (a `RangeError`) rather
13727
+ * than silently shortened, so a caller passing an over-long name finds out.
13728
+ */
13729
+ normalizeIdentity(identity) {
13730
+ if (identity.id.length === 0 || identity.id.length > MAX_IDENTITY_ID_LENGTH) {
13731
+ throw new RangeError("LocalAwareness: identity.id must be 1..128 characters");
13732
+ }
13733
+ const normalized = {
13734
+ id: identity.id
13735
+ };
13736
+ if (identity.name !== void 0) {
13737
+ normalized.name = identity.name.slice(0, MAX_IDENTITY_NAME_LENGTH);
13738
+ if (identity.name.length > MAX_IDENTITY_NAME_LENGTH) {
13739
+ this.report(
13740
+ new RangeError(
13741
+ `LocalAwareness: identity.name truncated to ${MAX_IDENTITY_NAME_LENGTH} characters`
13742
+ )
13743
+ );
13744
+ }
13745
+ }
13746
+ if (identity.color !== void 0) {
13747
+ normalized.color = identity.color.slice(0, MAX_IDENTITY_COLOR_LENGTH);
13748
+ if (identity.color.length > MAX_IDENTITY_COLOR_LENGTH) {
13749
+ this.report(
13750
+ new RangeError(
13751
+ `LocalAwareness: identity.color truncated to ${MAX_IDENTITY_COLOR_LENGTH} characters`
13752
+ )
13753
+ );
13754
+ }
13755
+ }
13756
+ if (identity.role !== void 0) {
13757
+ normalized.role = identity.role.slice(0, MAX_IDENTITY_ROLE_LENGTH);
13758
+ if (identity.role.length > MAX_IDENTITY_ROLE_LENGTH) {
13759
+ this.report(
13760
+ new RangeError(
13761
+ `LocalAwareness: identity.role truncated to ${MAX_IDENTITY_ROLE_LENGTH} characters`
13762
+ )
13763
+ );
13764
+ }
13765
+ }
13766
+ return normalized;
13767
+ }
13768
+ };
13769
+ function mergeFields(current, patch) {
13770
+ return Object.freeze({
13771
+ cursor: patch.cursor ?? current.cursor,
13772
+ selection: patch.selection ?? current.selection,
13773
+ tool: patch.tool ?? current.tool
13774
+ });
13775
+ }
13776
+
13777
+ // src/canvas/remote-cursor-overlay.ts
13778
+ var PEER_COLORS = Object.freeze([
13779
+ "#e11d48",
13780
+ "#ea580c",
13781
+ "#ca8a04",
13782
+ "#16a34a",
13783
+ "#0d9488",
13784
+ "#0284c7",
13785
+ "#2563eb",
13786
+ "#7c3aed",
13787
+ "#c026d3",
13788
+ "#db2777",
13789
+ "#4d7c0f",
13790
+ "#b45309"
13791
+ ]);
13792
+ function defaultPeerColor(seed) {
13793
+ if (seed.length === 0) return PEER_COLORS[0] ?? "#2563eb";
13794
+ let hash = 2166136261;
13795
+ for (let i = 0; i < seed.length; i++) {
13796
+ hash ^= seed.charCodeAt(i);
13797
+ hash = Math.imul(hash, 16777619) >>> 0;
13798
+ }
13799
+ return PEER_COLORS[hash % PEER_COLORS.length] ?? "#2563eb";
13800
+ }
13801
+ var DEFAULT_LABEL_FONT = "12px sans-serif";
13802
+ var LABEL_PAD_X = 6;
13803
+ var LABEL_PAD_Y = 3;
13804
+ var LABEL_HEIGHT = 16;
13805
+ var LABEL_OFFSET = 14;
13806
+ var MAX_LABEL_WIDTH_CACHE = 64;
13807
+ var RemoteCursorOverlay = class {
13808
+ host;
13809
+ roster;
13810
+ colorFor;
13811
+ showLabels;
13812
+ labelFont;
13813
+ labelWidths = /* @__PURE__ */ new Map();
13814
+ unregister;
13815
+ unsubscribe;
13816
+ isDisposed = false;
13817
+ constructor(host, roster, options = {}) {
13818
+ this.host = host;
13819
+ this.roster = roster;
13820
+ this.colorFor = options.colorFor;
13821
+ this.showLabels = options.showLabels ?? true;
13822
+ this.labelFont = options.labelFont ?? DEFAULT_LABEL_FONT;
13823
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
13824
+ this.unsubscribe = roster.onChange(() => {
13825
+ if (this.labelWidths.size > MAX_LABEL_WIDTH_CACHE) this.labelWidths.clear();
13826
+ host.requestRender();
13827
+ });
13828
+ }
13829
+ get disposed() {
13830
+ return this.isDisposed;
13831
+ }
13832
+ resolveColor(peer) {
13833
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
13834
+ }
13835
+ dispose() {
13836
+ if (this.isDisposed) return;
13837
+ this.isDisposed = true;
13838
+ this.unsubscribe?.();
13839
+ this.unsubscribe = null;
13840
+ this.unregister?.();
13841
+ this.unregister = null;
13842
+ this.labelWidths.clear();
13843
+ this.host.requestRender();
13844
+ }
13845
+ render(ctx) {
13846
+ if (this.isDisposed) return;
13847
+ const zoom = this.host.camera.zoom;
13848
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
13849
+ for (const peer of this.roster.getPeers()) {
13850
+ if (peer.cursor === null) continue;
13851
+ const color = this.resolveColor(peer);
13852
+ ctx.save();
13853
+ ctx.translate(peer.cursor.x, peer.cursor.y);
13854
+ ctx.scale(inv, inv);
13855
+ ctx.beginPath();
13856
+ ctx.moveTo(0, 0);
13857
+ ctx.lineTo(0, 16);
13858
+ ctx.lineTo(4.5, 12.5);
13859
+ ctx.lineTo(11, 12.5);
13860
+ ctx.closePath();
13861
+ ctx.fillStyle = color;
13862
+ ctx.fill();
13863
+ ctx.strokeStyle = "#ffffff";
13864
+ ctx.lineWidth = 1;
13865
+ ctx.stroke();
13866
+ if (this.showLabels && peer.name !== void 0 && peer.name.length > 0) {
13867
+ this.drawLabel(ctx, peer.name, color);
13868
+ }
13869
+ ctx.restore();
13870
+ }
13871
+ }
13872
+ drawLabel(ctx, name, color) {
13873
+ ctx.font = this.labelFont;
13874
+ const key = `${this.labelFont} ${name}`;
13875
+ let width = this.labelWidths.get(key);
13876
+ if (width === void 0) {
13877
+ width = ctx.measureText(name).width;
13878
+ this.labelWidths.set(key, width);
13879
+ }
13880
+ const w = width + LABEL_PAD_X * 2;
13881
+ ctx.fillStyle = color;
13882
+ ctx.beginPath();
13883
+ ctx.roundRect(LABEL_OFFSET, LABEL_OFFSET, w, LABEL_HEIGHT + LABEL_PAD_Y, 4);
13884
+ ctx.fill();
13885
+ ctx.fillStyle = "#ffffff";
13886
+ ctx.textAlign = "left";
13887
+ ctx.textBaseline = "middle";
13888
+ ctx.fillText(name, LABEL_OFFSET + LABEL_PAD_X, LABEL_OFFSET + (LABEL_HEIGHT + LABEL_PAD_Y) / 2);
13889
+ }
13890
+ };
13891
+
13892
+ // src/canvas/remote-selection-overlay.ts
13893
+ var DEFAULT_ALPHA = 0.6;
13894
+ var DEFAULT_LINE_WIDTH_PX = 2;
13895
+ var RemoteSelectionOverlay = class {
13896
+ host;
13897
+ roster;
13898
+ colorFor;
13899
+ alpha;
13900
+ lineWidthPx;
13901
+ signatures = [];
13902
+ outlines = [];
13903
+ storeDirty = true;
13904
+ unregister;
13905
+ unsubscribers = [];
13906
+ isDisposed = false;
13907
+ constructor(host, roster, options = {}) {
13908
+ this.host = host;
13909
+ this.roster = roster;
13910
+ this.colorFor = options.colorFor;
13911
+ this.alpha = options.alpha ?? DEFAULT_ALPHA;
13912
+ this.lineWidthPx = options.lineWidthPx ?? DEFAULT_LINE_WIDTH_PX;
13913
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
13914
+ const invalidate = () => {
13915
+ this.storeDirty = true;
13916
+ host.requestRender();
13917
+ };
13918
+ this.unsubscribers.push(
13919
+ roster.onChange(() => host.requestRender()),
13920
+ host.store.onChange(invalidate),
13921
+ host.layerManager.on("change", invalidate)
13922
+ );
13923
+ }
13924
+ get disposed() {
13925
+ return this.isDisposed;
13926
+ }
13927
+ dispose() {
13928
+ if (this.isDisposed) return;
13929
+ this.isDisposed = true;
13930
+ for (const unsub of this.unsubscribers) unsub();
13931
+ this.unsubscribers.length = 0;
13932
+ this.unregister?.();
13933
+ this.unregister = null;
13934
+ this.signatures = [];
13935
+ this.outlines = [];
13936
+ this.host.requestRender();
13937
+ }
13938
+ resolveColor(peer) {
13939
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
13940
+ }
13941
+ /** Recomputes outlines only when the selection signature or the store/layers changed. */
13942
+ rebuild() {
13943
+ const peers = this.roster.getPeers();
13944
+ const next = [];
13945
+ for (const peer of peers) {
13946
+ if (peer.selection.length === 0) continue;
13947
+ next.push({ from: peer.from, selection: peer.selection, color: this.resolveColor(peer) });
13948
+ }
13949
+ let changed = this.storeDirty || next.length !== this.signatures.length;
13950
+ if (!changed) {
13951
+ for (let i = 0; i < next.length; i++) {
13952
+ const a = next[i];
13953
+ const b = this.signatures[i];
13954
+ if (!a || !b || a.from !== b.from || a.selection !== b.selection || a.color !== b.color) {
13955
+ changed = true;
13956
+ break;
13957
+ }
13958
+ }
13959
+ }
13960
+ if (!changed) return;
13961
+ this.storeDirty = false;
13962
+ this.signatures = next;
13963
+ if (next.length === 0) {
13964
+ this.outlines = [];
13965
+ return;
13966
+ }
13967
+ const colorById = /* @__PURE__ */ new Map();
13968
+ for (const sig of next) {
13969
+ for (const id of sig.selection) if (!colorById.has(id)) colorById.set(id, sig.color);
13970
+ }
13971
+ const layers = this.host.layerManager;
13972
+ const rects = computeElementRects(
13973
+ this.host.store,
13974
+ (element) => colorById.has(element.id) && layers.isLayerVisible(element.layerId) ? element.id : null
13975
+ );
13976
+ this.outlines = rects.map((rect) => ({ rect, color: colorById.get(rect.id) ?? "#2563eb" }));
13977
+ }
13978
+ render(ctx) {
13979
+ if (this.isDisposed) return;
13980
+ this.rebuild();
13981
+ if (this.outlines.length === 0) return;
13982
+ const zoom = this.host.camera.zoom;
13983
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
13984
+ ctx.save();
13985
+ ctx.globalAlpha = this.alpha;
13986
+ ctx.lineWidth = this.lineWidthPx * inv;
13987
+ for (const { rect, color } of this.outlines) {
13988
+ ctx.save();
13989
+ ctx.strokeStyle = color;
13990
+ ctx.translate(rect.x + rect.w / 2, rect.y + rect.h / 2);
13991
+ if (rect.rotation !== 0) ctx.rotate(rect.rotation);
13992
+ ctx.strokeRect(-rect.w / 2, -rect.h / 2, rect.w, rect.h);
13993
+ ctx.restore();
13994
+ }
13995
+ ctx.restore();
13996
+ }
13997
+ };
13998
+
13999
+ // src/canvas/attach-awareness.ts
14000
+ function attachAwareness(viewport, channel, options) {
14001
+ const {
14002
+ roster: rosterOptions,
14003
+ cursors: cursorOptions,
14004
+ selections: selectionOptions,
14005
+ publish,
14006
+ ...localOptions
14007
+ } = options;
14008
+ const roster = new PeerRoster(rosterOptions);
14009
+ let local = null;
14010
+ let cursors = null;
14011
+ let selections = null;
14012
+ const unsubscribers = [];
14013
+ try {
14014
+ local = publish === false ? null : new LocalAwareness(viewport, {
14015
+ ...localOptions,
14016
+ send: (data) => channel.sendPresence(data)
14017
+ });
14018
+ cursors = cursorOptions === false ? null : new RemoteCursorOverlay(viewport, roster, cursorOptions ?? {});
14019
+ selections = selectionOptions === void 0 || selectionOptions === false ? null : new RemoteSelectionOverlay(
14020
+ viewport,
14021
+ roster,
14022
+ selectionOptions === true ? {} : selectionOptions
14023
+ );
14024
+ if (publish !== false) unsubscribers.push(roster.onDiscover(() => local?.announce()));
14025
+ unsubscribers.push(
14026
+ channel.onPresence((from, data) => {
14027
+ roster.apply(from, data);
14028
+ })
14029
+ );
14030
+ unsubscribers.push(channel.onPresenceLeave((from) => roster.remove(from)));
14031
+ } catch (error) {
14032
+ for (let i = unsubscribers.length - 1; i >= 0; i--) {
14033
+ try {
14034
+ unsubscribers[i]?.();
14035
+ } catch {
14036
+ }
14037
+ }
14038
+ unsubscribers.length = 0;
14039
+ try {
14040
+ selections?.dispose();
14041
+ } catch {
14042
+ }
14043
+ try {
14044
+ cursors?.dispose();
14045
+ } catch {
14046
+ }
14047
+ try {
14048
+ local?.dispose();
14049
+ } catch {
14050
+ }
14051
+ try {
14052
+ roster.dispose();
14053
+ } catch {
14054
+ }
14055
+ throw error;
14056
+ }
14057
+ let disposed = false;
14058
+ return {
14059
+ roster,
14060
+ local,
14061
+ cursors,
14062
+ selections,
14063
+ announce: () => local?.announce(),
14064
+ setFields: (fields) => local?.setFields(fields),
14065
+ dispose: () => {
14066
+ if (disposed) return;
14067
+ disposed = true;
14068
+ try {
14069
+ local?.dispose();
14070
+ } catch {
14071
+ }
14072
+ try {
14073
+ cursors?.dispose();
14074
+ } catch {
14075
+ }
14076
+ try {
14077
+ selections?.dispose();
14078
+ } catch {
14079
+ }
14080
+ try {
14081
+ roster.dispose();
14082
+ } catch {
14083
+ }
14084
+ for (const unsub of unsubscribers) {
14085
+ try {
14086
+ unsub();
14087
+ } catch {
14088
+ }
14089
+ }
14090
+ unsubscribers.length = 0;
14091
+ }
14092
+ };
14093
+ }
14094
+
14095
+ // src/tools/hand-tool.ts
14096
+ var HandTool = class {
14097
+ name = "hand";
14098
+ panning = false;
14099
+ lastScreen = { x: 0, y: 0 };
14100
+ onActivate(ctx) {
14101
+ ctx.setCursor?.("grab");
14102
+ }
14103
+ onDeactivate(ctx) {
14104
+ ctx.setCursor?.("default");
14105
+ }
14106
+ onPointerDown(state, ctx) {
14107
+ this.panning = true;
14108
+ this.lastScreen = { x: state.x, y: state.y };
14109
+ ctx.setCursor?.("grabbing");
14110
+ }
14111
+ onPointerMove(state, ctx) {
14112
+ if (!this.panning) return;
14113
+ const dx = state.x - this.lastScreen.x;
14114
+ const dy = state.y - this.lastScreen.y;
12251
14115
  this.lastScreen = { x: state.x, y: state.y };
12252
14116
  ctx.camera.pan(dx, dy);
12253
14117
  }
@@ -13893,7 +15757,7 @@ var MeasureTool = class {
13893
15757
  // src/tools/path-tool.ts
13894
15758
  var EPS2 = 1e-6;
13895
15759
  var DEFAULT_COMMIT_TAP_RADIUS_PX = 12;
13896
- function samePoint2(a, b) {
15760
+ function samePoint3(a, b) {
13897
15761
  return Math.abs(a.x - b.x) < EPS2 && Math.abs(a.y - b.y) < EPS2;
13898
15762
  }
13899
15763
  var PathTool = class {
@@ -14037,7 +15901,7 @@ var PathTool = class {
14037
15901
  return;
14038
15902
  }
14039
15903
  const last = this.lastWaypoint();
14040
- if (this.cursor && last && !samePoint2(this.cursor, last)) {
15904
+ if (this.cursor && last && !samePoint3(this.cursor, last)) {
14041
15905
  this.waypoints.push({ ...this.cursor });
14042
15906
  }
14043
15907
  this.scheduleEmission();
@@ -14098,7 +15962,7 @@ var PathTool = class {
14098
15962
  * world point.
14099
15963
  */
14100
15964
  withinCommitRadius(point, last, ctx) {
14101
- if (samePoint2(point, last)) return true;
15965
+ if (samePoint3(point, last)) return true;
14102
15966
  if (this.hasSnappingGrid()) return false;
14103
15967
  const zoom = ctx.camera.zoom;
14104
15968
  if (!(zoom > 0)) return false;
@@ -14146,7 +16010,7 @@ var PathTool = class {
14146
16010
  measure(cursor) {
14147
16011
  const points = this.waypoints.map((p) => ({ ...p }));
14148
16012
  const last = points[points.length - 1];
14149
- if (cursor && (!last || !samePoint2(cursor, last))) points.push({ ...cursor });
16013
+ if (cursor && (!last || !samePoint3(cursor, last))) points.push({ ...cursor });
14150
16014
  const { total, cumulative } = pathDistanceCells(points, {
14151
16015
  gridSize: this.gridSize,
14152
16016
  gridType: this.gridType,
@@ -14793,10 +16657,192 @@ var PingTool = class {
14793
16657
  }
14794
16658
  };
14795
16659
 
16660
+ // src/tools/fog-tool.ts
16661
+ var DEFAULT_RADIUS5 = 40;
16662
+ var MIN_POINT_DISTANCE = 4;
16663
+ var FogTool = class {
16664
+ name = "fog";
16665
+ drawing = false;
16666
+ points = [];
16667
+ startPoint = null;
16668
+ operation;
16669
+ shape;
16670
+ radius;
16671
+ manager;
16672
+ optionListeners = /* @__PURE__ */ new Set();
16673
+ constructor(manager, options = {}) {
16674
+ this.manager = manager;
16675
+ this.operation = options.operation ?? "reveal";
16676
+ this.shape = options.shape ?? "brush";
16677
+ this.radius = options.radius ?? DEFAULT_RADIUS5;
16678
+ }
16679
+ onActivate(ctx) {
16680
+ ctx.setCursor?.("crosshair");
16681
+ }
16682
+ onDeactivate(ctx) {
16683
+ this.cancelGesture(ctx);
16684
+ ctx.setCursor?.("default");
16685
+ }
16686
+ getOptions() {
16687
+ return {
16688
+ operation: this.operation,
16689
+ shape: this.shape,
16690
+ radius: this.radius
16691
+ };
16692
+ }
16693
+ setOptions(options) {
16694
+ if (options.operation !== void 0) this.operation = options.operation;
16695
+ if (options.shape !== void 0) this.shape = options.shape;
16696
+ if (options.radius !== void 0 && Number.isFinite(options.radius) && options.radius > 0) {
16697
+ this.radius = options.radius;
16698
+ }
16699
+ for (const listener of this.optionListeners) listener();
16700
+ }
16701
+ onOptionsChange(listener) {
16702
+ this.optionListeners.add(listener);
16703
+ return () => this.optionListeners.delete(listener);
16704
+ }
16705
+ onPointerDown(state, ctx) {
16706
+ if (this.drawing) return;
16707
+ this.drawing = true;
16708
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
16709
+ this.startPoint = world;
16710
+ this.points = [world];
16711
+ ctx.requestRender();
16712
+ }
16713
+ onPointerMove(state, ctx) {
16714
+ if (!this.drawing) return;
16715
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
16716
+ if (this.shape === "rectangle") {
16717
+ if (this.startPoint) this.points = [this.startPoint, world];
16718
+ } else {
16719
+ const last = this.points[this.points.length - 1];
16720
+ if (last) {
16721
+ const dx = world.x - last.x;
16722
+ const dy = world.y - last.y;
16723
+ if (dx * dx + dy * dy < MIN_POINT_DISTANCE * MIN_POINT_DISTANCE) return;
16724
+ }
16725
+ this.points.push(world);
16726
+ }
16727
+ ctx.requestRender();
16728
+ }
16729
+ onPointerUp(_state, ctx) {
16730
+ if (!this.drawing) return;
16731
+ this.drawing = false;
16732
+ const region = this.buildRegion();
16733
+ if (region) {
16734
+ this.manager.applyRegion(region, this.operation);
16735
+ }
16736
+ this.points = [];
16737
+ this.startPoint = null;
16738
+ ctx.requestRender();
16739
+ }
16740
+ onPointerCancel(_state, ctx) {
16741
+ this.cancelGesture(ctx);
16742
+ }
16743
+ onKeyDown(event, ctx) {
16744
+ if (event.key === "Escape" && this.drawing) {
16745
+ this.cancelGesture(ctx);
16746
+ return true;
16747
+ }
16748
+ return false;
16749
+ }
16750
+ renderOverlay(ctx) {
16751
+ if (!this.drawing || this.points.length === 0) return;
16752
+ ctx.save();
16753
+ ctx.strokeStyle = this.operation === "reveal" ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.4)";
16754
+ ctx.fillStyle = this.operation === "reveal" ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.1)";
16755
+ ctx.lineWidth = 2;
16756
+ ctx.setLineDash([6, 4]);
16757
+ switch (this.shape) {
16758
+ case "brush":
16759
+ this.renderBrushPreview(ctx);
16760
+ break;
16761
+ case "rectangle":
16762
+ this.renderRectanglePreview(ctx);
16763
+ break;
16764
+ case "polygon":
16765
+ this.renderPolygonPreview(ctx);
16766
+ break;
16767
+ }
16768
+ ctx.restore();
16769
+ }
16770
+ buildRegion() {
16771
+ switch (this.shape) {
16772
+ case "brush": {
16773
+ if (this.points.length === 0) return null;
16774
+ return { kind: "brush", points: this.points, radius: this.radius };
16775
+ }
16776
+ case "rectangle": {
16777
+ if (!this.startPoint || this.points.length < 2) return null;
16778
+ const end = this.points[this.points.length - 1];
16779
+ if (this.startPoint.x === end.x && this.startPoint.y === end.y) return null;
16780
+ return { kind: "rectangle", from: this.startPoint, to: end };
16781
+ }
16782
+ case "polygon": {
16783
+ if (this.points.length < 3) return null;
16784
+ return { kind: "polygon", points: this.points };
16785
+ }
16786
+ }
16787
+ }
16788
+ cancelGesture(ctx) {
16789
+ this.drawing = false;
16790
+ this.points = [];
16791
+ this.startPoint = null;
16792
+ ctx.requestRender();
16793
+ }
16794
+ renderBrushPreview(ctx) {
16795
+ if (this.points.length === 1) {
16796
+ const p = this.points[0];
16797
+ ctx.beginPath();
16798
+ ctx.arc(p.x, p.y, this.radius, 0, Math.PI * 2);
16799
+ ctx.fill();
16800
+ ctx.stroke();
16801
+ return;
16802
+ }
16803
+ ctx.beginPath();
16804
+ for (let i = 0; i < this.points.length; i++) {
16805
+ const p = this.points[i];
16806
+ if (i === 0) ctx.moveTo(p.x, p.y);
16807
+ else ctx.lineTo(p.x, p.y);
16808
+ }
16809
+ ctx.lineWidth = this.radius * 2;
16810
+ ctx.lineCap = "round";
16811
+ ctx.lineJoin = "round";
16812
+ ctx.stroke();
16813
+ }
16814
+ renderRectanglePreview(ctx) {
16815
+ if (this.points.length < 2) return;
16816
+ const from = this.points[0];
16817
+ const to = this.points[this.points.length - 1];
16818
+ const x = Math.min(from.x, to.x);
16819
+ const y = Math.min(from.y, to.y);
16820
+ const w = Math.abs(to.x - from.x);
16821
+ const h = Math.abs(to.y - from.y);
16822
+ ctx.fillRect(x, y, w, h);
16823
+ ctx.strokeRect(x, y, w, h);
16824
+ }
16825
+ renderPolygonPreview(ctx) {
16826
+ if (this.points.length < 2) return;
16827
+ const first = this.points[0];
16828
+ ctx.beginPath();
16829
+ ctx.moveTo(first.x, first.y);
16830
+ for (let i = 1; i < this.points.length; i++) {
16831
+ const p = this.points[i];
16832
+ ctx.lineTo(p.x, p.y);
16833
+ }
16834
+ ctx.closePath();
16835
+ ctx.fill();
16836
+ ctx.stroke();
16837
+ }
16838
+ };
16839
+
14796
16840
  // src/index.ts
14797
- var VERSION = "0.64.0";
16841
+ var VERSION = "0.66.0";
14798
16842
  // Annotate the CommonJS export names for ESM import in node:
14799
16843
  0 && (module.exports = {
16844
+ AWARENESS_MAX_SELECTION,
16845
+ AWARENESS_PRESENCE_KIND,
14800
16846
  ArrowTool,
14801
16847
  AutoSave,
14802
16848
  Camera,
@@ -14806,6 +16852,12 @@ var VERSION = "0.64.0";
14806
16852
  ElementStore,
14807
16853
  EraserTool,
14808
16854
  FOCUS_PRESENCE_KIND,
16855
+ FOG_MAX_TILES,
16856
+ FOG_STATE_VERSION,
16857
+ FOG_TILE_CELLS,
16858
+ FogManager,
16859
+ FogRenderer,
16860
+ FogTool,
14809
16861
  HandTool,
14810
16862
  HistoryStack,
14811
16863
  HtmlPainterMissingError,
@@ -14815,6 +16867,7 @@ var VERSION = "0.64.0";
14815
16867
  LASER_TRAIL_PRESENCE_KIND,
14816
16868
  LaserTool,
14817
16869
  LayerManager,
16870
+ LocalAwareness,
14818
16871
  LocalStorageAdapter,
14819
16872
  MEASURE_PRESENCE_KIND,
14820
16873
  MeasureTool,
@@ -14823,16 +16876,20 @@ var VERSION = "0.64.0";
14823
16876
  NoteTool,
14824
16877
  PATH_PRESENCE_KIND,
14825
16878
  PATH_PRESENCE_MAX_POINTS,
16879
+ PEER_COLORS,
14826
16880
  PING_PRESENCE_KIND,
14827
16881
  PathTool,
16882
+ PeerRoster,
14828
16883
  PencilTool,
14829
16884
  PingInput,
14830
16885
  PingTool,
16886
+ RemoteCursorOverlay,
14831
16887
  RemoteFocusReceiver,
14832
16888
  RemoteLaserOverlay,
14833
16889
  RemoteMeasureOverlay,
14834
16890
  RemotePathOverlay,
14835
16891
  RemotePingOverlay,
16892
+ RemoteSelectionOverlay,
14836
16893
  SelectTool,
14837
16894
  ShapeTool,
14838
16895
  TemplateTool,
@@ -14841,8 +16898,10 @@ var VERSION = "0.64.0";
14841
16898
  VERSION,
14842
16899
  Viewport,
14843
16900
  applyCameraView,
16901
+ attachAwareness,
14844
16902
  boundsIntersect,
14845
16903
  cameraOriginForView,
16904
+ canonicalizeFogTile,
14846
16905
  captureCameraView,
14847
16906
  computeElementRects,
14848
16907
  createArrow,
@@ -14854,11 +16913,14 @@ var VERSION = "0.64.0";
14854
16913
  createStroke,
14855
16914
  createTemplate,
14856
16915
  createText,
16916
+ defaultPeerColor,
14857
16917
  drawHexPath,
14858
16918
  elementRectsEqual,
14859
16919
  exportImage,
14860
16920
  exportSvg,
14861
16921
  fitZoomForView,
16922
+ fogDecodeBase64,
16923
+ fogEncodeBase64,
14862
16924
  footprintFromSize,
14863
16925
  getActiveFormats,
14864
16926
  getArrowBounds,
@@ -14876,6 +16938,7 @@ var VERSION = "0.64.0";
14876
16938
  getHexCellsInSquare,
14877
16939
  getHexDistance,
14878
16940
  gridDistanceCells,
16941
+ isAwarenessPresence,
14879
16942
  isFocusPresence,
14880
16943
  isLaserTrailPresence,
14881
16944
  isMeasurePresence,
@@ -14883,6 +16946,7 @@ var VERSION = "0.64.0";
14883
16946
  isPathPresence,
14884
16947
  isPingPresence,
14885
16948
  pathDistanceCells,
16949
+ recommendedFogCellSize,
14886
16950
  resolveHtmlRouting,
14887
16951
  setFontSize,
14888
16952
  smartSnap,
@@ -14899,6 +16963,9 @@ var VERSION = "0.64.0";
14899
16963
  toggleBold,
14900
16964
  toggleItalic,
14901
16965
  toggleStrikethrough,
14902
- toggleUnderline
16966
+ toggleUnderline,
16967
+ validateFogDefinition,
16968
+ validateFogState,
16969
+ validateFogTile
14903
16970
  });
14904
16971
  //# sourceMappingURL=index.cjs.map