@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.js CHANGED
@@ -4695,88 +4695,247 @@ function miniToWorld(t, p) {
4695
4695
  return { x: (p.x - t.offsetX) / t.scale, y: (p.y - t.offsetY) / t.scale };
4696
4696
  }
4697
4697
 
4698
- // src/canvas/minimap.ts
4699
- var WIDTH = 200;
4700
- var HEIGHT = 140;
4701
- var MARGIN = 16;
4702
- var PADDING = 8;
4698
+ // src/canvas/minimap-controller.ts
4699
+ var DEFAULT_WIDTH = 200;
4700
+ var DEFAULT_HEIGHT = 140;
4701
+ var DEFAULT_PADDING = 8;
4702
+ var DEFAULT_DEBOUNCE_MS2 = 200;
4703
+ var DEFAULT_VIEWPORT_STROKE = "#3b82f6";
4703
4704
  var NEUTRAL = "rgba(100,116,139,0.6)";
4704
- var VIEWPORT_STROKE = "#3b82f6";
4705
+ var DOM_FALLBACK_TYPES = /* @__PURE__ */ new Set(["note", "text", "html"]);
4705
4706
  function elementColor(el) {
4706
4707
  return "color" in el && typeof el.color === "string" ? el.color : NEUTRAL;
4707
4708
  }
