@fieldnotes/core 0.56.0 → 0.58.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
@@ -34,14 +34,17 @@ __export(index_exports, {
34
34
  LaserTool: () => LaserTool,
35
35
  LayerManager: () => LayerManager,
36
36
  LocalStorageAdapter: () => LocalStorageAdapter,
37
+ MEASURE_PRESENCE_KIND: () => MEASURE_PRESENCE_KIND,
37
38
  MeasureTool: () => MeasureTool,
38
39
  MemoryAdapter: () => MemoryAdapter,
40
+ MinimapController: () => MinimapController,
39
41
  NoteTool: () => NoteTool,
40
42
  PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
41
43
  PencilTool: () => PencilTool,
42
44
  PingInput: () => PingInput,
43
45
  PingTool: () => PingTool,
44
46
  RemoteLaserOverlay: () => RemoteLaserOverlay,
47
+ RemoteMeasureOverlay: () => RemoteMeasureOverlay,
45
48
  RemotePingOverlay: () => RemotePingOverlay,
46
49
  SelectTool: () => SelectTool,
47
50
  ShapeTool: () => ShapeTool,
@@ -79,6 +82,7 @@ __export(index_exports, {
79
82
  getHexCellsInSquare: () => getHexCellsInSquare,
80
83
  getHexDistance: () => getHexDistance,
81
84
  isLaserTrailPresence: () => isLaserTrailPresence,
85
+ isMeasurePresence: () => isMeasurePresence,
82
86
  isNearBezier: () => isNearBezier,
83
87
  isPingPresence: () => isPingPresence,
84
88
  setFontSize: () => setFontSize,
@@ -87,6 +91,7 @@ __export(index_exports, {
87
91
  snapToHexCenter: () => snapToHexCenter,
88
92
  styleToPatch: () => styleToPatch,
89
93
  toLaserTrailPresence: () => toLaserTrailPresence,
94
+ toMeasurePresence: () => toMeasurePresence,
90
95
  toPingPresence: () => toPingPresence,
91
96
  toggleBold: () => toggleBold,
92
97
  toggleItalic: () => toggleItalic,
@@ -4792,88 +4797,247 @@ function miniToWorld(t, p) {
4792
4797
  return { x: (p.x - t.offsetX) / t.scale, y: (p.y - t.offsetY) / t.scale };
4793
4798
  }
4794
4799
 
4795
- // src/canvas/minimap.ts
4796
- var WIDTH = 200;
4797
- var HEIGHT = 140;
4798
- var MARGIN = 16;
4799
- var PADDING = 8;
4800
+ // src/canvas/minimap-controller.ts
4801
+ var DEFAULT_WIDTH = 200;
4802
+ var DEFAULT_HEIGHT = 140;
4803
+ var DEFAULT_PADDING = 8;
4804
+ var DEFAULT_DEBOUNCE_MS2 = 200;
4805
+ var DEFAULT_VIEWPORT_STROKE = "#3b82f6";
4800
4806
  var NEUTRAL = "rgba(100,116,139,0.6)";
4801
- var VIEWPORT_STROKE = "#3b82f6";
4807
+ var DOM_FALLBACK_TYPES = /* @__PURE__ */ new Set(["note", "text", "html"]);
4802
4808
  function elementColor(el) {
4803
4809
  return "color" in el && typeof el.color === "string" ? el.color : NEUTRAL;
4804
4810
  }
4805
- var Minimap = class {
4806
- constructor(deps) {
4807
- this.deps = deps;
4808
- const canvas = document.createElement("canvas");
4809
- canvas.width = WIDTH;
4810
- canvas.height = HEIGHT;
4811
- Object.assign(canvas.style, {
4812
- position: "absolute",
4813
- right: `${MARGIN}px`,
4814
- bottom: `${MARGIN}px`,
4815
- width: `${WIDTH}px`,
4816
- height: `${HEIGHT}px`,
4817
- background: "rgba(255,255,255,0.85)",
4818
- border: "1px solid rgba(0,0,0,0.15)",
4819
- borderRadius: "4px",
4820
- touchAction: "none",
4821
- cursor: "pointer",
4822
- zIndex: "10"
4823
- });
4824
- canvas.addEventListener("pointerdown", this.onPointerDown);
4825
- canvas.addEventListener("pointermove", this.onPointerMove);
4826
- canvas.addEventListener("pointerup", this.onPointerUp);
4827
- this.deps.container.appendChild(canvas);
4811
+ function sameBounds(a, b) {
4812
+ return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
4813
+ }
4814
+ var MinimapController = class {
4815
+ constructor(viewport, canvas, options = {}) {
4816
+ this.viewport = viewport;
4828
4817
  this.canvas = canvas;
4818
+ this.width = options.width ?? DEFAULT_WIDTH;
4819
+ this.height = options.height ?? DEFAULT_HEIGHT;
4820
+ this.padding = options.padding ?? DEFAULT_PADDING;
4821
+ this.background = options.background ?? null;
4822
+ this.viewportStroke = options.viewportStroke ?? DEFAULT_VIEWPORT_STROKE;
4823
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS2;
4824
+ this.interactive = options.interactive !== false;
4825
+ this.requestFrame = options.requestFrame ?? ((cb) => typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame(cb) : 0);
4826
+ this.cancelFrame = options.cancelFrame ?? ((id) => {
4827
+ if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(id);
4828
+ });
4829
+ this.renderer.setStore(viewport.store);
4830
+ this.renderer.setOnImageLoad(() => this.markSceneDirty());
4831
+ this.applyCanvasSize();
4832
+ const onScene = () => this.markSceneDirty();
4833
+ this.unsubs.push(
4834
+ viewport.store.on("add", onScene),
4835
+ viewport.store.on("remove", onScene),
4836
+ viewport.store.on("update", onScene),
4837
+ viewport.store.on("clear", onScene),
4838
+ viewport.layerManager.on("change", onScene),
4839
+ viewport.camera.onChange(() => this.onViewChanged()),
4840
+ viewport.onResize(() => this.onViewChanged())
4841
+ );
4842
+ if (this.interactive) {
4843
+ canvas.style.touchAction = "none";
4844
+ canvas.style.cursor = "pointer";
4845
+ canvas.addEventListener("pointerdown", this.onPointerDown);
4846
+ canvas.addEventListener("pointermove", this.onPointerMove);
4847
+ canvas.addEventListener("pointerup", this.onPointerUp);
4848
+ canvas.addEventListener("pointercancel", this.onPointerEnd);
4849
+ canvas.addEventListener("lostpointercapture", this.onPointerEnd);
4850
+ }
4851
+ this.renderScene();
4852
+ this.requestDraw();
4829
4853
  }
4830
- canvas;
4831
- rafId = null;
4854
+ width;
4855
+ height;
4856
+ padding;
4857
+ background;
4858
+ viewportStroke;
4859
+ debounceMs;
4860
+ interactive;
4861
+ requestFrame;
4862
+ cancelFrame;
4863
+ renderer = new ElementRenderer();
4864
+ scene = null;
4865
+ frameId = null;
4866
+ debounceTimer = null;
4832
4867
  dragging = false;
4833
- scheduleDraw() {
4834
- if (this.rafId !== null) return;
4835
- this.rafId = this.deps.requestFrame(this.draw);
4868
+ disposed = false;
4869
+ unsubs = [];
4870
+ setSize(width, height) {
4871
+ if (this.disposed) return;
4872
+ this.width = width;
4873
+ this.height = height;
4874
+ this.applyCanvasSize();
4875
+ this.clearDebounce();
4876
+ this.renderScene();
4877
+ this.requestDraw();
4836
4878
  }
4837
- destroy() {
4838
- if (this.rafId !== null) {
4839
- this.deps.cancelFrame(this.rafId);
4840
- this.rafId = null;
4879
+ requestDraw() {
4880
+ if (this.disposed || this.frameId !== null) return;
4881
+ this.frameId = this.requestFrame(this.draw);
4882
+ }
4883
+ dispose() {
4884
+ if (this.disposed) return;
4885
+ this.disposed = true;
4886
+ this.clearDebounce();
4887
+ if (this.frameId !== null) {
4888
+ this.cancelFrame(this.frameId);
4889
+ this.frameId = null;
4890
+ }
4891
+ for (const unsub of this.unsubs.splice(0)) unsub();
4892
+ if (this.interactive) {
4893
+ this.canvas.removeEventListener("pointerdown", this.onPointerDown);
4894
+ this.canvas.removeEventListener("pointermove", this.onPointerMove);
4895
+ this.canvas.removeEventListener("pointerup", this.onPointerUp);
4896
+ this.canvas.removeEventListener("pointercancel", this.onPointerEnd);
4897
+ this.canvas.removeEventListener("lostpointercapture", this.onPointerEnd);
4898
+ }
4899
+ }
4900
+ clearDebounce() {
4901
+ if (this.debounceTimer !== null) {
4902
+ clearTimeout(this.debounceTimer);
4903
+ this.debounceTimer = null;
4904
+ }
4905
+ }
4906
+ dpr() {
4907
+ return typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4908
+ }
4909
+ applyCanvasSize() {
4910
+ const dpr = this.dpr();
4911
+ this.canvas.width = Math.max(1, Math.round(this.width * dpr));
4912
+ this.canvas.height = Math.max(1, Math.round(this.height * dpr));
4913
+ }
4914
+ // Single source for both mapping bounds and rendering: layer-visible,
4915
+ // grids excluded. (getElementBounds already returns null for grids, so they
4916
+ // cannot extend the bounding box today — this filter makes the invariant
4917
+ // structural instead of relying on that special case.)
4918
+ sceneElements() {
4919
+ return this.viewport.store.getAll().filter((el) => el.type !== "grid" && this.viewport.layerManager.isLayerVisible(el.layerId));
4920
+ }
4921
+ currentMapping() {
4922
+ const viewportRect = this.viewport.getVisibleRect();
4923
+ const content = getElementsBoundingBox(this.sceneElements());
4924
+ return content ? unionBounds(content, viewportRect) : viewportRect;
4925
+ }
4926
+ onViewChanged() {
4927
+ if (this.disposed) return;
4928
+ const mapping = this.currentMapping();
4929
+ if (!this.scene || !sameBounds(mapping, this.scene.mapping)) {
4930
+ this.markSceneDirty();
4841
4931
  }
4842
- this.canvas.removeEventListener("pointerdown", this.onPointerDown);
4843
- this.canvas.removeEventListener("pointermove", this.onPointerMove);
4844
- this.canvas.removeEventListener("pointerup", this.onPointerUp);
4845
- this.canvas.remove();
4932
+ this.requestDraw();
4846
4933
  }
4847
- currentTransform() {
4848
- const viewport = this.deps.getViewportRect();
4849
- const content = this.deps.getContentBounds();
4850
- const mapping = content ? unionBounds(content, viewport) : viewport;
4851
- return computeMinimapTransform(mapping, WIDTH, HEIGHT, PADDING);
4934
+ markSceneDirty() {
4935
+ if (this.disposed) return;
4936
+ this.clearDebounce();
4937
+ this.debounceTimer = setTimeout(() => {
4938
+ this.debounceTimer = null;
4939
+ this.renderScene();
4940
+ this.requestDraw();
4941
+ }, this.debounceMs);
4942
+ }
4943
+ renderScene() {
4944
+ if (this.disposed) return;
4945
+ const dpr = this.dpr();
4946
+ const mapping = this.currentMapping();
4947
+ const transform = computeMinimapTransform(mapping, this.width, this.height, this.padding);
4948
+ const sceneCanvas = document.createElement("canvas");
4949
+ sceneCanvas.width = Math.max(1, Math.round(this.width * dpr));
4950
+ sceneCanvas.height = Math.max(1, Math.round(this.height * dpr));
4951
+ const ctx = sceneCanvas.getContext("2d");
4952
+ if (!ctx) return;
4953
+ const byLayer = /* @__PURE__ */ new Map();
4954
+ for (const el of this.sceneElements()) {
4955
+ let list = byLayer.get(el.layerId);
4956
+ if (!list) {
4957
+ list = [];
4958
+ byLayer.set(el.layerId, list);
4959
+ }
4960
+ list.push(el);
4961
+ }
4962
+ for (const layer of this.viewport.layerManager.getLayers()) {
4963
+ const els = byLayer.get(layer.id);
4964
+ if (!els || els.length === 0) continue;
4965
+ const opacity = layer.opacity;
4966
+ if (opacity >= 1) {
4967
+ this.renderLayerElements(ctx, els, transform, dpr);
4968
+ continue;
4969
+ }
4970
+ const layerCanvas = document.createElement("canvas");
4971
+ layerCanvas.width = sceneCanvas.width;
4972
+ layerCanvas.height = sceneCanvas.height;
4973
+ const layerCtx = layerCanvas.getContext("2d");
4974
+ if (!layerCtx) continue;
4975
+ this.renderLayerElements(layerCtx, els, transform, dpr);
4976
+ ctx.save();
4977
+ ctx.globalAlpha = opacity;
4978
+ ctx.drawImage(layerCanvas, 0, 0);
4979
+ ctx.restore();
4980
+ }
4981
+ this.scene = { canvas: sceneCanvas, transform, mapping };
4982
+ }
4983
+ renderLayerElements(ctx, elements, t, dpr) {
4984
+ for (const el of elements) {
4985
+ if (DOM_FALLBACK_TYPES.has(el.type)) {
4986
+ const b = getElementBounds(el);
4987
+ if (!b) continue;
4988
+ const tl = worldToMini(t, { x: b.x, y: b.y });
4989
+ ctx.save();
4990
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
4991
+ ctx.fillStyle = elementColor(el);
4992
+ ctx.fillRect(tl.x, tl.y, Math.max(1, b.w * t.scale), Math.max(1, b.h * t.scale));
4993
+ ctx.restore();
4994
+ continue;
4995
+ }
4996
+ ctx.save();
4997
+ ctx.setTransform(dpr * t.scale, 0, 0, dpr * t.scale, dpr * t.offsetX, dpr * t.offsetY);
4998
+ this.renderer.renderCanvasElement(ctx, el);
4999
+ ctx.restore();
5000
+ }
4852
5001
  }
4853
5002
  draw = () => {
4854
- this.rafId = null;
5003
+ this.frameId = null;
5004
+ if (this.disposed) return;
4855
5005
  const ctx = this.canvas.getContext("2d");
4856
5006
  if (!ctx) return;
4857
- const t = this.currentTransform();
4858
- const viewport = this.deps.getViewportRect();
4859
- ctx.clearRect(0, 0, WIDTH, HEIGHT);
4860
- for (const el of this.deps.getElements()) {
4861
- const b = getElementBounds(el);
4862
- if (!b) continue;
4863
- const tl = worldToMini(t, { x: b.x, y: b.y });
4864
- ctx.fillStyle = elementColor(el);
4865
- ctx.fillRect(tl.x, tl.y, Math.max(1, b.w * t.scale), Math.max(1, b.h * t.scale));
4866
- }
4867
- const vtl = worldToMini(t, { x: viewport.x, y: viewport.y });
4868
- ctx.strokeStyle = VIEWPORT_STROKE;
5007
+ const dpr = this.dpr();
5008
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
5009
+ ctx.clearRect(0, 0, this.width, this.height);
5010
+ if (this.background) {
5011
+ ctx.fillStyle = this.background;
5012
+ ctx.fillRect(0, 0, this.width, this.height);
5013
+ }
5014
+ const scene = this.scene;
5015
+ if (!scene) return;
5016
+ ctx.drawImage(scene.canvas, 0, 0, this.width, this.height);
5017
+ const viewportRect = this.viewport.getVisibleRect();
5018
+ const tl = worldToMini(scene.transform, { x: viewportRect.x, y: viewportRect.y });
5019
+ ctx.strokeStyle = this.viewportStroke;
4869
5020
  ctx.lineWidth = 1.5;
4870
- ctx.strokeRect(vtl.x, vtl.y, viewport.w * t.scale, viewport.h * t.scale);
5021
+ ctx.strokeRect(
5022
+ tl.x,
5023
+ tl.y,
5024
+ viewportRect.w * scene.transform.scale,
5025
+ viewportRect.h * scene.transform.scale
5026
+ );
4871
5027
  };
5028
+ navTransform() {
5029
+ return this.scene?.transform ?? computeMinimapTransform(this.currentMapping(), this.width, this.height, this.padding);
5030
+ }
4872
5031
  navigateFromEvent(e) {
4873
5032
  const rect = this.canvas.getBoundingClientRect();
4874
- const point = { x: e.clientX - rect.left, y: e.clientY - rect.top };
4875
- const world = miniToWorld(this.currentTransform(), point);
4876
- this.deps.navigateTo(world);
5033
+ const scaleX = rect.width > 0 ? this.width / rect.width : 1;
5034
+ const scaleY = rect.height > 0 ? this.height / rect.height : 1;
5035
+ const point = {
5036
+ x: (e.clientX - rect.left) * scaleX,
5037
+ y: (e.clientY - rect.top) * scaleY
5038
+ };
5039
+ const world = miniToWorld(this.navTransform(), point);
5040
+ this.viewport.centerCameraAt(world);
4877
5041
  }
4878
5042
  onPointerDown = (e) => {
4879
5043
  e.stopPropagation();
@@ -4894,6 +5058,45 @@ var Minimap = class {
4894
5058
  } catch {
4895
5059
  }
4896
5060
  };
5061
+ // pointercancel / lostpointercapture: a browser gesture or interrupted touch
5062
+ // can end the interaction without a pointerup — the drag must not stick.
5063
+ onPointerEnd = () => {
5064
+ this.dragging = false;
5065
+ };
5066
+ };
5067
+
5068
+ // src/canvas/minimap.ts
5069
+ var WIDTH = 200;
5070
+ var HEIGHT = 140;
5071
+ var MARGIN = 16;
5072
+ var Minimap = class {
5073
+ canvas;
5074
+ controller;
5075
+ constructor(container, viewport) {
5076
+ const canvas = document.createElement("canvas");
5077
+ Object.assign(canvas.style, {
5078
+ position: "absolute",
5079
+ right: `${MARGIN}px`,
5080
+ bottom: `${MARGIN}px`,
5081
+ width: `${WIDTH}px`,
5082
+ height: `${HEIGHT}px`,
5083
+ background: "rgba(255,255,255,0.85)",
5084
+ border: "1px solid rgba(0,0,0,0.15)",
5085
+ borderRadius: "4px",
5086
+ zIndex: "10"
5087
+ });
5088
+ canvas.dataset.fieldnotesMinimap = "true";
5089
+ container.appendChild(canvas);
5090
+ this.canvas = canvas;
5091
+ this.controller = new MinimapController(viewport, canvas, { width: WIDTH, height: HEIGHT });
5092
+ }
5093
+ scheduleDraw() {
5094
+ this.controller.requestDraw();
5095
+ }
5096
+ destroy() {
5097
+ this.controller.dispose();
5098
+ this.canvas.remove();
5099
+ }
4897
5100
  };
4898
5101
 
4899
5102
  // src/canvas/viewport-dom.ts
@@ -8075,25 +8278,7 @@ var Viewport = class {
8075
8278
  }
8076
8279
  this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
8077
8280
  if (options.minimap) {
8078
- const visibleEls = () => this.store.getAll().filter((el) => this.layerManager.isLayerVisible(el.layerId));
8079
- this.minimap = new Minimap({
8080
- container: this.wrapper,
8081
- getElements: visibleEls,
8082
- getContentBounds: () => getElementsBoundingBox(visibleEls()),
8083
- getViewportRect: () => this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight),
8084
- navigateTo: (w) => {
8085
- const z = this.camera.zoom;
8086
- this.camera.moveTo(
8087
- this.canvasEl.clientWidth / 2 - w.x * z,
8088
- this.canvasEl.clientHeight / 2 - w.y * z
8089
- );
8090
- },
8091
- requestFrame: (cb) => typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame(cb) : 0,
8092
- cancelFrame: (id) => {
8093
- if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(id);
8094
- }
8095
- });
8096
- this.minimap.scheduleDraw();
8281
+ this.minimap = new Minimap(this.wrapper, this);
8097
8282
  }
8098
8283
  this.domNodeManager = new DomNodeManager({
8099
8284
  domLayer: this.paintStack,
@@ -8129,7 +8314,6 @@ var Viewport = class {
8129
8314
  this.applyCameraTransform();
8130
8315
  this.noteEditor.updateToolbarPosition();
8131
8316
  this.contextMenu?.close();
8132
- this.minimap?.scheduleDraw();
8133
8317
  this.requestRender();
8134
8318
  });
8135
8319
  this.gridController = new GridController({
@@ -8144,7 +8328,6 @@ var Viewport = class {
8144
8328
  this.store.on("add", (el) => {
8145
8329
  if (el.type === "grid") this.gridController.syncContext();
8146
8330
  this.renderLoop.markLayerDirty(el.layerId);
8147
- this.minimap?.scheduleDraw();
8148
8331
  this.requestRender();
8149
8332
  }),
8150
8333
  this.store.on("remove", (el) => {
@@ -8152,7 +8335,6 @@ var Viewport = class {
8152
8335
  this.unbindArrowsFrom(el);
8153
8336
  this.domNodeManager.removeDomNode(el.id);
8154
8337
  this.renderLoop.markLayerDirty(el.layerId);
8155
- this.minimap?.scheduleDraw();
8156
8338
  this.requestRender();
8157
8339
  }),
8158
8340
  this.store.on("update", ({ previous, current }) => {
@@ -8161,21 +8343,18 @@ var Viewport = class {
8161
8343
  if (previous.layerId !== current.layerId) {
8162
8344
  this.renderLoop.markLayerDirty(previous.layerId);
8163
8345
  }
8164
- this.minimap?.scheduleDraw();
8165
8346
  this.requestRender();
8166
8347
  }),
8167
8348
  this.store.on("clear", () => {
8168
8349
  this.domNodeManager.clearDomNodes();
8169
8350
  this.renderLoop.markAllLayersDirty();
8170
8351
  this.gridController.syncContext();
8171
- this.minimap?.scheduleDraw();
8172
8352
  this.requestRender();
8173
8353
  })
8174
8354
  ];
8175
8355
  this.layerManager.on("change", () => {
8176
8356
  this.toolContext.activeLayerId = this.layerManager.activeLayerId;
8177
8357
  this.renderLoop.markAllLayersDirty();
8178
- this.minimap?.scheduleDraw();
8179
8358
  this.requestRender();
8180
8359
  });
8181
8360
  this.interactions = new ViewportInteractions({
@@ -8239,6 +8418,7 @@ var Viewport = class {
8239
8418
  contextMenu = null;
8240
8419
  minimap = null;
8241
8420
  htmlRenderers = /* @__PURE__ */ new Map();
8421
+ resizeListeners = /* @__PURE__ */ new Set();
8242
8422
  get ctx() {
8243
8423
  return this.canvasEl.getContext("2d");
8244
8424
  }
@@ -8263,6 +8443,27 @@ var Viewport = class {
8263
8443
  if (!bbox) return;
8264
8444
  this.camera.fitToContent(bbox, this.wrapper.clientWidth, this.wrapper.clientHeight, padding);
8265
8445
  }
8446
+ /** World-space rectangle currently visible through the canvas. */
8447
+ getVisibleRect() {
8448
+ return this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
8449
+ }
8450
+ /** Centers the camera on a world point without changing zoom. */
8451
+ centerCameraAt(world) {
8452
+ const z = this.camera.zoom;
8453
+ this.camera.moveTo(
8454
+ this.canvasEl.clientWidth / 2 - world.x * z,
8455
+ this.canvasEl.clientHeight / 2 - world.y * z
8456
+ );
8457
+ }
8458
+ /**
8459
+ * Notifies after the host container resizes (ResizeObserver-driven). A resize
8460
+ * changes the visible world rect without a camera event; overlays such as the
8461
+ * minimap subscribe to stay current. Returns an idempotent unsubscribe.
8462
+ */
8463
+ onResize(listener) {
8464
+ this.resizeListeners.add(listener);
8465
+ return () => this.resizeListeners.delete(listener);
8466
+ }
8266
8467
  requestRender() {
8267
8468
  this.renderLoop.requestRender();
8268
8469
  }
@@ -8567,6 +8768,7 @@ var Viewport = class {
8567
8768
  this.unsubStore.forEach((fn) => fn());
8568
8769
  this.resizeObserver?.disconnect();
8569
8770
  this.resizeObserver = null;
8771
+ this.resizeListeners.clear();
8570
8772
  this.wrapper.remove();
8571
8773
  }
8572
8774
  stopInteracting() {
@@ -8616,6 +8818,7 @@ var Viewport = class {
8616
8818
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
8617
8819
  this.renderLoop.setCanvasSize(rect.width * dpr, rect.height * dpr);
8618
8820
  this.requestRender();
8821
+ this.resizeListeners.forEach((fn) => fn());
8619
8822
  }
8620
8823
  observeResize() {
8621
8824
  if (typeof ResizeObserver === "undefined") return;
@@ -8655,7 +8858,7 @@ function toLaserTrailPresence(emission) {
8655
8858
  };
8656
8859
  }
8657
8860
  var DEFAULT_COLOR = "#ff3b30";
8658
- var DEFAULT_WIDTH = 4;
8861
+ var DEFAULT_WIDTH2 = 4;
8659
8862
  var DEFAULT_FADE_MS = 1200;
8660
8863
  var DEFAULT_MAX_POINTS = 512;
8661
8864
  var RemoteLaserOverlay = class {
@@ -8671,7 +8874,7 @@ var RemoteLaserOverlay = class {
8671
8874
  constructor(host, options = {}) {
8672
8875
  this.host = host;
8673
8876
  this.color = options.color ?? DEFAULT_COLOR;
8674
- this.width = options.width ?? DEFAULT_WIDTH;
8877
+ this.width = options.width ?? DEFAULT_WIDTH2;
8675
8878
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
8676
8879
  this.maxPointsPerSender = options.maxPointsPerSender ?? DEFAULT_MAX_POINTS;
8677
8880
  this.unregister = host.registerOverlay((ctx) => this.renderTrails(ctx));
@@ -8942,10 +9145,218 @@ var RemotePingOverlay = class {
8942
9145
  }
8943
9146
  };
8944
9147
 
9148
+ // src/canvas/measure-render.ts
9149
+ function formatMeasureLabel(feet) {
9150
+ return `${Math.round(feet)} ft`;
9151
+ }
9152
+ function drawMeasurement(ctx, m, opts = {}) {
9153
+ ctx.save();
9154
+ if (opts.alpha !== void 0) ctx.globalAlpha = opts.alpha;
9155
+ ctx.strokeStyle = m.color;
9156
+ ctx.setLineDash([8, 4]);
9157
+ ctx.lineWidth = 2;
9158
+ ctx.beginPath();
9159
+ ctx.moveTo(m.start.x, m.start.y);
9160
+ ctx.lineTo(m.end.x, m.end.y);
9161
+ ctx.stroke();
9162
+ ctx.setLineDash([]);
9163
+ ctx.fillStyle = m.color;
9164
+ const dotRadius = 4;
9165
+ ctx.beginPath();
9166
+ ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
9167
+ ctx.fill();
9168
+ ctx.beginPath();
9169
+ ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
9170
+ ctx.fill();
9171
+ const label = formatMeasureLabel(m.feet);
9172
+ const midX = (m.start.x + m.end.x) / 2;
9173
+ const midY = (m.start.y + m.end.y) / 2;
9174
+ ctx.font = "14px sans-serif";
9175
+ const metrics = ctx.measureText(label);
9176
+ const padX = 6;
9177
+ const padY = 4;
9178
+ const textH = 14;
9179
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
9180
+ ctx.beginPath();
9181
+ ctx.roundRect(
9182
+ midX - metrics.width / 2 - padX,
9183
+ midY - textH / 2 - padY,
9184
+ metrics.width + padX * 2,
9185
+ textH + padY * 2,
9186
+ 4
9187
+ );
9188
+ ctx.fill();
9189
+ ctx.fillStyle = "#FFFFFF";
9190
+ ctx.textAlign = "center";
9191
+ ctx.textBaseline = "middle";
9192
+ ctx.fillText(label, midX, midY);
9193
+ ctx.restore();
9194
+ }
9195
+
9196
+ // src/canvas/remote-measure-overlay.ts
9197
+ var MEASURE_PRESENCE_KIND = "measure";
9198
+ function isFinitePoint2(value) {
9199
+ if (typeof value !== "object" || value === null) return false;
9200
+ const point = value;
9201
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
9202
+ }
9203
+ function isMeasurePresence(data) {
9204
+ if (typeof data !== "object" || data === null) return false;
9205
+ const payload = data;
9206
+ if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
9207
+ if ("cleared" in payload) return payload.cleared === true;
9208
+ if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
9209
+ if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
9210
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
9211
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
9212
+ return true;
9213
+ }
9214
+ function toMeasurePresence(emission) {
9215
+ if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
9216
+ return {
9217
+ kind: MEASURE_PRESENCE_KIND,
9218
+ start: emission.start,
9219
+ end: emission.end,
9220
+ cells: emission.cells,
9221
+ feet: emission.feet,
9222
+ color: emission.color
9223
+ };
9224
+ }
9225
+ var DEFAULT_COLOR3 = "#FF5722";
9226
+ var DEFAULT_HOLD_MS = 1500;
9227
+ var DEFAULT_FADE_MS2 = 400;
9228
+ var DEFAULT_MAX_AGE_MS = 3e4;
9229
+ var RemoteMeasureOverlay = class {
9230
+ host;
9231
+ color;
9232
+ holdMs;
9233
+ fadeMs;
9234
+ maxAgeMs;
9235
+ measurements = /* @__PURE__ */ new Map();
9236
+ unregister;
9237
+ rafId = null;
9238
+ disposed = false;
9239
+ constructor(host, options = {}) {
9240
+ this.host = host;
9241
+ this.color = options.color ?? DEFAULT_COLOR3;
9242
+ this.holdMs = options.holdMs ?? DEFAULT_HOLD_MS;
9243
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
9244
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
9245
+ this.unregister = host.registerOverlay((ctx) => this.renderMeasurements(ctx));
9246
+ }
9247
+ now() {
9248
+ return performance.now();
9249
+ }
9250
+ /**
9251
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
9252
+ * the envelope `from`). Non-measure or malformed payloads are ignored and
9253
+ * reported as `false`, so hosts can feed every presence frame through.
9254
+ */
9255
+ apply(sender, data) {
9256
+ if (this.disposed || !isMeasurePresence(data)) return false;
9257
+ if ("cleared" in data) {
9258
+ this.beginLinger(sender);
9259
+ return true;
9260
+ }
9261
+ const existing = this.measurements.get(sender);
9262
+ if (existing?.expiryTimer != null) clearTimeout(existing.expiryTimer);
9263
+ this.measurements.set(sender, {
9264
+ start: data.start,
9265
+ end: data.end,
9266
+ feet: data.feet,
9267
+ color: data.color ?? this.color,
9268
+ clearedAt: null,
9269
+ expiryTimer: setTimeout(() => this.beginLinger(sender), this.maxAgeMs)
9270
+ });
9271
+ this.host.requestRender();
9272
+ return true;
9273
+ }
9274
+ /** Removes a sender's ruler immediately (presence-leave/disconnect). */
9275
+ remove(sender) {
9276
+ const entry = this.measurements.get(sender);
9277
+ if (!entry) return;
9278
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9279
+ this.measurements.delete(sender);
9280
+ this.host.requestRender();
9281
+ }
9282
+ /** Removes every ruler immediately. */
9283
+ clear() {
9284
+ if (this.measurements.size === 0) return;
9285
+ for (const entry of this.measurements.values()) {
9286
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9287
+ }
9288
+ this.measurements.clear();
9289
+ this.host.requestRender();
9290
+ }
9291
+ /** Number of senders with a visible (active or lingering) ruler. */
9292
+ get activeSenderCount() {
9293
+ return this.measurements.size;
9294
+ }
9295
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
9296
+ dispose() {
9297
+ if (this.disposed) return;
9298
+ this.disposed = true;
9299
+ if (this.rafId !== null) {
9300
+ cancelAnimationFrame(this.rafId);
9301
+ this.rafId = null;
9302
+ }
9303
+ for (const entry of this.measurements.values()) {
9304
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9305
+ }
9306
+ this.measurements.clear();
9307
+ this.unregister?.();
9308
+ this.unregister = null;
9309
+ this.host.requestRender();
9310
+ }
9311
+ beginLinger(sender) {
9312
+ const entry = this.measurements.get(sender);
9313
+ if (!entry || entry.clearedAt !== null) return;
9314
+ if (entry.expiryTimer != null) {
9315
+ clearTimeout(entry.expiryTimer);
9316
+ entry.expiryTimer = null;
9317
+ }
9318
+ entry.clearedAt = this.now();
9319
+ this.ensureAnimating();
9320
+ this.host.requestRender();
9321
+ }
9322
+ ensureAnimating() {
9323
+ if (this.rafId === null) {
9324
+ this.rafId = requestAnimationFrame(() => this.tick());
9325
+ }
9326
+ }
9327
+ tick() {
9328
+ if (this.disposed) return;
9329
+ const now = this.now();
9330
+ let lingering = 0;
9331
+ for (const [sender, entry] of this.measurements) {
9332
+ if (entry.clearedAt === null) continue;
9333
+ if (now - entry.clearedAt >= this.holdMs + this.fadeMs) {
9334
+ this.measurements.delete(sender);
9335
+ } else {
9336
+ lingering += 1;
9337
+ }
9338
+ }
9339
+ this.host.requestRender();
9340
+ this.rafId = lingering > 0 ? requestAnimationFrame(() => this.tick()) : null;
9341
+ }
9342
+ renderMeasurements(ctx) {
9343
+ if (this.measurements.size === 0) return;
9344
+ const now = this.now();
9345
+ for (const entry of this.measurements.values()) {
9346
+ let alpha = 1;
9347
+ if (entry.clearedAt !== null) {
9348
+ const fadeAge = now - entry.clearedAt - this.holdMs;
9349
+ if (fadeAge > 0) alpha = Math.max(0, 1 - fadeAge / this.fadeMs);
9350
+ }
9351
+ drawMeasurement(ctx, entry, { alpha });
9352
+ }
9353
+ }
9354
+ };
9355
+
8945
9356
  // src/canvas/ping-input.ts
8946
9357
  var DEFAULT_LONG_PRESS_MS = 600;
8947
9358
  var DEFAULT_SLOP_PX = 8;
8948
- var DEFAULT_COLOR3 = "#ff3b30";
9359
+ var DEFAULT_COLOR4 = "#ff3b30";
8949
9360
  var DEFAULT_DURATION_MS2 = 1800;
8950
9361
  var DEFAULT_RADIUS2 = 48;
8951
9362
  var DEFAULT_MIN_INTERVAL_MS = 300;
@@ -8978,7 +9389,7 @@ var PingInput = class {
8978
9389
  this.longPressEnabled = options.longPressEnabled ?? false;
8979
9390
  this.longPressMs = options.longPressMs ?? DEFAULT_LONG_PRESS_MS;
8980
9391
  this.slopPx = options.slopPx ?? DEFAULT_SLOP_PX;
8981
- this.color = options.color ?? DEFAULT_COLOR3;
9392
+ this.color = options.color ?? DEFAULT_COLOR4;
8982
9393
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
8983
9394
  this.radius = options.radius ?? DEFAULT_RADIUS2;
8984
9395
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
@@ -11268,21 +11679,37 @@ var MeasureTool = class {
11268
11679
  gridType;
11269
11680
  hexOrientation;
11270
11681
  feetPerCell;
11682
+ color;
11271
11683
  optionListeners = /* @__PURE__ */ new Set();
11684
+ measurementListeners = /* @__PURE__ */ new Set();
11685
+ emissionRafId = null;
11272
11686
  constructor(options = {}) {
11273
11687
  this.feetPerCell = options.feetPerCell ?? 5;
11688
+ this.color = options.color ?? "#FF5722";
11274
11689
  }
11275
11690
  getOptions() {
11276
- return { feetPerCell: this.feetPerCell };
11691
+ return { feetPerCell: this.feetPerCell, color: this.color };
11277
11692
  }
11278
11693
  setOptions(options) {
11279
11694
  if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
11695
+ if (options.color !== void 0) this.color = options.color;
11280
11696
  this.notifyOptionsChange();
11281
11697
  }
11282
11698
  onOptionsChange(listener) {
11283
11699
  this.optionListeners.add(listener);
11284
11700
  return () => this.optionListeners.delete(listener);
11285
11701
  }
11702
+ /**
11703
+ * Subscribes to raf-coalesced measurement snapshots. While a measurement is
11704
+ * in progress, listeners receive at most one snapshot per animation frame
11705
+ * carrying the latest state; `null` is delivered synchronously when the
11706
+ * measurement clears (pointer-up or deactivate). Emissions are ephemeral by
11707
+ * contract: presence only — never elements, history, or persisted state.
11708
+ */
11709
+ onMeasurement(listener) {
11710
+ this.measurementListeners.add(listener);
11711
+ return () => this.measurementListeners.delete(listener);
11712
+ }
11286
11713
  onPointerDown(state, ctx) {
11287
11714
  this.gridSize = ctx.gridSize ?? 1;
11288
11715
  this.gridType = ctx.gridType;
@@ -11290,22 +11717,27 @@ var MeasureTool = class {
11290
11717
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11291
11718
  this.start = this.snapToGrid(world, ctx);
11292
11719
  this.end = { ...this.start };
11720
+ this.scheduleEmission();
11293
11721
  }
11294
11722
  onPointerMove(state, ctx) {
11295
11723
  if (!this.start) return;
11296
11724
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11297
11725
  this.end = this.snapToGrid(world, ctx);
11298
11726
  ctx.requestRender();
11727
+ this.scheduleEmission();
11299
11728
  }
11300
11729
  onPointerUp(_state, ctx) {
11301
11730
  if (!this.start) return;
11302
11731
  this.start = null;
11303
11732
  this.end = null;
11304
11733
  ctx.requestRender();
11734
+ this.emitClear();
11305
11735
  }
11306
11736
  onDeactivate(_ctx) {
11737
+ const wasActive = this.start !== null;
11307
11738
  this.start = null;
11308
11739
  this.end = null;
11740
+ if (wasActive) this.emitClear();
11309
11741
  }
11310
11742
  getMeasurement() {
11311
11743
  if (!this.start || !this.end) return null;
@@ -11331,46 +11763,7 @@ var MeasureTool = class {
11331
11763
  renderOverlay(ctx) {
11332
11764
  const m = this.getMeasurement();
11333
11765
  if (!m) return;
11334
- ctx.save();
11335
- ctx.strokeStyle = "#FF5722";
11336
- ctx.setLineDash([8, 4]);
11337
- ctx.lineWidth = 2;
11338
- ctx.beginPath();
11339
- ctx.moveTo(m.start.x, m.start.y);
11340
- ctx.lineTo(m.end.x, m.end.y);
11341
- ctx.stroke();
11342
- ctx.setLineDash([]);
11343
- ctx.fillStyle = "#FF5722";
11344
- const dotRadius = 4;
11345
- ctx.beginPath();
11346
- ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
11347
- ctx.fill();
11348
- ctx.beginPath();
11349
- ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
11350
- ctx.fill();
11351
- const label = `${Math.round(m.feet)} ft`;
11352
- const midX = (m.start.x + m.end.x) / 2;
11353
- const midY = (m.start.y + m.end.y) / 2;
11354
- ctx.font = "14px sans-serif";
11355
- const metrics = ctx.measureText(label);
11356
- const padX = 6;
11357
- const padY = 4;
11358
- const textH = 14;
11359
- ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
11360
- ctx.beginPath();
11361
- ctx.roundRect(
11362
- midX - metrics.width / 2 - padX,
11363
- midY - textH / 2 - padY,
11364
- metrics.width + padX * 2,
11365
- textH + padY * 2,
11366
- 4
11367
- );
11368
- ctx.fill();
11369
- ctx.fillStyle = "#FFFFFF";
11370
- ctx.textAlign = "center";
11371
- ctx.textBaseline = "middle";
11372
- ctx.fillText(label, midX, midY);
11373
- ctx.restore();
11766
+ drawMeasurement(ctx, { start: m.start, end: m.end, feet: m.feet, color: this.color });
11374
11767
  }
11375
11768
  snapToGrid(point, ctx) {
11376
11769
  if (!ctx.gridSize) return point;
@@ -11388,6 +11781,32 @@ var MeasureTool = class {
11388
11781
  notifyOptionsChange() {
11389
11782
  for (const listener of this.optionListeners) listener();
11390
11783
  }
11784
+ scheduleEmission() {
11785
+ if (this.measurementListeners.size === 0) return;
11786
+ if (this.emissionRafId !== null) return;
11787
+ this.emissionRafId = requestAnimationFrame(() => {
11788
+ this.emissionRafId = null;
11789
+ const m = this.getMeasurement();
11790
+ if (!m) return;
11791
+ this.emit({ ...m, color: this.color });
11792
+ });
11793
+ }
11794
+ emitClear() {
11795
+ if (this.emissionRafId !== null) {
11796
+ cancelAnimationFrame(this.emissionRafId);
11797
+ this.emissionRafId = null;
11798
+ }
11799
+ if (this.measurementListeners.size === 0) return;
11800
+ this.emit(null);
11801
+ }
11802
+ emit(emission) {
11803
+ for (const listener of this.measurementListeners) {
11804
+ try {
11805
+ listener(emission);
11806
+ } catch {
11807
+ }
11808
+ }
11809
+ }
11391
11810
  };
11392
11811
 
11393
11812
  // src/tools/template-tool.ts
@@ -11683,9 +12102,9 @@ var TemplateTool = class {
11683
12102
  };
11684
12103
 
11685
12104
  // src/tools/laser-tool.ts
11686
- var DEFAULT_COLOR4 = "#ff3b30";
11687
- var DEFAULT_WIDTH2 = 4;
11688
- var DEFAULT_FADE_MS2 = 1200;
12105
+ var DEFAULT_COLOR5 = "#ff3b30";
12106
+ var DEFAULT_WIDTH3 = 4;
12107
+ var DEFAULT_FADE_MS3 = 1200;
11689
12108
  var LaserTool = class {
11690
12109
  name;
11691
12110
  color;
@@ -11699,9 +12118,9 @@ var LaserTool = class {
11699
12118
  pendingEmission = [];
11700
12119
  constructor(options = {}) {
11701
12120
  this.name = options.name ?? "laser";
11702
- this.color = options.color ?? DEFAULT_COLOR4;
11703
- this.width = options.width ?? DEFAULT_WIDTH2;
11704
- this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
12121
+ this.color = options.color ?? DEFAULT_COLOR5;
12122
+ this.width = options.width ?? DEFAULT_WIDTH3;
12123
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS3;
11705
12124
  }
11706
12125
  now() {
11707
12126
  return performance.now();
@@ -11827,7 +12246,7 @@ var LaserTool = class {
11827
12246
  };
11828
12247
 
11829
12248
  // src/tools/ping-tool.ts
11830
- var DEFAULT_COLOR5 = "#ff3b30";
12249
+ var DEFAULT_COLOR6 = "#ff3b30";
11831
12250
  var DEFAULT_DURATION_MS3 = 1800;
11832
12251
  var DEFAULT_RADIUS4 = 48;
11833
12252
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
@@ -11844,7 +12263,7 @@ var PingTool = class {
11844
12263
  pingListeners = /* @__PURE__ */ new Set();
11845
12264
  constructor(options = {}) {
11846
12265
  this.name = options.name ?? "ping";
11847
- this.color = options.color ?? DEFAULT_COLOR5;
12266
+ this.color = options.color ?? DEFAULT_COLOR6;
11848
12267
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
11849
12268
  this.radius = options.radius ?? DEFAULT_RADIUS4;
11850
12269
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
@@ -11947,7 +12366,7 @@ var PingTool = class {
11947
12366
  };
11948
12367
 
11949
12368
  // src/index.ts
11950
- var VERSION = "0.56.0";
12369
+ var VERSION = "0.58.0";
11951
12370
  // Annotate the CommonJS export names for ESM import in node:
11952
12371
  0 && (module.exports = {
11953
12372
  ArrowTool,
@@ -11964,14 +12383,17 @@ var VERSION = "0.56.0";
11964
12383
  LaserTool,
11965
12384
  LayerManager,
11966
12385
  LocalStorageAdapter,
12386
+ MEASURE_PRESENCE_KIND,
11967
12387
  MeasureTool,
11968
12388
  MemoryAdapter,
12389
+ MinimapController,
11969
12390
  NoteTool,
11970
12391
  PING_PRESENCE_KIND,
11971
12392
  PencilTool,
11972
12393
  PingInput,
11973
12394
  PingTool,
11974
12395
  RemoteLaserOverlay,
12396
+ RemoteMeasureOverlay,
11975
12397
  RemotePingOverlay,
11976
12398
  SelectTool,
11977
12399
  ShapeTool,
@@ -12009,6 +12431,7 @@ var VERSION = "0.56.0";
12009
12431
  getHexCellsInSquare,
12010
12432
  getHexDistance,
12011
12433
  isLaserTrailPresence,
12434
+ isMeasurePresence,
12012
12435
  isNearBezier,
12013
12436
  isPingPresence,
12014
12437
  setFontSize,
@@ -12017,6 +12440,7 @@ var VERSION = "0.56.0";
12017
12440
  snapToHexCenter,
12018
12441
  styleToPatch,
12019
12442
  toLaserTrailPresence,
12443
+ toMeasurePresence,
12020
12444
  toPingPresence,
12021
12445
  toggleBold,
12022
12446
  toggleItalic,