4708
- var Minimap = class {
4709
- constructor(deps) {
4710
- this.deps = deps;
4711
- const canvas = document.createElement("canvas");
4712
- canvas.width = WIDTH;
4713
- canvas.height = HEIGHT;
4714
- Object.assign(canvas.style, {
4715
- position: "absolute",
4716
- right: `${MARGIN}px`,
4717
- bottom: `${MARGIN}px`,
4718
- width: `${WIDTH}px`,
4719
- height: `${HEIGHT}px`,
4720
- background: "rgba(255,255,255,0.85)",
4721
- border: "1px solid rgba(0,0,0,0.15)",
4722
- borderRadius: "4px",
4723
- touchAction: "none",
4724
- cursor: "pointer",
4725
- zIndex: "10"
4726
- });
4727
- canvas.addEventListener("pointerdown", this.onPointerDown);
4728
- canvas.addEventListener("pointermove", this.onPointerMove);
4729
- canvas.addEventListener("pointerup", this.onPointerUp);
4730
- this.deps.container.appendChild(canvas);
4709
+ function sameBounds(a, b) {
4710
+ return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
4711
+ }
4712
+ var MinimapController = class {
4713
+ constructor(viewport, canvas, options = {}) {
4714
+ this.viewport = viewport;
4731
4715
  this.canvas = canvas;
4716
+ this.width = options.width ?? DEFAULT_WIDTH;
4717
+ this.height = options.height ?? DEFAULT_HEIGHT;
4718
+ this.padding = options.padding ?? DEFAULT_PADDING;
4719
+ this.background = options.background ?? null;
4720
+ this.viewportStroke = options.viewportStroke ?? DEFAULT_VIEWPORT_STROKE;
4721
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS2;
4722
+ this.interactive = options.interactive !== false;
4723
+ this.requestFrame = options.requestFrame ?? ((cb) => typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame(cb) : 0);
4724
+ this.cancelFrame = options.cancelFrame ?? ((id) => {
4725
+ if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(id);
4726
+ });
4727
+ this.renderer.setStore(viewport.store);
4728
+ this.renderer.setOnImageLoad(() => this.markSceneDirty());
4729
+ this.applyCanvasSize();
4730
+ const onScene = () => this.markSceneDirty();
4731
+ this.unsubs.push(
4732
+ viewport.store.on("add", onScene),
4733
+ viewport.store.on("remove", onScene),
4734
+ viewport.store.on("update", onScene),
4735
+ viewport.store.on("clear", onScene),
4736
+ viewport.layerManager.on("change", onScene),
4737
+ viewport.camera.onChange(() => this.onViewChanged()),
4738
+ viewport.onResize(() => this.onViewChanged())
4739
+ );
4740
+ if (this.interactive) {
4741
+ canvas.style.touchAction = "none";
4742
+ canvas.style.cursor = "pointer";
4743
+ canvas.addEventListener("pointerdown", this.onPointerDown);
4744
+ canvas.addEventListener("pointermove", this.onPointerMove);
4745
+ canvas.addEventListener("pointerup", this.onPointerUp);
4746
+ canvas.addEventListener("pointercancel", this.onPointerEnd);
4747
+ canvas.addEventListener("lostpointercapture", this.onPointerEnd);
4748
+ }
4749
+ this.renderScene();
4750
+ this.requestDraw();
4732
4751
  }
4733
- canvas;
4734
- rafId = null;
4752
+ width;
4753
+ height;
4754
+ padding;
4755
+ background;
4756
+ viewportStroke;
4757
+ debounceMs;
4758
+ interactive;
4759
+ requestFrame;
4760
+ cancelFrame;
4761
+ renderer = new ElementRenderer();
4762
+ scene = null;
4763
+ frameId = null;
4764
+ debounceTimer = null;
4735
4765
  dragging = false;
4736
- scheduleDraw() {
4737
- if (this.rafId !== null) return;
4738
- this.rafId = this.deps.requestFrame(this.draw);
4766
+ disposed = false;
4767
+ unsubs = [];
4768
+ setSize(width, height) {
4769
+ if (this.disposed) return;
4770
+ this.width = width;
4771
+ this.height = height;
4772
+ this.applyCanvasSize();
4773
+ this.clearDebounce();
4774
+ this.renderScene();
4775
+ this.requestDraw();
4739
4776
  }
4740
- destroy() {
4741
- if (this.rafId !== null) {
4742
- this.deps.cancelFrame(this.rafId);
4743
- this.rafId = null;
4777
+ requestDraw() {
4778
+ if (this.disposed || this.frameId !== null) return;
4779
+ this.frameId = this.requestFrame(this.draw);
4780
+ }
4781
+ dispose() {
4782
+ if (this.disposed) return;
4783
+ this.disposed = true;
4784
+ this.clearDebounce();
4785
+ if (this.frameId !== null) {
4786
+ this.cancelFrame(this.frameId);
4787
+ this.frameId = null;
4788
+ }
4789
+ for (const unsub of this.unsubs.splice(0)) unsub();
4790
+ if (this.interactive) {
4791
+ this.canvas.removeEventListener("pointerdown", this.onPointerDown);
4792
+ this.canvas.removeEventListener("pointermove", this.onPointerMove);
4793
+ this.canvas.removeEventListener("pointerup", this.onPointerUp);
4794
+ this.canvas.removeEventListener("pointercancel", this.onPointerEnd);
4795
+ this.canvas.removeEventListener("lostpointercapture", this.onPointerEnd);
4796
+ }
4797
+ }
4798
+ clearDebounce() {
4799
+ if (this.debounceTimer !== null) {
4800
+ clearTimeout(this.debounceTimer);
4801
+ this.debounceTimer = null;
4802
+ }
4803
+ }
4804
+ dpr() {
4805
+ return typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4806
+ }
4807
+ applyCanvasSize() {
4808
+ const dpr = this.dpr();
4809
+ this.canvas.width = Math.max(1, Math.round(this.width * dpr));
4810
+ this.canvas.height = Math.max(1, Math.round(this.height * dpr));
4811
+ }
4812
+ // Single source for both mapping bounds and rendering: layer-visible,
4813
+ // grids excluded. (getElementBounds already returns null for grids, so they
4814
+ // cannot extend the bounding box today — this filter makes the invariant
4815
+ // structural instead of relying on that special case.)
4816
+ sceneElements() {
4817
+ return this.viewport.store.getAll().filter((el) => el.type !== "grid" && this.viewport.layerManager.isLayerVisible(el.layerId));
4818
+ }
4819
+ currentMapping() {
4820
+ const viewportRect = this.viewport.getVisibleRect();
4821
+ const content = getElementsBoundingBox(this.sceneElements());
4822
+ return content ? unionBounds(content, viewportRect) : viewportRect;
4823
+ }
4824
+ onViewChanged() {
4825
+ if (this.disposed) return;
4826
+ const mapping = this.currentMapping();
4827
+ if (!this.scene || !sameBounds(mapping, this.scene.mapping)) {
4828
+ this.markSceneDirty();
4744
4829
  }
4745
- this.canvas.removeEventListener("pointerdown", this.onPointerDown);
4746
- this.canvas.removeEventListener("pointermove", this.onPointerMove);
4747
- this.canvas.removeEventListener("pointerup", this.onPointerUp);
4748
- this.canvas.remove();
4830
+ this.requestDraw();
4749
4831
  }
4750
- currentTransform() {
4751
- const viewport = this.deps.getViewportRect();
4752
- const content = this.deps.getContentBounds();
4753
- const mapping = content ? unionBounds(content, viewport) : viewport;
4754
- return computeMinimapTransform(mapping, WIDTH, HEIGHT, PADDING);
4832
+ markSceneDirty() {
4833
+ if (this.disposed) return;
4834
+ this.clearDebounce();
4835
+ this.debounceTimer = setTimeout(() => {
4836
+ this.debounceTimer = null;
4837
+ this.renderScene();
4838
+ this.requestDraw();
4839
+ }, this.debounceMs);
4840
+ }
4841
+ renderScene() {
4842
+ if (this.disposed) return;
4843
+ const dpr = this.dpr();
4844
+ const mapping = this.currentMapping();
4845
+ const transform = computeMinimapTransform(mapping, this.width, this.height, this.padding);
4846
+ const sceneCanvas = document.createElement("canvas");
4847
+ sceneCanvas.width = Math.max(1, Math.round(this.width * dpr));
4848
+ sceneCanvas.height = Math.max(1, Math.round(this.height * dpr));
4849
+ const ctx = sceneCanvas.getContext("2d");
4850
+ if (!ctx) return;
4851
+ const byLayer = /* @__PURE__ */ new Map();
4852
+ for (const el of this.sceneElements()) {
4853
+ let list = byLayer.get(el.layerId);
4854
+ if (!list) {
4855
+ list = [];
4856
+ byLayer.set(el.layerId, list);
4857
+ }
4858
+ list.push(el);
4859
+ }
4860
+ for (const layer of this.viewport.layerManager.getLayers()) {
4861
+ const els = byLayer.get(layer.id);
4862
+ if (!els || els.length === 0) continue;
4863
+ const opacity = layer.opacity;
4864
+ if (opacity >= 1) {
4865
+ this.renderLayerElements(ctx, els, transform, dpr);
4866
+ continue;
4867
+ }
4868
+ const layerCanvas = document.createElement("canvas");
4869
+ layerCanvas.width = sceneCanvas.width;
4870
+ layerCanvas.height = sceneCanvas.height;
4871
+ const layerCtx = layerCanvas.getContext("2d");
4872
+ if (!layerCtx) continue;
4873
+ this.renderLayerElements(layerCtx, els, transform, dpr);
4874
+ ctx.save();
4875
+ ctx.globalAlpha = opacity;
4876
+ ctx.drawImage(layerCanvas, 0, 0);
4877
+ ctx.restore();
4878
+ }
4879
+ this.scene = { canvas: sceneCanvas, transform, mapping };
4880
+ }
4881
+ renderLayerElements(ctx, elements, t, dpr) {
4882
+ for (const el of elements) {
4883
+ if (DOM_FALLBACK_TYPES.has(el.type)) {
4884
+ const b = getElementBounds(el);
4885
+ if (!b) continue;
4886
+ const tl = worldToMini(t, { x: b.x, y: b.y });
4887
+ ctx.save();
4888
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
4889
+ ctx.fillStyle = elementColor(el);
4890
+ ctx.fillRect(tl.x, tl.y, Math.max(1, b.w * t.scale), Math.max(1, b.h * t.scale));
4891
+ ctx.restore();
4892
+ continue;
4893
+ }
4894
+ ctx.save();
4895
+ ctx.setTransform(dpr * t.scale, 0, 0, dpr * t.scale, dpr * t.offsetX, dpr * t.offsetY);
4896
+ this.renderer.renderCanvasElement(ctx, el);
4897
+ ctx.restore();
4898
+ }
4755
4899
  }
4756
4900
  draw = () => {
4757
- this.rafId = null;
4901
+ this.frameId = null;
4902
+ if (this.disposed) return;
4758
4903
  const ctx = this.canvas.getContext("2d");
4759
4904
  if (!ctx) return;
4760
- const t = this.currentTransform();
4761
- const viewport = this.deps.getViewportRect();
4762
- ctx.clearRect(0, 0, WIDTH, HEIGHT);
4763
- for (const el of this.deps.getElements()) {
4764
- const b = getElementBounds(el);
4765
- if (!b) continue;
4766
- const tl = worldToMini(t, { x: b.x, y: b.y });
4767
- ctx.fillStyle = elementColor(el);
4768
- ctx.fillRect(tl.x, tl.y, Math.max(1, b.w * t.scale), Math.max(1, b.h * t.scale));
4769
- }
4770
- const vtl = worldToMini(t, { x: viewport.x, y: viewport.y });
4771
- ctx.strokeStyle = VIEWPORT_STROKE;
4905
+ const dpr = this.dpr();
4906
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
4907
+ ctx.clearRect(0, 0, this.width, this.height);
4908
+ if (this.background) {
4909
+ ctx.fillStyle = this.background;
4910
+ ctx.fillRect(0, 0, this.width, this.height);
4911
+ }
4912
+ const scene = this.scene;
4913
+ if (!scene) return;
4914
+ ctx.drawImage(scene.canvas, 0, 0, this.width, this.height);
4915
+ const viewportRect = this.viewport.getVisibleRect();
4916
+ const tl = worldToMini(scene.transform, { x: viewportRect.x, y: viewportRect.y });
4917
+ ctx.strokeStyle = this.viewportStroke;
4772
4918
  ctx.lineWidth = 1.5;
4773
- ctx.strokeRect(vtl.x, vtl.y, viewport.w * t.scale, viewport.h * t.scale);
4919
+ ctx.strokeRect(
4920
+ tl.x,
4921
+ tl.y,
4922
+ viewportRect.w * scene.transform.scale,
4923
+ viewportRect.h * scene.transform.scale
4924
+ );
4774
4925
  };
4926
+ navTransform() {
4927
+ return this.scene?.transform ?? computeMinimapTransform(this.currentMapping(), this.width, this.height, this.padding);
4928
+ }
4775
4929
  navigateFromEvent(e) {
4776
4930
  const rect = this.canvas.getBoundingClientRect();
4777
- const point = { x: e.clientX - rect.left, y: e.clientY - rect.top };
4778
- const world = miniToWorld(this.currentTransform(), point);
4779
- this.deps.navigateTo(world);
4931
+ const scaleX = rect.width > 0 ? this.width / rect.width : 1;
4932
+ const scaleY = rect.height > 0 ? this.height / rect.height : 1;
4933
+ const point = {
4934
+ x: (e.clientX - rect.left) * scaleX,
4935
+ y: (e.clientY - rect.top) * scaleY
4936
+ };
4937
+ const world = miniToWorld(this.navTransform(), point);
4938
+ this.viewport.centerCameraAt(world);
4780
4939
  }
4781
4940
  onPointerDown = (e) => {
4782
4941
  e.stopPropagation();
@@ -4797,6 +4956,45 @@ var Minimap = class {
4797
4956
  } catch {
4798
4957
  }
4799
4958
  };
4959
+ // pointercancel / lostpointercapture: a browser gesture or interrupted touch
4960
+ // can end the interaction without a pointerup — the drag must not stick.
4961
+ onPointerEnd = () => {
4962
+ this.dragging = false;
4963
+ };
4964
+ };
4965
+
4966
+ // src/canvas/minimap.ts
4967
+ var WIDTH = 200;
4968
+ var HEIGHT = 140;
4969
+ var MARGIN = 16;
4970
+ var Minimap = class {
4971
+ canvas;
4972
+ controller;
4973
+ constructor(container, viewport) {
4974
+ const canvas = document.createElement("canvas");
4975
+ Object.assign(canvas.style, {
4976
+ position: "absolute",
4977
+ right: `${MARGIN}px`,
4978
+ bottom: `${MARGIN}px`,
4979
+ width: `${WIDTH}px`,
4980
+ height: `${HEIGHT}px`,
4981
+ background: "rgba(255,255,255,0.85)",
4982
+ border: "1px solid rgba(0,0,0,0.15)",
4983
+ borderRadius: "4px",
4984
+ zIndex: "10"
4985
+ });
4986
+ canvas.dataset.fieldnotesMinimap = "true";
4987
+ container.appendChild(canvas);
4988
+ this.canvas = canvas;
4989
+ this.controller = new MinimapController(viewport, canvas, { width: WIDTH, height: HEIGHT });
4990
+ }
4991
+ scheduleDraw() {
4992
+ this.controller.requestDraw();
4993
+ }
4994
+ destroy() {
4995
+ this.controller.dispose();
4996
+ this.canvas.remove();
4997
+ }
4800
4998
  };
4801
4999
 
4802
5000
  // src/canvas/viewport-dom.ts
@@ -7978,25 +8176,7 @@ var Viewport = class {
7978
8176
  }
7979
8177
  this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
7980
8178
  if (options.minimap) {
7981
- const visibleEls = () => this.store.getAll().filter((el) => this.layerManager.isLayerVisible(el.layerId));
7982
- this.minimap = new Minimap({
7983
- container: this.wrapper,
7984
- getElements: visibleEls,
7985
- getContentBounds: () => getElementsBoundingBox(visibleEls()),
7986
- getViewportRect: () => this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight),
7987
- navigateTo: (w) => {
7988
- const z = this.camera.zoom;
7989
- this.camera.moveTo(
7990
- this.canvasEl.clientWidth / 2 - w.x * z,
7991
- this.canvasEl.clientHeight / 2 - w.y * z
7992
- );
7993
- },
7994
- requestFrame: (cb) => typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame(cb) : 0,
7995
- cancelFrame: (id) => {
7996
- if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(id);
7997
- }
7998
- });
7999
- this.minimap.scheduleDraw();
8179
+ this.minimap = new Minimap(this.wrapper, this);
8000
8180
  }
8001
8181
  this.domNodeManager = new DomNodeManager({
8002
8182
  domLayer: this.paintStack,
@@ -8032,7 +8212,6 @@ var Viewport = class {
8032
8212
  this.applyCameraTransform();
8033
8213
  this.noteEditor.updateToolbarPosition();
8034
8214
  this.contextMenu?.close();
8035
- this.minimap?.scheduleDraw();
8036
8215
  this.requestRender();
8037
8216
  });
8038
8217
  this.gridController = new GridController({
@@ -8047,7 +8226,6 @@ var Viewport = class {
8047
8226
  this.store.on("add", (el) => {
8048
8227
  if (el.type === "grid") this.gridController.syncContext();
8049
8228
  this.renderLoop.markLayerDirty(el.layerId);
8050
- this.minimap?.scheduleDraw();
8051
8229
  this.requestRender();
8052
8230
  }),
8053
8231
  this.store.on("remove", (el) => {
@@ -8055,7 +8233,6 @@ var Viewport = class {
8055
8233
  this.unbindArrowsFrom(el);
8056
8234
  this.domNodeManager.removeDomNode(el.id);
8057
8235
  this.renderLoop.markLayerDirty(el.layerId);
8058
- this.minimap?.scheduleDraw();
8059
8236
  this.requestRender();
8060
8237
  }),
8061
8238
  this.store.on("update", ({ previous, current }) => {
@@ -8064,21 +8241,18 @@ var Viewport = class {
8064
8241
  if (previous.layerId !== current.layerId) {
8065
8242
  this.renderLoop.markLayerDirty(previous.layerId);
8066
8243
  }
8067
- this.minimap?.scheduleDraw();
8068
8244
  this.requestRender();
8069
8245
  }),
8070
8246
  this.store.on("clear", () => {
8071
8247
  this.domNodeManager.clearDomNodes();
8072
8248
  this.renderLoop.markAllLayersDirty();
8073
8249
  this.gridController.syncContext();
8074
- this.minimap?.scheduleDraw();
8075
8250
  this.requestRender();
8076
8251
  })
8077
8252
  ];
8078
8253
  this.layerManager.on("change", () => {
8079
8254
  this.toolContext.activeLayerId = this.layerManager.activeLayerId;
8080
8255
  this.renderLoop.markAllLayersDirty();
8081
- this.minimap?.scheduleDraw();
8082
8256
  this.requestRender();
8083
8257
  });
8084
8258
  this.interactions = new ViewportInteractions({
@@ -8142,6 +8316,7 @@ var Viewport = class {
8142
8316
  contextMenu = null;
8143
8317
  minimap = null;
8144
8318
  htmlRenderers = /* @__PURE__ */ new Map();
8319
+ resizeListeners = /* @__PURE__ */ new Set();
8145
8320
  get ctx() {
8146
8321
  return this.canvasEl.getContext("2d");
8147
8322
  }
@@ -8166,6 +8341,27 @@ var Viewport = class {
8166
8341
  if (!bbox) return;
8167
8342
  this.camera.fitToContent(bbox, this.wrapper.clientWidth, this.wrapper.clientHeight, padding);
8168
8343
  }
8344
+ /** World-space rectangle currently visible through the canvas. */
8345
+ getVisibleRect() {
8346
+ return this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
8347
+ }
8348
+ /** Centers the camera on a world point without changing zoom. */
8349
+ centerCameraAt(world) {
8350
+ const z = this.camera.zoom;
8351
+ this.camera.moveTo(
8352
+ this.canvasEl.clientWidth / 2 - world.x * z,
8353
+ this.canvasEl.clientHeight / 2 - world.y * z
8354
+ );
8355
+ }
8356
+ /**
8357
+ * Notifies after the host container resizes (ResizeObserver-driven). A resize
8358
+ * changes the visible world rect without a camera event; overlays such as the
8359
+ * minimap subscribe to stay current. Returns an idempotent unsubscribe.
8360
+ */
8361
+ onResize(listener) {
8362
+ this.resizeListeners.add(listener);
8363
+ return () => this.resizeListeners.delete(listener);
8364
+ }
8169
8365
  requestRender() {
8170
8366
  this.renderLoop.requestRender();
8171
8367
  }
@@ -8470,6 +8666,7 @@ var Viewport = class {
8470
8666
  this.unsubStore.forEach((fn) => fn());
8471
8667
  this.resizeObserver?.disconnect();
8472
8668
  this.resizeObserver = null;
8669
+ this.resizeListeners.clear();
8473
8670
  this.wrapper.remove();
8474
8671
  }
8475
8672
  stopInteracting() {
@@ -8519,6 +8716,7 @@ var Viewport = class {
8519
8716
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
8520
8717
  this.renderLoop.setCanvasSize(rect.width * dpr, rect.height * dpr);
8521
8718
  this.requestRender();
8719
+ this.resizeListeners.forEach((fn) => fn());
8522
8720
  }
8523
8721
  observeResize() {
8524
8722
  if (typeof ResizeObserver === "undefined") return;
@@ -8558,7 +8756,7 @@ function toLaserTrailPresence(emission) {
8558
8756
  };
8559
8757
  }
8560
8758
  var DEFAULT_COLOR = "#ff3b30";
8561
- var DEFAULT_WIDTH = 4;
8759
+ var DEFAULT_WIDTH2 = 4;
8562
8760
  var DEFAULT_FADE_MS = 1200;
8563
8761
  var DEFAULT_MAX_POINTS = 512;
8564
8762
  var RemoteLaserOverlay = class {
@@ -8574,7 +8772,7 @@ var RemoteLaserOverlay = class {
8574
8772
  constructor(host, options = {}) {
8575
8773
  this.host = host;
8576
8774
  this.color = options.color ?? DEFAULT_COLOR;
8577
- this.width = options.width ?? DEFAULT_WIDTH;
8775
+ this.width = options.width ?? DEFAULT_WIDTH2;
8578
8776
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
8579
8777
  this.maxPointsPerSender = options.maxPointsPerSender ?? DEFAULT_MAX_POINTS;
8580
8778
  this.unregister = host.registerOverlay((ctx) => this.renderTrails(ctx));
@@ -8845,10 +9043,218 @@ var RemotePingOverlay = class {
8845
9043
  }
8846
9044
  };
8847
9045
 
9046
+ // src/canvas/measure-render.ts
9047
+ function formatMeasureLabel(feet) {
9048
+ return `${Math.round(feet)} ft`;
9049
+ }
9050
+ function drawMeasurement(ctx, m, opts = {}) {
9051
+ ctx.save();
9052
+ if (opts.alpha !== void 0) ctx.globalAlpha = opts.alpha;
9053
+ ctx.strokeStyle = m.color;
9054
+ ctx.setLineDash([8, 4]);
9055
+ ctx.lineWidth = 2;
9056
+ ctx.beginPath();
9057
+ ctx.moveTo(m.start.x, m.start.y);
9058
+ ctx.lineTo(m.end.x, m.end.y);
9059
+ ctx.stroke();
9060
+ ctx.setLineDash([]);
9061
+ ctx.fillStyle = m.color;
9062
+ const dotRadius = 4;
9063
+ ctx.beginPath();
9064
+ ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
9065
+ ctx.fill();
9066
+ ctx.beginPath();
9067
+ ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
9068
+ ctx.fill();
9069
+ const label = formatMeasureLabel(m.feet);
9070
+ const midX = (m.start.x + m.end.x) / 2;
9071
+ const midY = (m.start.y + m.end.y) / 2;
9072
+ ctx.font = "14px sans-serif";
9073
+ const metrics = ctx.measureText(label);
9074
+ const padX = 6;
9075
+ const padY = 4;
9076
+ const textH = 14;
9077
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
9078
+ ctx.beginPath();
9079
+ ctx.roundRect(
9080
+ midX - metrics.width / 2 - padX,
9081
+ midY - textH / 2 - padY,
9082
+ metrics.width + padX * 2,
9083
+ textH + padY * 2,
9084
+ 4
9085
+ );
9086
+ ctx.fill();
9087
+ ctx.fillStyle = "#FFFFFF";
9088
+ ctx.textAlign = "center";
9089
+ ctx.textBaseline = "middle";
9090
+ ctx.fillText(label, midX, midY);
9091
+ ctx.restore();
9092
+ }
9093
+
9094
+ // src/canvas/remote-measure-overlay.ts
9095
+ var MEASURE_PRESENCE_KIND = "measure";
9096
+ function isFinitePoint2(value) {
9097
+ if (typeof value !== "object" || value === null) return false;
9098
+ const point = value;
9099
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
9100
+ }
9101
+ function isMeasurePresence(data) {
9102
+ if (typeof data !== "object" || data === null) return false;
9103
+ const payload = data;
9104
+ if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
9105
+ if ("cleared" in payload) return payload.cleared === true;
9106
+ if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
9107
+ if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
9108
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
9109
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
9110
+ return true;
9111
+ }
9112
+ function toMeasurePresence(emission) {
9113
+ if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
9114
+ return {
9115
+ kind: MEASURE_PRESENCE_KIND,
9116
+ start: emission.start,
9117
+ end: emission.end,
9118
+ cells: emission.cells,
9119
+ feet: emission.feet,
9120
+ color: emission.color
9121
+ };
9122
+ }
9123
+ var DEFAULT_COLOR3 = "#FF5722";
9124
+ var DEFAULT_HOLD_MS = 1500;
9125
+ var DEFAULT_FADE_MS2 = 400;
9126
+ var DEFAULT_MAX_AGE_MS = 3e4;
9127
+ var RemoteMeasureOverlay = class {
9128
+ host;
9129
+ color;
9130
+ holdMs;
9131
+ fadeMs;
9132
+ maxAgeMs;
9133
+ measurements = /* @__PURE__ */ new Map();
9134
+ unregister;
9135
+ rafId = null;
9136
+ disposed = false;
9137
+ constructor(host, options = {}) {
9138
+ this.host = host;
9139
+ this.color = options.color ?? DEFAULT_COLOR3;
9140
+ this.holdMs = options.holdMs ?? DEFAULT_HOLD_MS;
9141
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
9142
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
9143
+ this.unregister = host.registerOverlay((ctx) => this.renderMeasurements(ctx));
9144
+ }
9145
+ now() {
9146
+ return performance.now();
9147
+ }
9148
+ /**
9149
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
9150
+ * the envelope `from`). Non-measure or malformed payloads are ignored and
9151
+ * reported as `false`, so hosts can feed every presence frame through.
9152
+ */
9153
+ apply(sender, data) {
9154
+ if (this.disposed || !isMeasurePresence(data)) return false;
9155
+ if ("cleared" in data) {
9156
+ this.beginLinger(sender);
9157
+ return true;
9158
+ }
9159
+ const existing = this.measurements.get(sender);
9160
+ if (existing?.expiryTimer != null) clearTimeout(existing.expiryTimer);
9161
+ this.measurements.set(sender, {
9162
+ start: data.start,
9163
+ end: data.end,
9164
+ feet: data.feet,
9165
+ color: data.color ?? this.color,
9166
+ clearedAt: null,
9167
+ expiryTimer: setTimeout(() => this.beginLinger(sender), this.maxAgeMs)
9168
+ });
9169
+ this.host.requestRender();
9170
+ return true;
9171
+ }
9172
+ /** Removes a sender's ruler immediately (presence-leave/disconnect). */
9173
+ remove(sender) {
9174
+ const entry = this.measurements.get(sender);
9175
+ if (!entry) return;
9176
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9177
+ this.measurements.delete(sender);
9178
+ this.host.requestRender();
9179
+ }
9180
+ /** Removes every ruler immediately. */
9181
+ clear() {
9182
+ if (this.measurements.size === 0) return;
9183
+ for (const entry of this.measurements.values()) {
9184
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9185
+ }
9186
+ this.measurements.clear();
9187
+ this.host.requestRender();
9188
+ }
9189
+ /** Number of senders with a visible (active or lingering) ruler. */
9190
+ get activeSenderCount() {
9191
+ return this.measurements.size;
9192
+ }
9193
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
9194
+ dispose() {
9195
+ if (this.disposed) return;
9196
+ this.disposed = true;
9197
+ if (this.rafId !== null) {
9198
+ cancelAnimationFrame(this.rafId);
9199
+ this.rafId = null;
9200
+ }
9201
+ for (const entry of this.measurements.values()) {
9202
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9203
+ }
9204
+ this.measurements.clear();
9205
+ this.unregister?.();
9206
+ this.unregister = null;
9207
+ this.host.requestRender();
9208
+ }
9209
+ beginLinger(sender) {
9210
+ const entry = this.measurements.get(sender);
9211
+ if (!entry || entry.clearedAt !== null) return;
9212
+ if (entry.expiryTimer != null) {
9213
+ clearTimeout(entry.expiryTimer);
9214
+ entry.expiryTimer = null;
9215
+ }
9216
+ entry.clearedAt = this.now();
9217
+ this.ensureAnimating();
9218
+ this.host.requestRender();
9219
+ }
9220
+ ensureAnimating() {
9221
+ if (this.rafId === null) {
9222
+ this.rafId = requestAnimationFrame(() => this.tick());
9223
+ }
9224
+ }
9225
+ tick() {
9226
+ if (this.disposed) return;
9227
+ const now = this.now();
9228
+ let lingering = 0;
9229
+ for (const [sender, entry] of this.measurements) {
9230
+ if (entry.clearedAt === null) continue;
9231
+ if (now - entry.clearedAt >= this.holdMs + this.fadeMs) {
9232
+ this.measurements.delete(sender);
9233
+ } else {
9234
+ lingering += 1;
9235
+ }
9236
+ }
9237
+ this.host.requestRender();
9238
+ this.rafId = lingering > 0 ? requestAnimationFrame(() => this.tick()) : null;
9239
+ }
9240
+ renderMeasurements(ctx) {
9241
+ if (this.measurements.size === 0) return;
9242
+ const now = this.now();
9243
+ for (const entry of this.measurements.values()) {
9244
+ let alpha = 1;
9245
+ if (entry.clearedAt !== null) {
9246
+ const fadeAge = now - entry.clearedAt - this.holdMs;
9247
+ if (fadeAge > 0) alpha = Math.max(0, 1 - fadeAge / this.fadeMs);
9248
+ }
9249
+ drawMeasurement(ctx, entry, { alpha });
9250
+ }
9251
+ }
9252
+ };
9253
+
8848
9254
  // src/canvas/ping-input.ts
8849
9255
  var DEFAULT_LONG_PRESS_MS = 600;
8850
9256
  var DEFAULT_SLOP_PX = 8;
8851
- var DEFAULT_COLOR3 = "#ff3b30";
9257
+ var DEFAULT_COLOR4 = "#ff3b30";
8852
9258
  var DEFAULT_DURATION_MS2 = 1800;
8853
9259
  var DEFAULT_RADIUS2 = 48;
8854
9260
  var DEFAULT_MIN_INTERVAL_MS = 300;
@@ -8881,7 +9287,7 @@ var PingInput = class {
8881
9287
  this.longPressEnabled = options.longPressEnabled ?? false;
8882
9288
  this.longPressMs = options.longPressMs ?? DEFAULT_LONG_PRESS_MS;
8883
9289
  this.slopPx = options.slopPx ?? DEFAULT_SLOP_PX;
8884
- this.color = options.color ?? DEFAULT_COLOR3;
9290
+ this.color = options.color ?? DEFAULT_COLOR4;
8885
9291
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
8886
9292
  this.radius = options.radius ?? DEFAULT_RADIUS2;
8887
9293
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
@@ -11171,21 +11577,37 @@ var MeasureTool = class {
11171
11577
  gridType;
11172
11578
  hexOrientation;
11173
11579
  feetPerCell;
11580
+ color;
11174
11581
  optionListeners = /* @__PURE__ */ new Set();
11582
+ measurementListeners = /* @__PURE__ */ new Set();
11583
+ emissionRafId = null;
11175
11584
  constructor(options = {}) {
11176
11585
  this.feetPerCell = options.feetPerCell ?? 5;
11586
+ this.color = options.color ?? "#FF5722";
11177
11587
  }
11178
11588
  getOptions() {
11179
- return { feetPerCell: this.feetPerCell };
11589
+ return { feetPerCell: this.feetPerCell, color: this.color };
11180
11590
  }
11181
11591
  setOptions(options) {
11182
11592
  if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
11593
+ if (options.color !== void 0) this.color = options.color;
11183
11594
  this.notifyOptionsChange();
11184
11595
  }
11185
11596
  onOptionsChange(listener) {
11186
11597
  this.optionListeners.add(listener);
11187
11598
  return () => this.optionListeners.delete(listener);
11188
11599
  }
11600
+ /**
11601
+ * Subscribes to raf-coalesced measurement snapshots. While a measurement is
11602
+ * in progress, listeners receive at most one snapshot per animation frame
11603
+ * carrying the latest state; `null` is delivered synchronously when the
11604
+ * measurement clears (pointer-up or deactivate). Emissions are ephemeral by
11605
+ * contract: presence only — never elements, history, or persisted state.
11606
+ */
11607
+ onMeasurement(listener) {
11608
+ this.measurementListeners.add(listener);
11609
+ return () => this.measurementListeners.delete(listener);
11610
+ }
11189
11611
  onPointerDown(state, ctx) {
11190
11612
  this.gridSize = ctx.gridSize ?? 1;
11191
11613
  this.gridType = ctx.gridType;
@@ -11193,22 +11615,27 @@ var MeasureTool = class {
11193
11615
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11194
11616
  this.start = this.snapToGrid(world, ctx);
11195
11617
  this.end = { ...this.start };
11618
+ this.scheduleEmission();
11196
11619
  }
11197
11620
  onPointerMove(state, ctx) {
11198
11621
  if (!this.start) return;
11199
11622
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11200
11623
  this.end = this.snapToGrid(world, ctx);
11201
11624
  ctx.requestRender();
11625
+ this.scheduleEmission();
11202
11626
  }
11203
11627
  onPointerUp(_state, ctx) {
11204
11628
  if (!this.start) return;
11205
11629
  this.start = null;
11206
11630
  this.end = null;
11207
11631
  ctx.requestRender();
11632
+ this.emitClear();
11208
11633
  }
11209
11634
  onDeactivate(_ctx) {
11635
+ const wasActive = this.start !== null;
11210
11636
  this.start = null;
11211
11637
  this.end = null;
11638
+ if (wasActive) this.emitClear();
11212
11639
  }
11213
11640
  getMeasurement() {
11214
11641
  if (!this.start || !this.end) return null;
@@ -11234,46 +11661,7 @@ var MeasureTool = class {
11234
11661
  renderOverlay(ctx) {
11235
11662
  const m = this.getMeasurement();
11236
11663
  if (!m) return;
11237
- ctx.save();
11238
- ctx.strokeStyle = "#FF5722";
11239
- ctx.setLineDash([8, 4]);
11240
- ctx.lineWidth = 2;
11241
- ctx.beginPath();
11242
- ctx.moveTo(m.start.x, m.start.y);
11243
- ctx.lineTo(m.end.x, m.end.y);
11244
- ctx.stroke();
11245
- ctx.setLineDash([]);
11246
- ctx.fillStyle = "#FF5722";
11247
- const dotRadius = 4;
11248
- ctx.beginPath();
11249
- ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
11250
- ctx.fill();
11251
- ctx.beginPath();
11252
- ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
11253
- ctx.fill();
11254
- const label = `${Math.round(m.feet)} ft`;
11255
- const midX = (m.start.x + m.end.x) / 2;
11256
- const midY = (m.start.y + m.end.y) / 2;
11257
- ctx.font = "14px sans-serif";
11258
- const metrics = ctx.measureText(label);
11259
- const padX = 6;
11260
- const padY = 4;
11261
- const textH = 14;
11262
- ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
11263
- ctx.beginPath();
11264
- ctx.roundRect(
11265
- midX - metrics.width / 2 - padX,
11266
- midY - textH / 2 - padY,
11267
- metrics.width + padX * 2,
11268
- textH + padY * 2,
11269
- 4
11270
- );
11271
- ctx.fill();
11272
- ctx.fillStyle = "#FFFFFF";
11273
- ctx.textAlign = "center";
11274
- ctx.textBaseline = "middle";
11275
- ctx.fillText(label, midX, midY);
11276
- ctx.restore();
11664
+ drawMeasurement(ctx, { start: m.start, end: m.end, feet: m.feet, color: this.color });
11277
11665
  }
11278
11666
  snapToGrid(point, ctx) {
11279
11667
  if (!ctx.gridSize) return point;
@@ -11291,6 +11679,32 @@ var MeasureTool = class {
11291
11679
  notifyOptionsChange() {
11292
11680
  for (const listener of this.optionListeners) listener();
11293
11681
  }
11682
+ scheduleEmission() {
11683
+ if (this.measurementListeners.size === 0) return;
11684
+ if (this.emissionRafId !== null) return;
11685
+ this.emissionRafId = requestAnimationFrame(() => {
11686
+ this.emissionRafId = null;
11687
+ const m = this.getMeasurement();
11688
+ if (!m) return;
11689
+ this.emit({ ...m, color: this.color });
11690
+ });
11691
+ }
11692
+ emitClear() {
11693
+ if (this.emissionRafId !== null) {
11694
+ cancelAnimationFrame(this.emissionRafId);
11695
+ this.emissionRafId = null;
11696
+ }
11697
+ if (this.measurementListeners.size === 0) return;
11698
+ this.emit(null);
11699
+ }
11700
+ emit(emission) {
11701
+ for (const listener of this.measurementListeners) {
11702
+ try {
11703
+ listener(emission);
11704
+ } catch {
11705
+ }
11706
+ }
11707
+ }
11294
11708
  };
11295
11709
 
11296
11710
  // src/tools/template-tool.ts
@@ -11586,9 +12000,9 @@ var TemplateTool = class {
11586
12000
  };
11587
12001
 
11588
12002
  // src/tools/laser-tool.ts
11589
- var DEFAULT_COLOR4 = "#ff3b30";
11590
- var DEFAULT_WIDTH2 = 4;
11591
- var DEFAULT_FADE_MS2 = 1200;
12003
+ var DEFAULT_COLOR5 = "#ff3b30";
12004
+ var DEFAULT_WIDTH3 = 4;
12005
+ var DEFAULT_FADE_MS3 = 1200;
11592
12006
  var LaserTool = class {
11593
12007
  name;
11594
12008
  color;
@@ -11602,9 +12016,9 @@ var LaserTool = class {
11602
12016
  pendingEmission = [];
11603
12017
  constructor(options = {}) {
11604
12018
  this.name = options.name ?? "laser";
11605
- this.color = options.color ?? DEFAULT_COLOR4;
11606
- this.width = options.width ?? DEFAULT_WIDTH2;
11607
- this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
12019
+ this.color = options.color ?? DEFAULT_COLOR5;
12020
+ this.width = options.width ?? DEFAULT_WIDTH3;
12021
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS3;
11608
12022
  }
11609
12023
  now() {
11610
12024
  return performance.now();
@@ -11730,7 +12144,7 @@ var LaserTool = class {
11730
12144
  };
11731
12145
 
11732
12146
  // src/tools/ping-tool.ts
11733
- var DEFAULT_COLOR5 = "#ff3b30";
12147
+ var DEFAULT_COLOR6 = "#ff3b30";
11734
12148
  var DEFAULT_DURATION_MS3 = 1800;
11735
12149
  var DEFAULT_RADIUS4 = 48;
11736
12150
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
@@ -11747,7 +12161,7 @@ var PingTool = class {
11747
12161
  pingListeners = /* @__PURE__ */ new Set();
11748
12162
  constructor(options = {}) {
11749
12163
  this.name = options.name ?? "ping";
11750
- this.color = options.color ?? DEFAULT_COLOR5;
12164
+ this.color = options.color ?? DEFAULT_COLOR6;
11751
12165
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
11752
12166
  this.radius = options.radius ?? DEFAULT_RADIUS4;
11753
12167
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
@@ -11850,7 +12264,7 @@ var PingTool = class {
11850
12264
  };
11851
12265
 
11852
12266
  // src/index.ts
11853
- var VERSION = "0.56.0";
12267
+ var VERSION = "0.58.0";
11854
12268
  export {
11855
12269
  ArrowTool,
11856
12270
  AutoSave,
@@ -11866,14 +12280,17 @@ export {
11866
12280
  LaserTool,
11867
12281
  LayerManager,
11868
12282
  LocalStorageAdapter,
12283
+ MEASURE_PRESENCE_KIND,
11869
12284
  MeasureTool,
11870
12285
  MemoryAdapter,
12286
+ MinimapController,
11871
12287
  NoteTool,
11872
12288
  PING_PRESENCE_KIND,
11873
12289
  PencilTool,
11874
12290
  PingInput,
11875
12291
  PingTool,
11876
12292
  RemoteLaserOverlay,
12293
+ RemoteMeasureOverlay,
11877
12294
  RemotePingOverlay,
11878
12295
  SelectTool,
11879
12296
  ShapeTool,
@@ -11911,6 +12328,7 @@ export {
11911
12328
  getHexCellsInSquare,
11912
12329
  getHexDistance,
11913
12330
  isLaserTrailPresence,
12331
+ isMeasurePresence,
11914
12332
  isNearBezier,
11915
12333
  isPingPresence,
11916
12334
  setFontSize,
@@ -11919,6 +12337,7 @@ export {
11919
12337
  snapToHexCenter,
11920
12338
  styleToPatch,
11921
12339
  toLaserTrailPresence,
12340
+ toMeasurePresence,
11922
12341
  toPingPresence,
11923
12342
  toggleBold,
11924
12343
  toggleItalic,