@fieldnotes/core 0.61.0 → 0.62.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
@@ -1774,6 +1774,10 @@ var PanInertia = class {
1774
1774
  cancel() {
1775
1775
  this.reset();
1776
1776
  }
1777
+ /** Whether a coast frame is scheduled, i.e. the camera is still gliding. */
1778
+ isCoasting() {
1779
+ return this.rafId !== null;
1780
+ }
1777
1781
  step = () => {
1778
1782
  if (this.rafId === null) return;
1779
1783
  this.deps.pan(this.vx, this.vy);
@@ -1887,6 +1891,7 @@ var InputHandler = class {
1887
1891
  openContextMenu;
1888
1892
  panInertia;
1889
1893
  panInertiaEnabled;
1894
+ coastStoppedByPointer = false;
1890
1895
  setToolManager(toolManager, toolContext) {
1891
1896
  this.toolManager = toolManager;
1892
1897
  this.toolContext = toolContext;
@@ -1897,8 +1902,17 @@ var InputHandler = class {
1897
1902
  get shortcuts() {
1898
1903
  return this.keyboard.shortcuts;
1899
1904
  }
1905
+ /**
1906
+ * True while the camera glides under pan inertia, and while the pointer
1907
+ * gesture that just stopped such a glide is still down. Passive observers use
1908
+ * it to ignore a gesture that was only meant to halt the coast.
1909
+ */
1910
+ isCameraCoasting() {
1911
+ return this.panInertia.isCoasting() || this.coastStoppedByPointer;
1912
+ }
1900
1913
  destroy() {
1901
1914
  this.panInertia.cancel();
1915
+ this.coastStoppedByPointer = false;
1902
1916
  this.actions.dispose();
1903
1917
  this.abortController.abort();
1904
1918
  this.inputFilter.reset();
@@ -1919,6 +1933,8 @@ var InputHandler = class {
1919
1933
  this.element.addEventListener("pointerleave", this.onPointerLeave, opts);
1920
1934
  this.element.addEventListener("pointercancel", this.onPointerUp, opts);
1921
1935
  this.element.addEventListener("contextmenu", this.onContextMenu, opts);
1936
+ window.addEventListener("blur", this.onCoastInterrupt, opts);
1937
+ window.addEventListener("visibilitychange", this.onCoastInterrupt, opts);
1922
1938
  }
1923
1939
  onWheel = (e) => {
1924
1940
  e.preventDefault();
@@ -1932,6 +1948,7 @@ var InputHandler = class {
1932
1948
  });
1933
1949
  };
1934
1950
  onPointerDown = (e) => {
1951
+ if (this.panInertia.isCoasting()) this.coastStoppedByPointer = true;
1935
1952
  this.panInertia.cancel();
1936
1953
  this.focusSelf();
1937
1954
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@ -2012,6 +2029,7 @@ var InputHandler = class {
2012
2029
  this.isPanning = false;
2013
2030
  this.panInertia.release();
2014
2031
  }
2032
+ if (this.activePointers.size === 0) this.coastStoppedByPointer = false;
2015
2033
  const upResult = this.inputFilter.filterUp(e);
2016
2034
  if (this.isToolActive) {
2017
2035
  this.dispatchToolUp(e);
@@ -2091,6 +2109,12 @@ var InputHandler = class {
2091
2109
  this.lastPointerEvent = null;
2092
2110
  this.onPointerUp(e);
2093
2111
  };
2112
+ // Recovers `coastStoppedByPointer` when the gesture that set it never delivers
2113
+ // a matching pointerup/cancel/leave. Deliberately narrow: it clears only this
2114
+ // flag, not `activePointers`/`isPanning`, which is a separate pre-existing gap.
2115
+ onCoastInterrupt = () => {
2116
+ this.coastStoppedByPointer = false;
2117
+ };
2094
2118
  toPointerState(e) {
2095
2119
  const rect = this.element.getBoundingClientRect();
2096
2120
  return {
@@ -3904,6 +3928,157 @@ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
3904
3928
  }
3905
3929
  }
3906
3930
 
3931
+ // src/canvas/html-painter-registry.ts
3932
+ var HtmlPainterMissingError = class extends Error {
3933
+ elementId;
3934
+ htmlType;
3935
+ constructor(elementId, htmlType) {
3936
+ super(`[fieldnotes] no painter registered for canvas-backed htmlType "${htmlType ?? ""}"`);
3937
+ this.name = "HtmlPainterMissingError";
3938
+ this.elementId = elementId;
3939
+ this.htmlType = htmlType;
3940
+ }
3941
+ };
3942
+ var HtmlPainterRegistry = class {
3943
+ painters = /* @__PURE__ */ new Map();
3944
+ declared = /* @__PURE__ */ new Map();
3945
+ listeners = /* @__PURE__ */ new Set();
3946
+ _version = 0;
3947
+ canvasTypesCache = null;
3948
+ get version() {
3949
+ return this._version;
3950
+ }
3951
+ /**
3952
+ * Memoized: this is the hottest read in the feature — `isDomElement` consults it several
3953
+ * times per element per frame, and every html-element store update re-reconciles every
3954
+ * html element. The cache is dropped in `bump()`, which is called on exactly the
3955
+ * transitions that can change the membership of this set (first `expect` of a type, last
3956
+ * release of a type, any `register`, and any unregister that empties a type's stack).
3957
+ *
3958
+ * The returned `Set` is the LIVE memoized instance, not a copy: callers must not mutate
3959
+ * it, and a caller that holds it across a `bump()` holds a stale snapshot. Nothing in the
3960
+ * codebase does either — `withHtmlDefaults` passes it straight through to the exporters
3961
+ * unless the caller supplied `expectedCanvasTypes`, in which case it builds a fresh union
3962
+ * — and holding it across a change was already a stale snapshot before memoization.
3963
+ */
3964
+ get canvasTypes() {
3965
+ const cached = this.canvasTypesCache;
3966
+ if (cached) return cached;
3967
+ const types = new Set(this.declared.keys());
3968
+ for (const [type, stack] of this.painters) {
3969
+ if (stack.length > 0) types.add(type);
3970
+ }
3971
+ this.canvasTypesCache = types;
3972
+ return types;
3973
+ }
3974
+ expect(htmlTypes) {
3975
+ const claimed = [...htmlTypes];
3976
+ let changed = false;
3977
+ for (const type of claimed) {
3978
+ const count = this.declared.get(type) ?? 0;
3979
+ if (count === 0) changed = true;
3980
+ this.declared.set(type, count + 1);
3981
+ }
3982
+ if (changed) this.bump();
3983
+ let released = false;
3984
+ return () => {
3985
+ if (released) return;
3986
+ released = true;
3987
+ let releaseChanged = false;
3988
+ for (const type of claimed) {
3989
+ const count = this.declared.get(type) ?? 0;
3990
+ if (count <= 1) {
3991
+ this.declared.delete(type);
3992
+ releaseChanged = true;
3993
+ } else {
3994
+ this.declared.set(type, count - 1);
3995
+ }
3996
+ }
3997
+ if (releaseChanged) this.bump();
3998
+ };
3999
+ }
4000
+ register(htmlType, painter) {
4001
+ const entry = { token: /* @__PURE__ */ Symbol("html-painter"), painter };
4002
+ const stack = this.painters.get(htmlType) ?? [];
4003
+ stack.push(entry);
4004
+ this.painters.set(htmlType, stack);
4005
+ this.bump();
4006
+ let released = false;
4007
+ return () => {
4008
+ if (released) return;
4009
+ released = true;
4010
+ const current = this.painters.get(htmlType);
4011
+ if (!current) return;
4012
+ const index = current.findIndex((candidate) => candidate.token === entry.token);
4013
+ if (index === -1) return;
4014
+ const wasActive = index === current.length - 1;
4015
+ current.splice(index, 1);
4016
+ if (current.length === 0) this.painters.delete(htmlType);
4017
+ if (wasActive) this.bump();
4018
+ };
4019
+ }
4020
+ getActivePainter(htmlType) {
4021
+ const stack = this.painters.get(htmlType);
4022
+ return stack && stack.length > 0 ? stack[stack.length - 1]?.painter : void 0;
4023
+ }
4024
+ onChange(listener) {
4025
+ this.listeners.add(listener);
4026
+ return () => this.listeners.delete(listener);
4027
+ }
4028
+ bump() {
4029
+ this.canvasTypesCache = null;
4030
+ this._version += 1;
4031
+ for (const listener of [...this.listeners]) {
4032
+ try {
4033
+ listener();
4034
+ } catch {
4035
+ }
4036
+ }
4037
+ }
4038
+ };
4039
+ function resolveHtmlRouting(el, registry, expectedCanvasTypes) {
4040
+ const htmlType = el.htmlType;
4041
+ if (htmlType === void 0) return "dom";
4042
+ const expected = (registry?.canvasTypes.has(htmlType) ?? false) || (expectedCanvasTypes?.has(htmlType) ?? false);
4043
+ if (!expected) return "dom";
4044
+ return registry?.getActivePainter(htmlType) ? "canvas" : "missing";
4045
+ }
4046
+
4047
+ // src/canvas/html-paint.ts
4048
+ function paintHtmlElement(el, painter, opts) {
4049
+ const { ctx, zoom, target, onDiagnostic } = opts;
4050
+ const { w, h } = el.size;
4051
+ if (!(w > 0) || !(h > 0)) {
4052
+ onDiagnostic?.({ kind: "degenerate-size", elementId: el.id, htmlType: el.htmlType, target });
4053
+ return;
4054
+ }
4055
+ ctx.save();
4056
+ try {
4057
+ const rotation = opts.applyRotation === false ? 0 : el.rotation ?? 0;
4058
+ if (rotation !== 0) {
4059
+ ctx.translate(el.position.x + w / 2, el.position.y + h / 2);
4060
+ ctx.rotate(rotation);
4061
+ ctx.translate(-w / 2, -h / 2);
4062
+ } else {
4063
+ ctx.translate(el.position.x, el.position.y);
4064
+ }
4065
+ ctx.beginPath();
4066
+ ctx.rect(0, 0, w, h);
4067
+ ctx.clip();
4068
+ painter({ ctx, element: el, size: { w, h }, zoom });
4069
+ } catch (error) {
4070
+ onDiagnostic?.({
4071
+ kind: "painter-threw",
4072
+ elementId: el.id,
4073
+ htmlType: el.htmlType,
4074
+ target,
4075
+ error
4076
+ });
4077
+ } finally {
4078
+ ctx.restore();
4079
+ }
4080
+ }
4081
+
3907
4082
  // src/elements/element-renderer.ts
3908
4083
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
3909
4084
  var ElementRenderer = class {
@@ -3917,6 +4092,11 @@ var ElementRenderer = class {
3917
4092
  hexTileCacheKey = "";
3918
4093
  gridBoundsOverride = null;
3919
4094
  labelEditingId = null;
4095
+ htmlPainters = null;
4096
+ expectedCanvasTypes;
4097
+ renderTarget = "screen";
4098
+ diagnosticSink = null;
4099
+ surfaceZoom = null;
3920
4100
  setStore(store) {
3921
4101
  this.store = store;
3922
4102
  }
@@ -3938,8 +4118,27 @@ var ElementRenderer = class {
3938
4118
  setLabelEditingId(id) {
3939
4119
  this.labelEditingId = id;
3940
4120
  }
4121
+ /** Registry of canvas-backed html painters, plus any types declared canvas-routed
4122
+ * before a painter is registered (`expectedCanvasTypes`). Null clears routing back
4123
+ * to legacy DOM-only behavior. */
4124
+ setHtmlPainters(registry, expected) {
4125
+ this.htmlPainters = registry;
4126
+ this.expectedCanvasTypes = expected;
4127
+ }
4128
+ setRenderTarget(target) {
4129
+ this.renderTarget = target;
4130
+ }
4131
+ setDiagnosticSink(sink) {
4132
+ this.diagnosticSink = sink;
4133
+ }
4134
+ /** Explicit zoom used for surfaces with no camera (minimap, export). Ignored for
4135
+ * the 'screen' target, which always reads the live camera zoom. */
4136
+ setSurfaceZoom(zoom) {
4137
+ this.surfaceZoom = zoom;
4138
+ }
3941
4139
  isDomElement(element) {
3942
- return DOM_ELEMENT_TYPES.has(element.type);
4140
+ if (element.type !== "html") return DOM_ELEMENT_TYPES.has(element.type);
4141
+ return resolveHtmlRouting(element, this.htmlPainters, this.expectedCanvasTypes) === "dom";
3943
4142
  }
3944
4143
  renderCanvasElement(ctx, element) {
3945
4144
  switch (element.type) {
@@ -3975,7 +4174,37 @@ var ElementRenderer = class {
3975
4174
  case "template":
3976
4175
  renderTemplate(ctx, element, this.store);
3977
4176
  break;
4177
+ case "html":
4178
+ this.renderHtml(ctx, element);
4179
+ break;
4180
+ }
4181
+ }
4182
+ /** Only reached for canvas-routed html (isDomElement already sent 'dom'-routed
4183
+ * elements down the DOM sync branch instead). */
4184
+ renderHtml(ctx, el) {
4185
+ const routing = resolveHtmlRouting(el, this.htmlPainters, this.expectedCanvasTypes);
4186
+ if (routing === "dom") return;
4187
+ if (routing === "missing") {
4188
+ this.diagnosticSink?.({
4189
+ kind: "missing-painter",
4190
+ elementId: el.id,
4191
+ htmlType: el.htmlType,
4192
+ target: this.renderTarget
4193
+ });
4194
+ return;
3978
4195
  }
4196
+ const painter = this.htmlPainters?.getActivePainter(el.htmlType ?? "");
4197
+ if (!painter) return;
4198
+ paintHtmlElement(el, painter, {
4199
+ ctx,
4200
+ zoom: this.zoomForTarget(),
4201
+ target: this.renderTarget,
4202
+ onDiagnostic: this.diagnosticSink ?? void 0
4203
+ });
4204
+ }
4205
+ zoomForTarget() {
4206
+ if (this.renderTarget === "screen") return this.camera?.zoom ?? 1;
4207
+ return this.surfaceZoom ?? 1;
3979
4208
  }
3980
4209
  renderGrid(ctx, grid) {
3981
4210
  const canvasSize = this.canvasSize;
@@ -4122,6 +4351,7 @@ function createHtmlElement(input) {
4122
4351
  if (input.interactive) el.interactive = input.interactive;
4123
4352
  if (input.htmlType) el.htmlType = input.htmlType;
4124
4353
  if (input.data) el.data = input.data;
4354
+ if (input.rotation !== void 0) el.rotation = input.rotation;
4125
4355
  return el;
4126
4356
  }
4127
4357
  function createShape(input) {
@@ -4702,7 +4932,6 @@ var DEFAULT_PADDING = 8;
4702
4932
  var DEFAULT_DEBOUNCE_MS2 = 200;
4703
4933
  var DEFAULT_VIEWPORT_STROKE = "#3b82f6";
4704
4934
  var NEUTRAL = "rgba(100,116,139,0.6)";
4705
- var DOM_FALLBACK_TYPES = /* @__PURE__ */ new Set(["note", "text", "html"]);
4706
4935
  function elementColor(el) {
4707
4936
  return "color" in el && typeof el.color === "string" ? el.color : NEUTRAL;
4708
4937
  }
@@ -4726,6 +4955,9 @@ var MinimapController = class {
4726
4955
  });
4727
4956
  this.renderer.setStore(viewport.store);
4728
4957
  this.renderer.setOnImageLoad(() => this.markSceneDirty());
4958
+ this.htmlPainters = viewport.getHtmlPainters();
4959
+ this.renderer.setHtmlPainters(this.htmlPainters);
4960
+ this.renderer.setRenderTarget("minimap");
4729
4961
  this.applyCanvasSize();
4730
4962
  const onScene = () => this.markSceneDirty();
4731
4963
  this.unsubs.push(
@@ -4735,7 +4967,8 @@ var MinimapController = class {
4735
4967
  viewport.store.on("clear", onScene),
4736
4968
  viewport.layerManager.on("change", onScene),
4737
4969
  viewport.camera.onChange(() => this.onViewChanged()),
4738
- viewport.onResize(() => this.onViewChanged())
4970
+ viewport.onResize(() => this.onViewChanged()),
4971
+ this.htmlPainters.onChange(() => this.invalidateScene())
4739
4972
  );
4740
4973
  if (this.interactive) {
4741
4974
  canvas.style.touchAction = "none";
@@ -4759,6 +4992,10 @@ var MinimapController = class {
4759
4992
  requestFrame;
4760
4993
  cancelFrame;
4761
4994
  renderer = new ElementRenderer();
4995
+ // Declared, not initialized here: a field initializer runs before parameter
4996
+ // properties are assigned (ES2022 useDefineForClassFields), so it cannot
4997
+ // read the `viewport` parameter. Assigned in the constructor body instead.
4998
+ htmlPainters;
4762
4999
  scene = null;
4763
5000
  frameId = null;
4764
5001
  debounceTimer = null;
@@ -4778,6 +5015,23 @@ var MinimapController = class {
4778
5015
  if (this.disposed || this.frameId !== null) return;
4779
5016
  this.frameId = this.requestFrame(this.draw);
4780
5017
  }
5018
+ /**
5019
+ * Invalidates the cached scene bitmap in response to html-painter registry
5020
+ * changes (a painter registering/unregistering, or an `expect` declaration
5021
+ * changing) and schedules the same debounced rebuild `markSceneDirty` uses
5022
+ * for every other invalidation source. Unlike those other sources — which
5023
+ * deliberately keep compositing the OLD bitmap until the rebuild lands, so
5024
+ * camera motion and content edits never stall on a render — painter
5025
+ * availability has no "old bitmap is still valid" reading: an element that
5026
+ * was falling back to a neutral fillRect (no painter yet) or a stale
5027
+ * painter's output is not a safe thing to keep showing, so the cached
5028
+ * bitmap is dropped immediately instead of composited a further time.
5029
+ */
5030
+ invalidateScene() {
5031
+ if (this.disposed) return;
5032
+ this.scene = null;
5033
+ this.markSceneDirty();
5034
+ }
4781
5035
  dispose() {
4782
5036
  if (this.disposed) return;
4783
5037
  this.disposed = true;
@@ -4843,6 +5097,7 @@ var MinimapController = class {
4843
5097
  const dpr = this.dpr();
4844
5098
  const mapping = this.currentMapping();
4845
5099
  const transform = computeMinimapTransform(mapping, this.width, this.height, this.padding);
5100
+ this.renderer.setSurfaceZoom(transform.scale);
4846
5101
  const sceneCanvas = document.createElement("canvas");
4847
5102
  sceneCanvas.width = Math.max(1, Math.round(this.width * dpr));
4848
5103
  sceneCanvas.height = Math.max(1, Math.round(this.height * dpr));
@@ -4880,7 +5135,7 @@ var MinimapController = class {
4880
5135
  }
4881
5136
  renderLayerElements(ctx, elements, t, dpr) {
4882
5137
  for (const el of elements) {
4883
- if (DOM_FALLBACK_TYPES.has(el.type)) {
5138
+ if (this.renderer.isDomElement(el)) {
4884
5139
  const b = getElementBounds(el);
4885
5140
  if (!b) continue;
4886
5141
  const tl = worldToMini(t, { x: b.x, y: b.y });
@@ -5960,7 +6215,28 @@ async function exportImage(store, options = {}, layerManager) {
5960
6215
  assertExportSize(width, height, options);
5961
6216
  const imageCache = await loadImages(visibleElements, options);
5962
6217
  const htmlElements = visibleElements.filter((el) => el.type === "html");
5963
- const htmlSources = await renderHtmlElements(htmlElements, options);
6218
+ const canvasRoutedIds = /* @__PURE__ */ new Set();
6219
+ const domHtmlElements = [];
6220
+ for (const el of htmlElements) {
6221
+ const routing = resolveHtmlRouting(
6222
+ el,
6223
+ options.htmlPainters ?? null,
6224
+ options.expectedCanvasTypes
6225
+ );
6226
+ if (routing === "missing") {
6227
+ if (options.strictMissingCanvasHtml) {
6228
+ throw new HtmlPainterMissingError(el.id, el.htmlType);
6229
+ }
6230
+ options.onHtmlError?.({ elementId: el.id, htmlType: el.htmlType, reason: "missing-painter" });
6231
+ continue;
6232
+ }
6233
+ if (routing === "canvas") {
6234
+ canvasRoutedIds.add(el.id);
6235
+ } else {
6236
+ domHtmlElements.push(el);
6237
+ }
6238
+ }
6239
+ const htmlSources = await renderHtmlElements(domHtmlElements, options);
5964
6240
  const canvas = document.createElement("canvas");
5965
6241
  canvas.width = width;
5966
6242
  canvas.height = height;
@@ -5972,6 +6248,14 @@ async function exportImage(store, options = {}, layerManager) {
5972
6248
  ctx.fillRect(bounds.x, bounds.y, bounds.w, bounds.h);
5973
6249
  const renderer = new ElementRenderer();
5974
6250
  renderer.setStore(store);
6251
+ const onHtmlPaintDiagnostic = (d) => {
6252
+ options.onHtmlError?.({
6253
+ elementId: d.elementId,
6254
+ htmlType: d.htmlType,
6255
+ reason: d.kind,
6256
+ cause: d.kind === "painter-threw" ? d.error : void 0
6257
+ });
6258
+ };
5975
6259
  const grids = [];
5976
6260
  const renderElement = (target, el) => {
5977
6261
  if (el.type === "note") {
@@ -5985,6 +6269,24 @@ async function exportImage(store, options = {}, layerManager) {
5985
6269
  return;
5986
6270
  }
5987
6271
  if (el.type === "html") {
6272
+ if (canvasRoutedIds.has(el.id)) {
6273
+ const painter = options.htmlPainters?.getActivePainter(el.htmlType ?? "");
6274
+ if (!painter) {
6275
+ options.onHtmlError?.({
6276
+ elementId: el.id,
6277
+ htmlType: el.htmlType,
6278
+ reason: "missing-painter"
6279
+ });
6280
+ return;
6281
+ }
6282
+ paintHtmlElement(el, painter, {
6283
+ ctx: target,
6284
+ zoom: scale,
6285
+ target: "export",
6286
+ onDiagnostic: onHtmlPaintDiagnostic
6287
+ });
6288
+ return;
6289
+ }
5988
6290
  const source = htmlSources.get(el.id);
5989
6291
  if (!source) return;
5990
6292
  const b = getElementBounds(el);
@@ -6379,8 +6681,36 @@ async function exportSvg(store, options = {}, layerManager) {
6379
6681
  const imageCache = await loadImages(remoteImages, options);
6380
6682
  const imageDataUris = encodeImages(visibleElements, imageCache, rasterScale, options);
6381
6683
  const htmlElements = visibleElements.filter((el) => el.type === "html");
6382
- const htmlSources = await renderHtmlElements(htmlElements, options);
6383
- const htmlDataUris = encodeHtmlElements(htmlElements, htmlSources, rasterScale, options);
6684
+ const canvasRoutedElements = [];
6685
+ const domHtmlElements = [];
6686
+ for (const el of htmlElements) {
6687
+ const routing = resolveHtmlRouting(
6688
+ el,
6689
+ options.htmlPainters ?? null,
6690
+ options.expectedCanvasTypes
6691
+ );
6692
+ if (routing === "missing") {
6693
+ if (options.strictMissingCanvasHtml) {
6694
+ throw new HtmlPainterMissingError(el.id, el.htmlType);
6695
+ }
6696
+ options.onHtmlError?.({ elementId: el.id, htmlType: el.htmlType, reason: "missing-painter" });
6697
+ continue;
6698
+ }
6699
+ if (routing === "canvas") {
6700
+ canvasRoutedElements.push(el);
6701
+ } else {
6702
+ domHtmlElements.push(el);
6703
+ }
6704
+ }
6705
+ const htmlSources = await renderHtmlElements(domHtmlElements, options);
6706
+ const htmlDataUris = encodeHtmlElements(domHtmlElements, htmlSources, rasterScale, options);
6707
+ const canvasHtmlDataUris = rasterizeCanvasRoutedHtml(
6708
+ canvasRoutedElements,
6709
+ options.htmlPainters,
6710
+ rasterScale,
6711
+ options
6712
+ );
6713
+ for (const [id, uri] of canvasHtmlDataUris) htmlDataUris.set(id, uri);
6384
6714
  const grids = visibleElements.filter((el) => el.type === "grid");
6385
6715
  const firstGrid = grids[0];
6386
6716
  let body = "";
@@ -6435,6 +6765,81 @@ function emitElement(el, imageDataUris, htmlDataUris, rasterScale, firstGrid, st
6435
6765
  return "";
6436
6766
  }
6437
6767
  }
6768
+ function rasterizeCanvasRoutedHtml(elements, registry, rasterScale, options) {
6769
+ const encoded = /* @__PURE__ */ new Map();
6770
+ const onDiagnostic = (d) => {
6771
+ options.onHtmlError?.({
6772
+ elementId: d.elementId,
6773
+ htmlType: d.htmlType,
6774
+ reason: d.kind,
6775
+ cause: d.kind === "painter-threw" ? d.error : void 0
6776
+ });
6777
+ };
6778
+ for (const element of elements) {
6779
+ const painter = registry?.getActivePainter(element.htmlType ?? "");
6780
+ if (!painter) {
6781
+ options.onHtmlError?.({
6782
+ elementId: element.id,
6783
+ htmlType: element.htmlType,
6784
+ reason: "missing-painter"
6785
+ });
6786
+ continue;
6787
+ }
6788
+ if (typeof document === "undefined") {
6789
+ options.onHtmlError?.({
6790
+ elementId: element.id,
6791
+ htmlType: element.htmlType,
6792
+ reason: "encode"
6793
+ });
6794
+ continue;
6795
+ }
6796
+ const width = Math.max(1, Math.ceil(element.size.w * rasterScale));
6797
+ const height = Math.max(1, Math.ceil(element.size.h * rasterScale));
6798
+ assertExportSize(width, height, options);
6799
+ const off = document.createElement("canvas");
6800
+ off.width = width;
6801
+ off.height = height;
6802
+ const octx = off.getContext("2d");
6803
+ if (!octx) {
6804
+ options.onHtmlError?.({
6805
+ elementId: element.id,
6806
+ htmlType: element.htmlType,
6807
+ reason: "encode"
6808
+ });
6809
+ continue;
6810
+ }
6811
+ octx.scale(rasterScale, rasterScale);
6812
+ octx.translate(-element.position.x, -element.position.y);
6813
+ paintHtmlElement(element, painter, {
6814
+ ctx: octx,
6815
+ zoom: rasterScale,
6816
+ target: "export",
6817
+ applyRotation: false,
6818
+ // withRotationSvg wraps the emitted <image>; rotating here too would double it
6819
+ onDiagnostic
6820
+ });
6821
+ try {
6822
+ const dataUri = off.toDataURL("image/png");
6823
+ if (dataUri.startsWith("data:")) {
6824
+ encoded.set(element.id, dataUri);
6825
+ } else {
6826
+ options.onHtmlError?.({
6827
+ elementId: element.id,
6828
+ htmlType: element.htmlType,
6829
+ reason: "encode"
6830
+ });
6831
+ }
6832
+ } catch (cause) {
6833
+ options.onHtmlError?.({
6834
+ elementId: element.id,
6835
+ htmlType: element.htmlType,
6836
+ reason: "encode",
6837
+ cause
6838
+ });
6839
+ }
6840
+ }
6841
+ return encoded;
6842
+ }
6438
6843
  function encodeHtmlElements(elements, sources, rasterScale, options) {
6439
6844
  const encoded = /* @__PURE__ */ new Map();
6440
6845
  for (const element of elements) {
@@ -6780,6 +7185,10 @@ var DomNodeManager = class {
6780
7185
  domNodes = /* @__PURE__ */ new Map();
6781
7186
  strata = /* @__PURE__ */ new Map();
6782
7187
  htmlContent = /* @__PURE__ */ new Map();
7188
+ /** Elements whose node carries host-supplied content (see `markHostOwnedContent`). */
7189
+ hostOwnedContent = /* @__PURE__ */ new Set();
7190
+ /** Host-owned nodes removed from the DOM but kept alive for a later remount. */
7191
+ preservedNodes = /* @__PURE__ */ new Map();
6783
7192
  domLayer;
6784
7193
  onEditRequest;
6785
7194
  isEditingElement;
@@ -6804,12 +7213,34 @@ var DomNodeManager = class {
6804
7213
  }
6805
7214
  storeHtmlContent(elementId, dom) {
6806
7215
  this.htmlContent.set(elementId, dom);
7216
+ this.lastSyncedVersion.delete(elementId);
7217
+ }
7218
+ /**
7219
+ * Marks an element's node as carrying content the HOST mounted into it directly
7220
+ * (`ViewportOptions.onHtmlElementMount`). That content is never recorded in
7221
+ * `htmlContent` — the host appends straight into the node — so a detach would
7222
+ * destroy it and no remount could ever bring it back. Preserving the node itself
7223
+ * (rather than a guessed-at child) keeps arbitrary subtrees, host-attached
7224
+ * listeners, and the host's own reference to the node all valid.
7225
+ *
7226
+ * Callers mark UNCONDITIONALLY, without inspecting the node — a host that only attaches
7227
+ * listeners or styles owns its node just as much as one that appended children, and
7228
+ * there is no way to tell those apart from the outside. Two consequences follow, both
7229
+ * accepted: a node the host never populated can still round-trip back into the DOM (the
7230
+ * documented exception to "never had content -> never remount", reachable only via
7231
+ * `onHtmlElementMount`), and one detached `<div>` is retained per such element for its
7232
+ * lifetime.
7233
+ */
7234
+ markHostOwnedContent(elementId) {
7235
+ this.hostOwnedContent.add(elementId);
6807
7236
  }
6808
7237
  hasContent(elementId) {
6809
7238
  return this.htmlContent.has(elementId);
6810
7239
  }
6811
7240
  resetHtmlContent(elementId) {
6812
7241
  this.htmlContent.delete(elementId);
7242
+ this.hostOwnedContent.delete(elementId);
7243
+ this.preservedNodes.delete(elementId);
6813
7244
  this.lastSyncedVersion.delete(elementId);
6814
7245
  this.lastSyncedZIndex.delete(elementId);
6815
7246
  this.lastSyncedOpacity.delete(elementId);
@@ -6823,12 +7254,18 @@ var DomNodeManager = class {
6823
7254
  syncDomNode(element, zIndex = 0, opacity = 1) {
6824
7255
  let node = this.domNodes.get(element.id);
6825
7256
  if (!node) {
6826
- node = document.createElement("div");
6827
- node.dataset["elementId"] = element.id;
6828
- Object.assign(node.style, {
6829
- position: "absolute",
6830
- pointerEvents: "auto"
6831
- });
7257
+ const preserved = this.preservedNodes.get(element.id);
7258
+ if (preserved) {
7259
+ this.preservedNodes.delete(element.id);
7260
+ node = preserved;
7261
+ } else {
7262
+ node = document.createElement("div");
7263
+ node.dataset["elementId"] = element.id;
7264
+ Object.assign(node.style, {
7265
+ position: "absolute",
7266
+ pointerEvents: "auto"
7267
+ });
7268
+ }
6832
7269
  this.getStratum(zIndex).appendChild(node);
6833
7270
  this.domNodes.set(element.id, node);
6834
7271
  } else if (this.getVersion) {
@@ -6877,18 +7314,51 @@ var DomNodeManager = class {
6877
7314
  }
6878
7315
  removeDomNode(id) {
6879
7316
  this.htmlContent.delete(id);
7317
+ this.hostOwnedContent.delete(id);
7318
+ this.preservedNodes.delete(id);
7319
+ this.detachNodeElement(id);
7320
+ }
7321
+ /** Removes the node but KEEPS htmlContent, so a later re-mount restores the original embed.
7322
+ * The registry factory that produces embed content only runs in loadState (G1), so dropping
7323
+ * content here would be unrecoverable. For a host-owned node there is no recorded content at
7324
+ * all, so the node ITSELF is kept alive off-DOM and reattached by `syncDomNode`.
7325
+ * Use `removeDomNode` when the element itself is gone. */
7326
+ detachDomNode(id) {
7327
+ if (this.hostOwnedContent.has(id)) {
7328
+ const node = this.domNodes.get(id);
7329
+ if (node) this.preservedNodes.set(id, node);
7330
+ }
7331
+ this.detachNodeElement(id);
7332
+ }
7333
+ /** Shared by `removeDomNode` and `detachDomNode`: clears dirty-tracking caches, removes the
7334
+ * node from the DOM, and cleans up its stratum if now empty. Does NOT touch `htmlContent` —
7335
+ * that distinction is each caller's own responsibility. */
7336
+ detachNodeElement(id) {
6880
7337
  this.lastSyncedVersion.delete(id);
6881
7338
  this.lastSyncedZIndex.delete(id);
6882
7339
  this.lastSyncedOpacity.delete(id);
6883
7340
  const node = this.domNodes.get(id);
6884
- if (node) {
6885
- const stratum = node.parentElement;
6886
- node.remove();
6887
- this.domNodes.delete(id);
6888
- if (stratum?.childElementCount === 0) {
6889
- const order = Number(stratum.dataset["paintOrder"]);
6890
- stratum.remove();
6891
- this.strata.delete(order);
7341
+ if (!node) return;
7342
+ const stratum = node.parentElement;
7343
+ node.remove();
7344
+ this.domNodes.delete(id);
7345
+ if (stratum?.childElementCount === 0) {
7346
+ const order = Number(stratum.dataset["paintOrder"]);
7347
+ stratum.remove();
7348
+ this.strata.delete(order);
7349
+ }
7350
+ }
7351
+ /** Reconciles BOTH directions synchronously. Canvas/missing routing detaches the node
7352
+ * (content preserved); dom routing remounts preserved content immediately, so a painter
7353
+ * unregistration does not wait for an unrelated render pass. */
7354
+ reconcileHtmlRouting(store, resolve) {
7355
+ for (const el of store.getElementsByType("html")) {
7356
+ if (resolve(el) !== "dom") {
7357
+ this.detachDomNode(el.id);
7358
+ continue;
7359
+ }
7360
+ if ((this.htmlContent.has(el.id) || this.preservedNodes.has(el.id)) && !this.domNodes.has(el.id)) {
7361
+ this.syncDomNode(el);
6892
7362
  }
6893
7363
  }
6894
7364
  }
@@ -6896,6 +7366,8 @@ var DomNodeManager = class {
6896
7366
  this.domNodes.forEach((node) => node.remove());
6897
7367
  this.domNodes.clear();
6898
7368
  this.htmlContent.clear();
7369
+ this.hostOwnedContent.clear();
7370
+ this.preservedNodes.clear();
6899
7371
  this.lastSyncedVersion.clear();
6900
7372
  this.lastSyncedZIndex.clear();
6901
7373
  this.lastSyncedOpacity.clear();
@@ -6968,8 +7440,8 @@ var DomNodeManager = class {
6968
7440
  }
6969
7441
  }
6970
7442
  if (element.type === "html") {
7443
+ const content = this.htmlContent.get(element.id);
6971
7444
  if (!node.dataset["initialized"]) {
6972
- const content = this.htmlContent.get(element.id);
6973
7445
  if (content) {
6974
7446
  node.dataset["initialized"] = "true";
6975
7447
  Object.assign(node.style, {
@@ -6977,6 +7449,8 @@ var DomNodeManager = class {
6977
7449
  pointerEvents: element.interactive ? "auto" : "none"
6978
7450
  });
6979
7451
  node.appendChild(content);
7452
+ } else {
7453
+ node.style.pointerEvents = "none";
6980
7454
  }
6981
7455
  } else {
6982
7456
  node.style.pointerEvents = element.interactive ? "auto" : "none";
@@ -7111,6 +7585,8 @@ var RenderLoop = class {
7111
7585
  gridCacheCanvas = null;
7112
7586
  gridCacheCtx = null;
7113
7587
  lastGridRefs = [];
7588
+ htmlScratchCanvas = null;
7589
+ htmlScratchCtx = null;
7114
7590
  constructor(deps) {
7115
7591
  this.canvasEl = deps.canvasEl;
7116
7592
  this.camera = deps.camera;
@@ -7221,6 +7697,117 @@ var RenderLoop = class {
7221
7697
  }
7222
7698
  this.gridCacheCtx = this.gridCacheCanvas.getContext("2d");
7223
7699
  }
7700
+ /**
7701
+ * Lazily allocated, and only ever touched by the translucent-layer html branch below.
7702
+ * Grow-only: the branch runs once per element per frame, and reassigning `width` or
7703
+ * `height` reallocates the backing store, so shrinking to each element in turn would
7704
+ * thrash. The requested size is always clipped to the visible canvas by
7705
+ * `htmlScratchRect`, so this stays bounded by the canvas itself.
7706
+ */
7707
+ ensureHtmlScratch(w, h) {
7708
+ if (this.htmlScratchCanvas === null || this.htmlScratchCtx === null) {
7709
+ if (typeof OffscreenCanvas !== "undefined") {
7710
+ this.htmlScratchCanvas = new OffscreenCanvas(w, h);
7711
+ } else if (typeof document !== "undefined") {
7712
+ this.htmlScratchCanvas = document.createElement("canvas");
7713
+ } else {
7714
+ return null;
7715
+ }
7716
+ this.htmlScratchCtx = this.htmlScratchCanvas.getContext(
7717
+ "2d"
7718
+ );
7719
+ if (this.htmlScratchCtx === null) {
7720
+ this.htmlScratchCanvas = null;
7721
+ return null;
7722
+ }
7723
+ }
7724
+ if (this.htmlScratchCanvas.width < w) this.htmlScratchCanvas.width = w;
7725
+ if (this.htmlScratchCanvas.height < h) this.htmlScratchCanvas.height = h;
7726
+ return this.htmlScratchCtx;
7727
+ }
7728
+ /**
7729
+ * The device-pixel footprint an element occupies on the hybrid surface: its world
7730
+ * bounds mapped through the same `scale(dpr) -> translate(cam) -> scale(zoom)` chain
7731
+ * the hybrid context uses, snapped OUT to whole pixels (so the clip edge's antialiased
7732
+ * pixel is included) and intersected with the surface.
7733
+ *
7734
+ * Null when nothing of the element lands on the surface. That includes the case where
7735
+ * the surface has no pixels at all: a host hiding the viewport (`display: none`) drives
7736
+ * `canvasEl.width` to 0 via `syncCanvasSize`, culling does not go degenerate with it,
7737
+ * and a zero-dimension canvas throws `InvalidStateError` when used as a `drawImage`
7738
+ * SOURCE — which would escape `render()` and kill the frame loop permanently.
7739
+ * Non-finite bounds fail the same `>= 1` test and are rejected here too.
7740
+ */
7741
+ htmlScratchRect(bounds, dpr) {
7742
+ let left = 0;
7743
+ let top = 0;
7744
+ let right = this.canvasEl.width;
7745
+ let bottom = this.canvasEl.height;
7746
+ if (bounds) {
7747
+ const zoom = this.camera.zoom;
7748
+ const camX = this.camera.position.x;
7749
+ const camY = this.camera.position.y;
7750
+ left = Math.max(left, Math.floor((bounds.x * zoom + camX) * dpr));
7751
+ top = Math.max(top, Math.floor((bounds.y * zoom + camY) * dpr));
7752
+ right = Math.min(right, Math.ceil(((bounds.x + bounds.w) * zoom + camX) * dpr));
7753
+ bottom = Math.min(bottom, Math.ceil(((bounds.y + bounds.h) * zoom + camY) * dpr));
7754
+ }
7755
+ const w = right - left;
7756
+ const h = bottom - top;
7757
+ if (!(w >= 1) || !(h >= 1)) return null;
7758
+ return { x: left, y: top, w, h };
7759
+ }
7760
+ /**
7761
+ * Draws one canvas-routed html element on the hybrid stratum with the layer-opacity
7762
+ * boundary the painter contract requires: paint at `globalAlpha === 1` into a scratch
7763
+ * surface, then composite that raster at the layer's opacity. Mirrors `exportImage`'s
7764
+ * per-layer temp canvas and the minimap's layer composite.
7765
+ *
7766
+ * The scratch covers only the element's own device-pixel rect, the way
7767
+ * `rasterizeCanvasRoutedHtml` sizes its offscreen to the element rather than the export
7768
+ * bounds. That is exact rather than approximate because `paintHtmlElement` clips every
7769
+ * painter to the element's (rotated) rect, so nothing can land outside those bounds.
7770
+ * The offset is whole device pixels and the blit is 1:1, so the element occupies
7771
+ * exactly the pixels a full-canvas scratch would have given it.
7772
+ */
7773
+ paintHybridHtmlAtLayerOpacity(hybridCtx, element, elementBounds, layerOpacity, dpr) {
7774
+ const rect = this.htmlScratchRect(elementBounds, dpr);
7775
+ if (!rect) return;
7776
+ const scratchCtx = this.ensureHtmlScratch(rect.w, rect.h);
7777
+ const scratchCanvas = this.htmlScratchCanvas;
7778
+ if (!scratchCtx || !scratchCanvas) {
7779
+ hybridCtx.save();
7780
+ hybridCtx.globalAlpha = layerOpacity;
7781
+ this.renderer.renderCanvasElement(hybridCtx, element);
7782
+ hybridCtx.restore();
7783
+ return;
7784
+ }
7785
+ scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
7786
+ scratchCtx.clearRect(0, 0, rect.w, rect.h);
7787
+ scratchCtx.save();
7788
+ scratchCtx.globalAlpha = 1;
7789
+ scratchCtx.translate(-rect.x, -rect.y);
7790
+ scratchCtx.scale(dpr, dpr);
7791
+ scratchCtx.translate(this.camera.position.x, this.camera.position.y);
7792
+ scratchCtx.scale(this.camera.zoom, this.camera.zoom);
7793
+ this.renderer.renderCanvasElement(scratchCtx, element);
7794
+ scratchCtx.restore();
7795
+ hybridCtx.save();
7796
+ hybridCtx.setTransform(1, 0, 0, 1, 0, 0);
7797
+ hybridCtx.globalAlpha = layerOpacity;
7798
+ hybridCtx.drawImage(
7799
+ scratchCanvas,
7800
+ 0,
7801
+ 0,
7802
+ rect.w,
7803
+ rect.h,
7804
+ rect.x,
7805
+ rect.y,
7806
+ rect.w,
7807
+ rect.h
7808
+ );
7809
+ hybridCtx.restore();
7810
+ }
7224
7811
  render() {
7225
7812
  const t0 = performance.now();
7226
7813
  const ctx = this.canvasEl.getContext("2d");
@@ -7425,8 +8012,13 @@ var RenderLoop = class {
7425
8012
  for (const element of elements) {
7426
8013
  const elBounds = getElementVisualBounds(element);
7427
8014
  if (elBounds && !boundsIntersect(elBounds, cullingRect)) continue;
8015
+ const layerOpacity = this.layerManager.getLayer?.(element.layerId)?.opacity ?? 1;
8016
+ if (element.type === "html" && layerOpacity < 1) {
8017
+ this.paintHybridHtmlAtLayerOpacity(hybridCtx, element, elBounds, layerOpacity, dpr);
8018
+ continue;
8019
+ }
7428
8020
  hybridCtx.save();
7429
- hybridCtx.globalAlpha = this.layerManager.getLayer?.(element.layerId)?.opacity ?? 1;
8021
+ hybridCtx.globalAlpha = layerOpacity;
7430
8022
  this.renderer.renderCanvasElement(hybridCtx, element);
7431
8023
  hybridCtx.restore();
7432
8024
  }
@@ -7462,6 +8054,28 @@ var RenderLoop = class {
7462
8054
  }
7463
8055
  };
7464
8056
 
8057
+ // src/canvas/html-paint-diagnostics.ts
8058
+ var HtmlPaintDiagnosticDeduper = class {
8059
+ constructor(sink) {
8060
+ this.sink = sink;
8061
+ }
8062
+ seen = /* @__PURE__ */ new Map();
8063
+ emit(diagnostic, keyParts) {
8064
+ const key = `${diagnostic.target}|${diagnostic.kind}|${keyParts.registryVersion}|${keyParts.elementVersion}`;
8065
+ const forElement = this.seen.get(diagnostic.elementId) ?? /* @__PURE__ */ new Set();
8066
+ if (forElement.has(key)) return;
8067
+ forElement.add(key);
8068
+ this.seen.set(diagnostic.elementId, forElement);
8069
+ this.sink(diagnostic);
8070
+ }
8071
+ forget(elementId) {
8072
+ this.seen.delete(elementId);
8073
+ }
8074
+ reset() {
8075
+ this.seen.clear();
8076
+ }
8077
+ };
8078
+
7465
8079
  // src/canvas/layer-cache.ts
7466
8080
  function createOffscreenCanvas(width, height) {
7467
8081
  if (typeof OffscreenCanvas !== "undefined") {
@@ -8186,59 +8800,915 @@ var ViewportInteractions = class {
8186
8800
  };
8187
8801
  };
8188
8802
 
8189
- // src/canvas/viewport.ts
8190
- var EMPTY_IDS = [];
8191
- function noop() {
8803
+ // src/elements/stroke-hit.ts
8804
+ function hitTestStroke(stroke, point, radius) {
8805
+ const bounds = getElementBounds(stroke);
8806
+ if (!bounds) return false;
8807
+ if (point.x < bounds.x - radius || point.x > bounds.x + bounds.w + radius || point.y < bounds.y - radius || point.y > bounds.y + bounds.h + radius) {
8808
+ return false;
8809
+ }
8810
+ const radiusSq = radius * radius;
8811
+ const local = { x: point.x - stroke.position.x, y: point.y - stroke.position.y };
8812
+ const { segments } = getStrokeRenderData(stroke);
8813
+ if (segments.length === 0) {
8814
+ const p = stroke.points[0];
8815
+ if (!p) return false;
8816
+ const dx = p.x - local.x;
8817
+ const dy = p.y - local.y;
8818
+ return dx * dx + dy * dy <= radiusSq;
8819
+ }
8820
+ for (const seg of segments) {
8821
+ if (distSqToSegment(local, seg.start, seg.end) <= radiusSq) return true;
8822
+ }
8823
+ return false;
8192
8824
  }
8193
- var Viewport = class _Viewport {
8194
- constructor(container, options = {}) {
8195
- this.container = container;
8196
- this.camera = new Camera(options.camera);
8197
- this.background = new Background(options.background);
8198
- this._gridSize = options.background?.spacing ?? 24;
8199
- this.store = new ElementStore();
8200
- this.layerManager = new LayerManager(this.store);
8201
- this.toolManager = new ToolManager();
8202
- this.unsubToolRegister = this.toolManager.onRegister((tool) => {
8203
- if (_Viewport.isSelectionSource(tool)) this.attachSelectionSource(tool);
8204
- });
8205
- const existingSelect = this.getSelectTool();
8206
- if (existingSelect && _Viewport.isSelectionSource(existingSelect)) {
8207
- this.attachSelectionSource(existingSelect);
8208
- }
8209
- this.renderer = new ElementRenderer();
8210
- this.renderer.setStore(this.store);
8211
- this.renderer.setCamera(this.camera);
8212
- this.renderer.setOnImageLoad(() => {
8213
- this.renderLoop.markAllLayersDirty();
8214
- this.requestRender();
8215
- });
8216
- this.renderer.setOnImageError((src, cause) => {
8217
- const elementIds = [];
8218
- for (const el of this.store.getAll()) {
8219
- if (el.type === "image" && el.src === src) elementIds.push(el.id);
8220
- }
8221
- if (options.onImageError) {
8222
- options.onImageError({ src, elementIds, cause });
8223
- } else {
8224
- console.warn(`[fieldnotes] image failed to load: ${src}`);
8225
- }
8226
- });
8227
- this.noteEditor = new NoteEditor({
8228
- fontSizePresets: options.fontSizePresets,
8229
- toolbar: options.toolbar,
8230
- placeholder: options.placeholder
8231
- });
8232
- this.noteEditor.setOnStop((id) => this.interactions.onTextEditStop(id));
8233
- this.noteEditor.setOnInput((id) => this.interactions.liveFitHeight(id));
8234
- this.arrowLabelEditor = new ArrowLabelEditor();
8235
- this.noteEditor.setHistoryHooks(
8236
- () => this.historyRecorder.begin(),
8237
- () => this.historyRecorder.commit()
8238
- );
8239
- this.onHtmlElementMount = options.onHtmlElementMount;
8240
- this.dropHandler = options.onDrop;
8241
- this.history = new HistoryStack();
8825
+
8826
+ // src/tools/arrow-handles.ts
8827
+ var BIND_THRESHOLD = 20;
8828
+ var HANDLE_RADIUS = 5;
8829
+ var HANDLE_HIT_PADDING = 4;
8830
+ var ARROW_HANDLE_CURSORS = {
8831
+ start: "crosshair",
8832
+ end: "crosshair",
8833
+ mid: "grab"
8834
+ };
8835
+ function getArrowHandleCursor(handle, active) {
8836
+ if (handle === "mid" && active) return "grabbing";
8837
+ return ARROW_HANDLE_CURSORS[handle];
8838
+ }
8839
+ function getArrowHandlePositions(arrow) {
8840
+ const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
8841
+ return [
8842
+ ["start", arrow.from],
8843
+ ["mid", mid],
8844
+ ["end", arrow.to]
8845
+ ];
8846
+ }
8847
+ function hitTestArrowHandles(world, selectedIds, ctx) {
8848
+ if (selectedIds.length === 0) return null;
8849
+ const zoom = ctx.camera.zoom;
8850
+ const hitRadius = (HANDLE_RADIUS + HANDLE_HIT_PADDING) / zoom;
8851
+ for (const id of selectedIds) {
8852
+ const el = ctx.store.getById(id);
8853
+ if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
8854
+ continue;
8855
+ const handles = getArrowHandlePositions(el);
8856
+ for (const [handle, pos] of handles) {
8857
+ const dx = world.x - pos.x;
8858
+ const dy = world.y - pos.y;
8859
+ if (dx * dx + dy * dy <= hitRadius * hitRadius) {
8860
+ return { elementId: id, handle };
8861
+ }
8862
+ }
8863
+ }
8864
+ return null;
8865
+ }
8866
+ function applyArrowHandleDrag(handle, elementId, world, ctx) {
8867
+ const el = ctx.store.getById(elementId);
8868
+ if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) return;
8869
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
8870
+ const layerFilter = (candidate) => candidate.layerId === el.layerId;
8871
+ switch (handle) {
8872
+ case "start": {
8873
+ const excludeId = el.toBinding?.elementId;
8874
+ const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
8875
+ if (target) {
8876
+ const center2 = getElementCenter(target);
8877
+ ctx.store.update(elementId, {
8878
+ from: center2,
8879
+ position: center2,
8880
+ fromBinding: { elementId: target.id }
8881
+ });
8882
+ } else {
8883
+ ctx.store.update(elementId, {
8884
+ from: { x: world.x, y: world.y },
8885
+ position: { x: world.x, y: world.y },
8886
+ fromBinding: void 0
8887
+ });
8888
+ }
8889
+ break;
8890
+ }
8891
+ case "end": {
8892
+ const excludeId = el.fromBinding?.elementId;
8893
+ const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
8894
+ if (target) {
8895
+ const center2 = getElementCenter(target);
8896
+ ctx.store.update(elementId, {
8897
+ to: center2,
8898
+ toBinding: { elementId: target.id }
8899
+ });
8900
+ } else {
8901
+ ctx.store.update(elementId, {
8902
+ to: { x: world.x, y: world.y },
8903
+ toBinding: void 0
8904
+ });
8905
+ }
8906
+ break;
8907
+ }
8908
+ case "mid": {
8909
+ const bend = getBendFromPoint(el.from, el.to, world);
8910
+ ctx.store.update(elementId, { bend });
8911
+ break;
8912
+ }
8913
+ }
8914
+ ctx.requestRender();
8915
+ }
8916
+ function getArrowHandleDragTarget(handle, elementId, world, ctx) {
8917
+ if (handle === "mid") return null;
8918
+ const el = ctx.store.getById(elementId);
8919
+ if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) {
8920
+ return null;
8921
+ }
8922
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
8923
+ const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
8924
+ const layerFilter = (candidate) => candidate.layerId === el.layerId;
8925
+ const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
8926
+ if (!target) return null;
8927
+ return getElementBounds(target);
8928
+ }
8929
+ function renderArrowHandles(canvasCtx, arrow, zoom) {
8930
+ const radius = HANDLE_RADIUS / zoom;
8931
+ const handles = getArrowHandlePositions(arrow);
8932
+ canvasCtx.setLineDash([]);
8933
+ canvasCtx.lineWidth = 1.5 / zoom;
8934
+ for (const [handle, pos] of handles) {
8935
+ canvasCtx.fillStyle = handle === "mid" ? "#2196F3" : "#ffffff";
8936
+ canvasCtx.strokeStyle = "#2196F3";
8937
+ canvasCtx.beginPath();
8938
+ canvasCtx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
8939
+ canvasCtx.fill();
8940
+ canvasCtx.stroke();
8941
+ }
8942
+ }
8943
+ function renderArrowHoverHandle(canvasCtx, arrow, zoom) {
8944
+ const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
8945
+ const radius = HANDLE_RADIUS / zoom;
8946
+ canvasCtx.fillStyle = "#2196F3";
8947
+ canvasCtx.strokeStyle = "#2196F3";
8948
+ canvasCtx.lineWidth = 1.5 / zoom;
8949
+ canvasCtx.beginPath();
8950
+ canvasCtx.arc(mid.x, mid.y, radius, 0, Math.PI * 2);
8951
+ canvasCtx.fill();
8952
+ canvasCtx.stroke();
8953
+ }
8954
+
8955
+ // src/tools/select-overlay.ts
8956
+ var HANDLE_SIZE = 8;
8957
+ var HANDLE_HIT_PADDING2 = 4;
8958
+ var SELECTION_PAD = 4;
8959
+ var ROTATE_HANDLE_OFFSET = 24;
8960
+ var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
8961
+ var HANDLE_CURSORS = {
8962
+ nw: "nwse-resize",
8963
+ se: "nwse-resize",
8964
+ ne: "nesw-resize",
8965
+ sw: "nesw-resize"
8966
+ };
8967
+ function getOverlayLayout(el, zoom) {
8968
+ const bounds = getElementBounds(el);
8969
+ if (!bounds) return null;
8970
+ const angle = el.rotation ?? 0;
8971
+ const pad = SELECTION_PAD / zoom;
8972
+ const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
8973
+ const raw = [
8974
+ ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
8975
+ ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
8976
+ ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
8977
+ ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
8978
+ ];
8979
+ const corners = raw.map(
8980
+ ([h, p]) => [h, rotatePoint(p, center2, angle)]
8981
+ );
8982
+ const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
8983
+ const rotateHandle = rotatePoint(topMid, center2, angle);
8984
+ return { center: center2, corners, rotateHandle, angle };
8985
+ }
8986
+ function templateAimKnob(el, zoom) {
8987
+ if (el.type !== "template") return null;
8988
+ if (el.templateShape !== "cone" && el.templateShape !== "line" && el.templateShape !== "rectangle")
8989
+ return null;
8990
+ const gap = ROTATE_HANDLE_OFFSET / zoom;
8991
+ const dist = el.radius + gap;
8992
+ const origin = el.position;
8993
+ return {
8994
+ origin,
8995
+ knob: {
8996
+ x: origin.x + dist * Math.cos(el.angle),
8997
+ y: origin.y + dist * Math.sin(el.angle)
8998
+ }
8999
+ };
9000
+ }
9001
+ function getHandlePositions(bounds) {
9002
+ return [
9003
+ ["nw", { x: bounds.x, y: bounds.y }],
9004
+ ["ne", { x: bounds.x + bounds.w, y: bounds.y }],
9005
+ ["sw", { x: bounds.x, y: bounds.y + bounds.h }],
9006
+ ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
9007
+ ];
9008
+ }
9009
+ function topMidpoint(layout) {
9010
+ const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
9011
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
9012
+ return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
9013
+ }
9014
+ function drawLockBadge(ctx, at, zoom) {
9015
+ const r = 9 / zoom;
9016
+ ctx.save();
9017
+ ctx.setLineDash([]);
9018
+ ctx.beginPath();
9019
+ ctx.arc(at.x, at.y, r, 0, Math.PI * 2);
9020
+ ctx.fillStyle = "#ffffff";
9021
+ ctx.fill();
9022
+ ctx.strokeStyle = "#2196F3";
9023
+ ctx.lineWidth = 1.5 / zoom;
9024
+ ctx.stroke();
9025
+ const bw = 8 / zoom;
9026
+ const bh = 6 / zoom;
9027
+ ctx.fillStyle = "#2196F3";
9028
+ ctx.fillRect(at.x - bw / 2, at.y - bh / 2 + 1 / zoom, bw, bh);
9029
+ ctx.beginPath();
9030
+ ctx.arc(at.x, at.y - bh / 2 + 1 / zoom, 2.5 / zoom, Math.PI, 0);
9031
+ ctx.lineWidth = 1.4 / zoom;
9032
+ ctx.stroke();
9033
+ ctx.restore();
9034
+ }
9035
+ function renderMarquee(ctx, rect) {
9036
+ ctx.save();
9037
+ ctx.strokeStyle = "#2196F3";
9038
+ ctx.fillStyle = "rgba(33, 150, 243, 0.08)";
9039
+ ctx.lineWidth = 1;
9040
+ ctx.setLineDash([4, 4]);
9041
+ ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
9042
+ ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
9043
+ ctx.restore();
9044
+ }
9045
+ function renderBindingHighlights(ctx, arrow, zoom, store) {
9046
+ if (!arrow.fromBinding && !arrow.toBinding) return;
9047
+ const pad = SELECTION_PAD / zoom;
9048
+ ctx.save();
9049
+ ctx.strokeStyle = "#2196F3";
9050
+ ctx.lineWidth = 2 / zoom;
9051
+ ctx.setLineDash([]);
9052
+ const drawn = /* @__PURE__ */ new Set();
9053
+ for (const binding of [arrow.fromBinding, arrow.toBinding]) {
9054
+ if (!binding || drawn.has(binding.elementId)) continue;
9055
+ drawn.add(binding.elementId);
9056
+ const target = store.getById(binding.elementId);
9057
+ if (!target) continue;
9058
+ const bounds = getElementBounds(target);
9059
+ if (!bounds) continue;
9060
+ ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
9061
+ }
9062
+ ctx.restore();
9063
+ }
9064
+ function renderSelectionBoxes(ctx, p) {
9065
+ if (p.selectedIds.length === 0) return;
9066
+ const zoom = p.zoom;
9067
+ const handleWorldSize = HANDLE_SIZE / zoom;
9068
+ ctx.save();
9069
+ ctx.strokeStyle = "#2196F3";
9070
+ ctx.lineWidth = 1.5 / zoom;
9071
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9072
+ for (const id of p.selectedIds) {
9073
+ const el = p.store.getById(id);
9074
+ if (!el) continue;
9075
+ const locked = el.locked || (p.isLayerLocked?.(el.layerId) ?? false);
9076
+ if (el.type === "arrow") {
9077
+ if (!locked) renderArrowHandles(ctx, el, zoom);
9078
+ renderBindingHighlights(ctx, el, zoom, p.store);
9079
+ continue;
9080
+ }
9081
+ if (el.type === "shape" && el.shape === "line") {
9082
+ if (!locked) {
9083
+ ctx.setLineDash([]);
9084
+ ctx.fillStyle = "#ffffff";
9085
+ const r = handleWorldSize / 2;
9086
+ for (const pt of lineEndpoints(el)) {
9087
+ ctx.beginPath();
9088
+ ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
9089
+ ctx.fill();
9090
+ ctx.stroke();
9091
+ }
9092
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9093
+ }
9094
+ continue;
9095
+ }
9096
+ const bounds = getElementBounds(el);
9097
+ if (!bounds) continue;
9098
+ const layout = getOverlayLayout(el, zoom);
9099
+ if (!layout) continue;
9100
+ const pad = SELECTION_PAD / zoom;
9101
+ if (layout.angle === 0) {
9102
+ ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
9103
+ } else {
9104
+ const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((pp) => !!pp);
9105
+ const [p0, ...others] = ordered;
9106
+ if (p0) {
9107
+ ctx.beginPath();
9108
+ ctx.moveTo(p0.x, p0.y);
9109
+ for (const pp of others) ctx.lineTo(pp.x, pp.y);
9110
+ ctx.closePath();
9111
+ ctx.stroke();
9112
+ }
9113
+ }
9114
+ if (!el.locked) {
9115
+ if ("size" in el) {
9116
+ ctx.setLineDash([]);
9117
+ ctx.fillStyle = "#ffffff";
9118
+ const corners = layout.angle === 0 ? getHandlePositions(bounds) : layout.corners;
9119
+ for (const [, pos] of corners) {
9120
+ ctx.fillRect(
9121
+ pos.x - handleWorldSize / 2,
9122
+ pos.y - handleWorldSize / 2,
9123
+ handleWorldSize,
9124
+ handleWorldSize
9125
+ );
9126
+ ctx.strokeRect(
9127
+ pos.x - handleWorldSize / 2,
9128
+ pos.y - handleWorldSize / 2,
9129
+ handleWorldSize,
9130
+ handleWorldSize
9131
+ );
9132
+ }
9133
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9134
+ } else if (el.type === "template") {
9135
+ ctx.setLineDash([]);
9136
+ ctx.fillStyle = "#ffffff";
9137
+ if (el.templateShape === "rectangle") {
9138
+ if (p.selectedIds.length === 1) {
9139
+ const cos = Math.cos(el.angle);
9140
+ const sin = Math.sin(el.angle);
9141
+ const halfW = (el.width ?? 0) / 2;
9142
+ const pts = [
9143
+ [el.position.x + el.radius * cos, el.position.y + el.radius * sin],
9144
+ [
9145
+ el.position.x + el.radius / 2 * cos + halfW * -sin,
9146
+ el.position.y + el.radius / 2 * sin + halfW * cos
9147
+ ]
9148
+ ];
9149
+ for (const [hx, hy] of pts) {
9150
+ ctx.fillRect(
9151
+ hx - handleWorldSize / 2,
9152
+ hy - handleWorldSize / 2,
9153
+ handleWorldSize,
9154
+ handleWorldSize
9155
+ );
9156
+ ctx.strokeRect(
9157
+ hx - handleWorldSize / 2,
9158
+ hy - handleWorldSize / 2,
9159
+ handleWorldSize,
9160
+ handleWorldSize
9161
+ );
9162
+ }
9163
+ }
9164
+ } else {
9165
+ const hx = bounds.x + bounds.w;
9166
+ const hy = bounds.y + bounds.h;
9167
+ ctx.fillRect(
9168
+ hx - handleWorldSize / 2,
9169
+ hy - handleWorldSize / 2,
9170
+ handleWorldSize,
9171
+ handleWorldSize
9172
+ );
9173
+ ctx.strokeRect(
9174
+ hx - handleWorldSize / 2,
9175
+ hy - handleWorldSize / 2,
9176
+ handleWorldSize,
9177
+ handleWorldSize
9178
+ );
9179
+ }
9180
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9181
+ if (p.selectedIds.length === 1 && (el.templateShape === "cone" || el.templateShape === "line" || el.templateShape === "rectangle")) {
9182
+ const aim = templateAimKnob(el, zoom);
9183
+ if (aim) {
9184
+ ctx.beginPath();
9185
+ ctx.moveTo(aim.origin.x, aim.origin.y);
9186
+ ctx.lineTo(aim.knob.x, aim.knob.y);
9187
+ ctx.stroke();
9188
+ ctx.setLineDash([]);
9189
+ ctx.fillStyle = "#ffffff";
9190
+ ctx.beginPath();
9191
+ ctx.arc(aim.knob.x, aim.knob.y, handleWorldSize / 2, 0, Math.PI * 2);
9192
+ ctx.fill();
9193
+ ctx.stroke();
9194
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9195
+ }
9196
+ }
9197
+ }
9198
+ if (p.selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
9199
+ const stemStart = topMidpoint(layout);
9200
+ const stemEnd = layout.rotateHandle;
9201
+ ctx.beginPath();
9202
+ ctx.moveTo(stemStart.x, stemStart.y);
9203
+ ctx.lineTo(stemEnd.x, stemEnd.y);
9204
+ ctx.stroke();
9205
+ ctx.setLineDash([]);
9206
+ ctx.fillStyle = "#ffffff";
9207
+ ctx.beginPath();
9208
+ ctx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
9209
+ ctx.fill();
9210
+ ctx.stroke();
9211
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9212
+ }
9213
+ }
9214
+ if (el.locked) {
9215
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1];
9216
+ if (ne) drawLockBadge(ctx, ne, zoom);
9217
+ }
9218
+ }
9219
+ ctx.restore();
9220
+ }
9221
+ function renderGuideLines(ctx, p) {
9222
+ const zoom = p.zoom;
9223
+ const rect = p.rect;
9224
+ ctx.save();
9225
+ ctx.strokeStyle = "#FF4081";
9226
+ ctx.lineWidth = 1 / zoom;
9227
+ ctx.setLineDash([]);
9228
+ for (const g of p.guides) {
9229
+ ctx.beginPath();
9230
+ if (g.axis === "x") {
9231
+ const y0 = rect ? rect.y : p.currentWorld.y - 1e5;
9232
+ const y1 = rect ? rect.y + rect.h : p.currentWorld.y + 1e5;
9233
+ ctx.moveTo(g.position, y0);
9234
+ ctx.lineTo(g.position, y1);
9235
+ } else {
9236
+ const x0 = rect ? rect.x : p.currentWorld.x - 1e5;
9237
+ const x1 = rect ? rect.x + rect.w : p.currentWorld.x + 1e5;
9238
+ ctx.moveTo(x0, g.position);
9239
+ ctx.lineTo(x1, g.position);
9240
+ }
9241
+ ctx.stroke();
9242
+ }
9243
+ ctx.restore();
9244
+ }
9245
+
9246
+ // src/tools/select-hit.ts
9247
+ function hitTest(world, ctx) {
9248
+ const r = 10;
9249
+ const candidates = ctx.store.queryRect({ x: world.x - r, y: world.y - r, w: r * 2, h: r * 2 }).reverse();
9250
+ for (const el of candidates) {
9251
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
9252
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
9253
+ if (el.type === "grid") continue;
9254
+ if (isInsideBounds(world, el)) return el;
9255
+ }
9256
+ return null;
9257
+ }
9258
+ function isInsideBounds(point, el) {
9259
+ if (el.type === "grid") return false;
9260
+ const angle = el.rotation ?? 0;
9261
+ if (angle !== 0) {
9262
+ const b = getElementBounds(el);
9263
+ if (b) {
9264
+ point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
9265
+ }
9266
+ }
9267
+ if (el.type === "shape" && el.shape === "line") {
9268
+ const [a, b] = lineEndpoints(el);
9269
+ const threshold = Math.max(el.strokeWidth / 2, 6);
9270
+ return distSqToSegment(point, a, b) <= threshold * threshold;
9271
+ }
9272
+ if ("size" in el) {
9273
+ const s = el.size;
9274
+ return point.x >= el.position.x && point.x <= el.position.x + s.w && point.y >= el.position.y && point.y <= el.position.y + s.h;
9275
+ }
9276
+ if (el.type === "stroke") {
9277
+ return hitTestStroke(el, point, 10);
9278
+ }
9279
+ if (el.type === "arrow") {
9280
+ return isNearBezier(point, el.from, el.to, el.bend, 10);
9281
+ }
9282
+ if (el.type === "template") {
9283
+ const bounds = getElementBounds(el);
9284
+ if (!bounds) return false;
9285
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
9286
+ }
9287
+ return false;
9288
+ }
9289
+ function hitTestResizeHandle(world, ctx, selectedIds) {
9290
+ if (selectedIds.length === 0) return null;
9291
+ const zoom = ctx.camera.zoom;
9292
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9293
+ for (const id of selectedIds) {
9294
+ const el = ctx.store.getById(id);
9295
+ if (!el || !("size" in el)) continue;
9296
+ if (el.locked) continue;
9297
+ if (el.type === "shape" && el.shape === "line") continue;
9298
+ const layout = getOverlayLayout(el, zoom);
9299
+ if (!layout) continue;
9300
+ for (const [handle, pos] of layout.corners) {
9301
+ if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
9302
+ return { elementId: id, handle };
9303
+ }
9304
+ }
9305
+ }
9306
+ return null;
9307
+ }
9308
+ function hitTestRotateHandle(world, ctx, selectedIds) {
9309
+ if (selectedIds.length !== 1) return null;
9310
+ const id = selectedIds[0];
9311
+ if (!id) return null;
9312
+ const el = ctx.store.getById(id);
9313
+ if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
9314
+ const layout = getOverlayLayout(el, ctx.camera.zoom);
9315
+ if (!layout) return null;
9316
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
9317
+ const dx = world.x - layout.rotateHandle.x;
9318
+ const dy = world.y - layout.rotateHandle.y;
9319
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9320
+ }
9321
+ function hitTestLineHandles(world, ctx, selectedIds) {
9322
+ if (selectedIds.length === 0) return null;
9323
+ const zoom = ctx.camera.zoom;
9324
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9325
+ const r2 = r * r;
9326
+ for (const id of selectedIds) {
9327
+ const el = ctx.store.getById(id);
9328
+ if (!el || el.type !== "shape" || el.shape !== "line" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
9329
+ continue;
9330
+ const [a, b] = lineEndpoints(el);
9331
+ if ((world.x - a.x) ** 2 + (world.y - a.y) ** 2 <= r2) return { elementId: id, fixed: b };
9332
+ if ((world.x - b.x) ** 2 + (world.y - b.y) ** 2 <= r2) return { elementId: id, fixed: a };
9333
+ }
9334
+ return null;
9335
+ }
9336
+ function hitTestTemplateResizeHandle(world, ctx, selectedIds) {
9337
+ if (selectedIds.length === 0) return null;
9338
+ const zoom = ctx.camera.zoom;
9339
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9340
+ for (const id of selectedIds) {
9341
+ const el = ctx.store.getById(id);
9342
+ if (!el || el.type !== "template") continue;
9343
+ if (el.templateShape === "rectangle") continue;
9344
+ const bounds = getElementBounds(el);
9345
+ if (!bounds) continue;
9346
+ const hx = bounds.x + bounds.w;
9347
+ const hy = bounds.y + bounds.h;
9348
+ if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
9349
+ return id;
9350
+ }
9351
+ }
9352
+ return null;
9353
+ }
9354
+ function hitTestTemplateAimHandle(world, ctx, selectedIds) {
9355
+ if (selectedIds.length !== 1) return null;
9356
+ const id = selectedIds[0];
9357
+ if (!id) return null;
9358
+ const el = ctx.store.getById(id);
9359
+ if (!el || el.locked) return null;
9360
+ const knob = templateAimKnob(el, ctx.camera.zoom);
9361
+ if (!knob) return null;
9362
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
9363
+ const dx = world.x - knob.knob.x;
9364
+ const dy = world.y - knob.knob.y;
9365
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9366
+ }
9367
+ function hitTestRectangleLengthHandle(world, ctx, selectedIds) {
9368
+ if (selectedIds.length !== 1) return null;
9369
+ const id = selectedIds[0];
9370
+ if (!id) return null;
9371
+ const el = ctx.store.getById(id);
9372
+ if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
9373
+ const zoom = ctx.camera.zoom;
9374
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9375
+ const hx = el.position.x + el.radius * Math.cos(el.angle);
9376
+ const hy = el.position.y + el.radius * Math.sin(el.angle);
9377
+ const dx = world.x - hx;
9378
+ const dy = world.y - hy;
9379
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9380
+ }
9381
+ function hitTestRectangleWidthHandle(world, ctx, selectedIds) {
9382
+ if (selectedIds.length !== 1) return null;
9383
+ const id = selectedIds[0];
9384
+ if (!id) return null;
9385
+ const el = ctx.store.getById(id);
9386
+ if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
9387
+ const zoom = ctx.camera.zoom;
9388
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9389
+ const cos = Math.cos(el.angle);
9390
+ const sin = Math.sin(el.angle);
9391
+ const halfW = (el.width ?? 0) / 2;
9392
+ const hx = el.position.x + el.radius / 2 * cos + halfW * -sin;
9393
+ const hy = el.position.y + el.radius / 2 * sin + halfW * cos;
9394
+ const dx = world.x - hx;
9395
+ const dy = world.y - hy;
9396
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9397
+ }
9398
+ function findElementsInRect(marquee, ctx) {
9399
+ const candidates = ctx.store.queryRect(marquee);
9400
+ const ids = [];
9401
+ for (const el of candidates) {
9402
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
9403
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
9404
+ if (el.type === "grid") continue;
9405
+ const bounds = getElementBounds(el);
9406
+ if (bounds && rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
9407
+ ids.push(el.id);
9408
+ }
9409
+ }
9410
+ return ids;
9411
+ }
9412
+ function rectsOverlap(a, b) {
9413
+ return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
9414
+ }
9415
+
9416
+ // src/canvas/element-activation.ts
9417
+ var DEFAULT_ACTIVATION_SLOP_PX = 8;
9418
+ var DEFAULT_ACTIVATION_DOUBLE_DELAY_MS = 300;
9419
+ var PASSIVE = { passive: true };
9420
+ function noop() {
9421
+ }
9422
+ var ElementActivation = class {
9423
+ deps;
9424
+ options;
9425
+ slopPx;
9426
+ doubleDelayMs;
9427
+ hitContext;
9428
+ active = null;
9429
+ pending = null;
9430
+ pendingTimer = null;
9431
+ downPointers = /* @__PURE__ */ new Set();
9432
+ cameraRevision = 0;
9433
+ disposed = false;
9434
+ listeners = /* @__PURE__ */ new Set();
9435
+ unsubCamera;
9436
+ unsubStore;
9437
+ handlePointerDown = (e) => this.onPointerDown(e);
9438
+ handlePointerMove = (e) => this.onPointerMove(e);
9439
+ handlePointerUp = (e) => this.onPointerUp(e);
9440
+ handlePointerCancel = (e) => this.onPointerCancel(e);
9441
+ handleWindowPointerEnd = (e) => this.onWindowPointerEnd(e);
9442
+ handleInterrupt = () => this.reset();
9443
+ constructor(deps, options) {
9444
+ this.slopPx = options.slopPx ?? DEFAULT_ACTIVATION_SLOP_PX;
9445
+ this.doubleDelayMs = options.doubleDelayMs ?? DEFAULT_ACTIVATION_DOUBLE_DELAY_MS;
9446
+ if (!Number.isFinite(this.slopPx) || this.slopPx < 0) {
9447
+ throw new RangeError(`[fieldnotes] activation slopPx must be finite and >= 0`);
9448
+ }
9449
+ if (!Number.isFinite(this.doubleDelayMs) || this.doubleDelayMs <= 0) {
9450
+ throw new RangeError(`[fieldnotes] activation doubleDelayMs must be finite and > 0`);
9451
+ }
9452
+ this.deps = deps;
9453
+ this.options = options;
9454
+ this.hitContext = {
9455
+ camera: deps.camera,
9456
+ store: deps.store,
9457
+ requestRender: noop,
9458
+ isLayerVisible: deps.isLayerVisible
9459
+ };
9460
+ this.unsubCamera = deps.camera.onChange(() => {
9461
+ this.cameraRevision += 1;
9462
+ });
9463
+ this.unsubStore = [
9464
+ deps.store.on("remove", (el) => this.forgetElement(el.id)),
9465
+ deps.store.on("clear", () => this.reset())
9466
+ ];
9467
+ deps.element.addEventListener("pointerdown", this.handlePointerDown, PASSIVE);
9468
+ deps.element.addEventListener("pointermove", this.handlePointerMove, PASSIVE);
9469
+ deps.element.addEventListener("pointerup", this.handlePointerUp, PASSIVE);
9470
+ deps.element.addEventListener("pointercancel", this.handlePointerCancel, PASSIVE);
9471
+ window.addEventListener("pointerup", this.handleWindowPointerEnd, PASSIVE);
9472
+ window.addEventListener("pointercancel", this.handleWindowPointerEnd, PASSIVE);
9473
+ window.addEventListener("blur", this.handleInterrupt, PASSIVE);
9474
+ window.addEventListener("visibilitychange", this.handleInterrupt, PASSIVE);
9475
+ }
9476
+ /**
9477
+ * Subscribes to activations. Emission iterates a snapshot with a per-listener
9478
+ * try/catch, so one throwing listener cannot break its siblings and a
9479
+ * concurrent unsubscribe cannot skip one. Returns an idempotent unsubscribe.
9480
+ */
9481
+ onActivate(listener) {
9482
+ this.listeners.add(listener);
9483
+ return () => {
9484
+ this.listeners.delete(listener);
9485
+ };
9486
+ }
9487
+ /** Removes every listener, clears timers and gesture state. Idempotent. */
9488
+ dispose() {
9489
+ if (this.disposed) return;
9490
+ this.disposed = true;
9491
+ this.reset();
9492
+ this.unsubCamera();
9493
+ for (const unsub of this.unsubStore) unsub();
9494
+ this.deps.element.removeEventListener("pointerdown", this.handlePointerDown);
9495
+ this.deps.element.removeEventListener("pointermove", this.handlePointerMove);
9496
+ this.deps.element.removeEventListener("pointerup", this.handlePointerUp);
9497
+ this.deps.element.removeEventListener("pointercancel", this.handlePointerCancel);
9498
+ window.removeEventListener("pointerup", this.handleWindowPointerEnd);
9499
+ window.removeEventListener("pointercancel", this.handleWindowPointerEnd);
9500
+ window.removeEventListener("blur", this.handleInterrupt);
9501
+ window.removeEventListener("visibilitychange", this.handleInterrupt);
9502
+ this.listeners.clear();
9503
+ }
9504
+ now() {
9505
+ return performance.now();
9506
+ }
9507
+ isCameraBusy() {
9508
+ return (this.deps.isCameraBusy?.() ?? false) || (this.options.isCameraBusy?.() ?? false);
9509
+ }
9510
+ toWorld(e) {
9511
+ const rect = this.deps.element.getBoundingClientRect();
9512
+ return this.deps.camera.screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top });
9513
+ }
9514
+ /**
9515
+ * Topmost visible element under `world` that activation accepts, or `null`.
9516
+ * The topmost hit wins outright: a rejected hit does not fall through to
9517
+ * whatever sits beneath it.
9518
+ */
9519
+ resolveTarget(world) {
9520
+ const hit = hitTest(world, this.hitContext);
9521
+ if (!hit) return null;
9522
+ if (hit.type === "note" || hit.type === "text") return null;
9523
+ if (hit.type === "html" && this.deps.resolveHtmlRouting(hit) !== "canvas") return null;
9524
+ if (this.options.isActivatable && !this.options.isActivatable(hit)) return null;
9525
+ return hit;
9526
+ }
9527
+ beyondSlop(from, to) {
9528
+ return Math.hypot(to.x - from.x, to.y - from.y) > this.slopPx;
9529
+ }
9530
+ onPointerDown(e) {
9531
+ const hadPointers = this.downPointers.size > 0;
9532
+ this.downPointers.add(e.pointerId);
9533
+ if (hadPointers) {
9534
+ this.clearGesture();
9535
+ return;
9536
+ }
9537
+ if (e.button !== 0) return;
9538
+ this.active = null;
9539
+ if (this.isCameraBusy()) {
9540
+ this.clearPending();
9541
+ return;
9542
+ }
9543
+ const target = this.resolveTarget(this.toWorld(e));
9544
+ if (!target) {
9545
+ this.clearPending();
9546
+ return;
9547
+ }
9548
+ this.active = {
9549
+ pointerId: e.pointerId,
9550
+ pointerType: e.pointerType,
9551
+ clientPoint: { x: e.clientX, y: e.clientY },
9552
+ elementId: target.id,
9553
+ cameraRevision: this.cameraRevision
9554
+ };
9555
+ }
9556
+ onPointerMove(e) {
9557
+ const active = this.active;
9558
+ if (!active || active.pointerId !== e.pointerId) return;
9559
+ if (this.beyondSlop(active.clientPoint, { x: e.clientX, y: e.clientY })) this.clearGesture();
9560
+ }
9561
+ onPointerUp(e) {
9562
+ this.downPointers.delete(e.pointerId);
9563
+ const active = this.active;
9564
+ if (!active || active.pointerId !== e.pointerId) return;
9565
+ this.active = null;
9566
+ if (this.beyondSlop(active.clientPoint, { x: e.clientX, y: e.clientY })) {
9567
+ this.clearPending();
9568
+ return;
9569
+ }
9570
+ if (this.cameraRevision !== active.cameraRevision || this.isCameraBusy()) {
9571
+ this.clearPending();
9572
+ return;
9573
+ }
9574
+ const world = this.toWorld(e);
9575
+ const target = this.resolveTarget(world);
9576
+ if (!target || target.id !== active.elementId) {
9577
+ this.clearPending();
9578
+ return;
9579
+ }
9580
+ if (this.options.gesture === "single") {
9581
+ this.clearPending();
9582
+ this.emit({ element: target, world, pointerType: e.pointerType, gesture: "single" });
9583
+ return;
9584
+ }
9585
+ const pending = this.pending;
9586
+ const time = this.now();
9587
+ if (pending && pending.elementId === target.id && pending.pointerType === e.pointerType && time - pending.time <= this.doubleDelayMs) {
9588
+ this.clearPending();
9589
+ this.emit({ element: target, world, pointerType: e.pointerType, gesture: "double" });
9590
+ return;
9591
+ }
9592
+ this.setPending({
9593
+ elementId: target.id,
9594
+ pointerType: e.pointerType,
9595
+ clientPoint: { x: e.clientX, y: e.clientY },
9596
+ time
9597
+ });
9598
+ }
9599
+ onPointerCancel(e) {
9600
+ this.downPointers.delete(e.pointerId);
9601
+ if (this.active && this.active.pointerId !== e.pointerId) return;
9602
+ this.clearGesture();
9603
+ }
9604
+ /**
9605
+ * A pointer pressed on the wrapper and released outside it. State-clearing
9606
+ * only: this path can never activate. Events that bubbled up from inside the
9607
+ * wrapper were already handled by the wrapper listener.
9608
+ */
9609
+ onWindowPointerEnd(e) {
9610
+ const target = e.target;
9611
+ if (target instanceof Node && this.deps.element.contains(target)) return;
9612
+ this.downPointers.delete(e.pointerId);
9613
+ if (this.active && this.active.pointerId !== e.pointerId) return;
9614
+ this.clearGesture();
9615
+ }
9616
+ forgetElement(id) {
9617
+ if (this.active?.elementId === id) this.active = null;
9618
+ if (this.pending?.elementId === id) this.clearPending();
9619
+ }
9620
+ setPending(pending) {
9621
+ this.clearPending();
9622
+ this.pending = pending;
9623
+ this.pendingTimer = setTimeout(() => this.clearPending(), this.doubleDelayMs);
9624
+ }
9625
+ clearPending() {
9626
+ this.pending = null;
9627
+ if (this.pendingTimer !== null) {
9628
+ clearTimeout(this.pendingTimer);
9629
+ this.pendingTimer = null;
9630
+ }
9631
+ }
9632
+ /** Drops the in-flight gesture and its pending half; leaves pointer bookkeeping. */
9633
+ clearGesture() {
9634
+ this.active = null;
9635
+ this.clearPending();
9636
+ }
9637
+ /** Drops everything, including which pointers are believed to be down. */
9638
+ reset() {
9639
+ this.clearGesture();
9640
+ this.downPointers.clear();
9641
+ }
9642
+ emit(event) {
9643
+ for (const listener of [...this.listeners]) {
9644
+ try {
9645
+ listener(event);
9646
+ } catch {
9647
+ }
9648
+ }
9649
+ }
9650
+ };
9651
+
9652
+ // src/canvas/viewport.ts
9653
+ var EMPTY_IDS = [];
9654
+ function noop2() {
9655
+ }
9656
+ var Viewport = class _Viewport {
9657
+ constructor(container, options = {}) {
9658
+ this.container = container;
9659
+ this.camera = new Camera(options.camera);
9660
+ this.background = new Background(options.background);
9661
+ this._gridSize = options.background?.spacing ?? 24;
9662
+ this.store = new ElementStore();
9663
+ this.layerManager = new LayerManager(this.store);
9664
+ this.toolManager = new ToolManager();
9665
+ this.unsubToolRegister = this.toolManager.onRegister((tool) => {
9666
+ if (_Viewport.isSelectionSource(tool)) this.attachSelectionSource(tool);
9667
+ });
9668
+ const existingSelect = this.getSelectTool();
9669
+ if (existingSelect && _Viewport.isSelectionSource(existingSelect)) {
9670
+ this.attachSelectionSource(existingSelect);
9671
+ }
9672
+ this.renderer = new ElementRenderer();
9673
+ this.renderer.setStore(this.store);
9674
+ this.renderer.setCamera(this.camera);
9675
+ this.renderer.setOnImageLoad(() => {
9676
+ this.renderLoop.markAllLayersDirty();
9677
+ this.requestRender();
9678
+ });
9679
+ this.renderer.setOnImageError((src, cause) => {
9680
+ const elementIds = [];
9681
+ for (const el of this.store.getAll()) {
9682
+ if (el.type === "image" && el.src === src) elementIds.push(el.id);
9683
+ }
9684
+ if (options.onImageError) {
9685
+ options.onImageError({ src, elementIds, cause });
9686
+ } else {
9687
+ console.warn(`[fieldnotes] image failed to load: ${src}`);
9688
+ }
9689
+ });
9690
+ this.renderer.setHtmlPainters(this.htmlPainters);
9691
+ this.renderer.setDiagnosticSink((d) => {
9692
+ this.htmlDiagnostics.emit(d, {
9693
+ registryVersion: this.htmlPainters.version,
9694
+ elementVersion: this.store.getVersion(d.elementId)
9695
+ });
9696
+ });
9697
+ this.noteEditor = new NoteEditor({
9698
+ fontSizePresets: options.fontSizePresets,
9699
+ toolbar: options.toolbar,
9700
+ placeholder: options.placeholder
9701
+ });
9702
+ this.noteEditor.setOnStop((id) => this.interactions.onTextEditStop(id));
9703
+ this.noteEditor.setOnInput((id) => this.interactions.liveFitHeight(id));
9704
+ this.arrowLabelEditor = new ArrowLabelEditor();
9705
+ this.noteEditor.setHistoryHooks(
9706
+ () => this.historyRecorder.begin(),
9707
+ () => this.historyRecorder.commit()
9708
+ );
9709
+ this.onHtmlElementMount = options.onHtmlElementMount;
9710
+ this.dropHandler = options.onDrop;
9711
+ this.history = new HistoryStack();
8242
9712
  this.historyRecorder = new HistoryRecorder(this.store, this.history, this.layerManager);
8243
9713
  this.unsubRecorderEnd = this.historyRecorder.onTransactionEnd(() => {
8244
9714
  if (!this.pendingSelectionPrune) return;
@@ -8301,7 +9771,7 @@ var Viewport = class _Viewport {
8301
9771
  if (options.contextMenu !== false) {
8302
9772
  this.contextMenu = new ContextMenu({
8303
9773
  onCommand: (action) => this.runAction(action),
8304
- onClose: noop
9774
+ onClose: noop2
8305
9775
  });
8306
9776
  }
8307
9777
  this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
@@ -8338,6 +9808,7 @@ var Viewport = class _Viewport {
8338
9808
  marginViewport: this.marginViewport,
8339
9809
  hybridSurface: new HybridRenderSurface(this.paintStack)
8340
9810
  });
9811
+ this.unsubHtmlPainters = this.htmlPainters.onChange(() => this.onHtmlRegistryChanged());
8341
9812
  this.unsubCamera = this.camera.onChange(() => {
8342
9813
  this.applyCameraTransform();
8343
9814
  this.noteEditor.updateToolbarPosition();
@@ -8355,6 +9826,9 @@ var Viewport = class _Viewport {
8355
9826
  this.unsubStore = [
8356
9827
  this.store.on("add", (el) => {
8357
9828
  if (el.type === "grid") this.gridController.syncContext();
9829
+ if (el.type === "html") {
9830
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
9831
+ }
8358
9832
  this.renderLoop.markLayerDirty(el.layerId);
8359
9833
  this.requestRender();
8360
9834
  }),
@@ -8362,12 +9836,16 @@ var Viewport = class _Viewport {
8362
9836
  if (el.type === "grid") this.gridController.syncContext();
8363
9837
  this.unbindArrowsFrom(el);
8364
9838
  this.domNodeManager.removeDomNode(el.id);
9839
+ this.htmlDiagnostics.forget(el.id);
8365
9840
  this.renderLoop.markLayerDirty(el.layerId);
8366
9841
  this.requestRender();
8367
9842
  this.handleRemovedElement(el.id);
8368
9843
  }),
8369
9844
  this.store.on("update", ({ previous, current }) => {
8370
9845
  if (current.type === "grid") this.gridController.syncContext();
9846
+ if (current.type === "html") {
9847
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
9848
+ }
8371
9849
  this.renderLoop.markLayerDirty(current.layerId);
8372
9850
  if (previous.layerId !== current.layerId) {
8373
9851
  this.renderLoop.markLayerDirty(previous.layerId);
@@ -8376,6 +9854,7 @@ var Viewport = class _Viewport {
8376
9854
  }),
8377
9855
  this.store.on("clear", () => {
8378
9856
  this.domNodeManager.clearDomNodes();
9857
+ this.htmlDiagnostics.reset();
8379
9858
  this.renderLoop.markAllLayersDirty();
8380
9859
  this.gridController.syncContext();
8381
9860
  this.requestRender();
@@ -8448,6 +9927,21 @@ var Viewport = class _Viewport {
8448
9927
  contextMenu = null;
8449
9928
  minimap = null;
8450
9929
  htmlRenderers = /* @__PURE__ */ new Map();
9930
+ htmlPainters = new HtmlPainterRegistry();
9931
+ htmlDiagnosticListeners = /* @__PURE__ */ new Set();
9932
+ htmlDiagnostics = new HtmlPaintDiagnosticDeduper((d) => {
9933
+ for (const listener of [...this.htmlDiagnosticListeners]) {
9934
+ try {
9935
+ listener(d);
9936
+ } catch {
9937
+ }
9938
+ }
9939
+ });
9940
+ resolveRouting = (el) => resolveHtmlRouting(el, this.htmlPainters);
9941
+ unsubHtmlPainters;
9942
+ activation = null;
9943
+ activationGeneration = 0;
9944
+ activationListeners = /* @__PURE__ */ new Set();
8451
9945
  resizeListeners = /* @__PURE__ */ new Set();
8452
9946
  selectionListeners = /* @__PURE__ */ new Set();
8453
9947
  detachSelectionSource = null;
@@ -8537,11 +10031,26 @@ var Viewport = class _Viewport {
8537
10031
  exportJSON() {
8538
10032
  return JSON.stringify(this.exportState());
8539
10033
  }
10034
+ /**
10035
+ * Injects this viewport's own html painter registry into export options so a host
10036
+ * that registered painters via `registerHtmlPainter`/`expectCanvasHtmlTypes` gets
10037
+ * markers in exports without passing anything. An explicitly passed `htmlPainters`
10038
+ * REPLACES the viewport's registry rather than merging with it. `expectedCanvasTypes`
10039
+ * is always UNIONED with the resolved registry's own declarations — a caller's set
10040
+ * can only add expectations, never shrink the registry's own.
10041
+ */
10042
+ withHtmlDefaults(options) {
10043
+ const base = options ?? {};
10044
+ const registry = base.htmlPainters ?? this.htmlPainters;
10045
+ const declared = registry.canvasTypes;
10046
+ const expected = base.expectedCanvasTypes ? /* @__PURE__ */ new Set([...declared, ...base.expectedCanvasTypes]) : declared;
10047
+ return { ...base, htmlPainters: registry, expectedCanvasTypes: expected };
10048
+ }
8540
10049
  async exportImage(options) {
8541
- return exportImage(this.store, options, this.layerManager);
10050
+ return exportImage(this.store, this.withHtmlDefaults(options), this.layerManager);
8542
10051
  }
8543
10052
  async exportSVG(options) {
8544
- return exportSvg(this.store, options, this.layerManager);
10053
+ return exportSvg(this.store, this.withHtmlDefaults(options), this.layerManager);
8545
10054
  }
8546
10055
  loadState(state) {
8547
10056
  this.inputHandler.flushPendingHistory();
@@ -8569,6 +10078,7 @@ var Viewport = class _Viewport {
8569
10078
  const node = this.domNodeManager.getNode(el.id);
8570
10079
  if (node) {
8571
10080
  this.onHtmlElementMount(el.id, el.domId, node);
10081
+ this.domNodeManager.markHostOwnedContent(el.id);
8572
10082
  node.dataset["initialized"] = "true";
8573
10083
  Object.assign(node.style, {
8574
10084
  overflow: "hidden",
@@ -8581,6 +10091,7 @@ var Viewport = class _Viewport {
8581
10091
  this.historyRecorder.resume();
8582
10092
  this.camera.moveTo(state.camera.position.x, state.camera.position.y);
8583
10093
  this.camera.setZoom(state.camera.zoom);
10094
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
8584
10095
  }
8585
10096
  loadJSON(json) {
8586
10097
  this.loadState(parseState(json));
@@ -8705,6 +10216,125 @@ var Viewport = class _Viewport {
8705
10216
  if (el.type !== "html") throw new Error(`Element ${id} is not an HTML element`);
8706
10217
  this.domNodeManager.resetHtmlContent(id);
8707
10218
  this.domNodeManager.storeHtmlContent(id, newContent);
10219
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
10220
+ this.requestRender();
10221
+ }
10222
+ /**
10223
+ * Declares htmlTypes that route to canvas painters even before a painter for
10224
+ * them registers, so the element renderer never treats them as DOM-backed
10225
+ * (avoiding a DOM-mount flash while a host is still loading its painter).
10226
+ * Returns an idempotent release; each `expect` call is independently reference
10227
+ * counted by the registry.
10228
+ */
10229
+ expectCanvasHtmlTypes(htmlTypes) {
10230
+ return this.htmlPainters.expect(htmlTypes);
10231
+ }
10232
+ /**
10233
+ * Direct access to the viewport's live html-painter registry — the same
10234
+ * instance the viewport itself uses to route canvas-backed html elements.
10235
+ * Beyond `register`/`expectCanvasHtmlTypes` (already exposed above), this
10236
+ * hands out `getActivePainter`, `canvasTypes`, `onChange`, and `version`,
10237
+ * so a surface such as the minimap that needs to read routing state or
10238
+ * react to registry changes can do so without the viewport re-deriving or
10239
+ * proxying each capability individually.
10240
+ */
10241
+ getHtmlPainters() {
10242
+ return this.htmlPainters;
10243
+ }
10244
+ /**
10245
+ * Registers the canvas painter for `htmlType`. Later registrations for the
10246
+ * same type shadow earlier ones (LIFO); unregistering restores the previous
10247
+ * entry. Existing elements of this type reconcile synchronously — DOM nodes
10248
+ * detach and the render loop repaints on the next frame.
10249
+ */
10250
+ registerHtmlPainter(htmlType, painter) {
10251
+ return this.htmlPainters.register(htmlType, painter);
10252
+ }
10253
+ /**
10254
+ * Subscribes to diagnostics emitted while painting canvas-routed html
10255
+ * elements (missing painter, painter threw, degenerate size). Deduped per
10256
+ * element/target/kind against the current registry and element versions, so
10257
+ * a fail -> repair -> fail-again sequence reports twice rather than being
10258
+ * suppressed forever. Returns an idempotent unsubscribe.
10259
+ */
10260
+ onHtmlPaintDiagnostic(listener) {
10261
+ this.htmlDiagnosticListeners.add(listener);
10262
+ return () => this.htmlDiagnosticListeners.delete(listener);
10263
+ }
10264
+ /**
10265
+ * Enables (or replaces, or with `null` disables) pointer activation of
10266
+ * canvas-painted elements — the bridge for elements that are drawn rather than
10267
+ * mounted and so cannot receive DOM events. **Default off**, so every existing
10268
+ * consumer behaves identically.
10269
+ *
10270
+ * The controller is a passive observer: listeners are `{ passive: true }` and
10271
+ * it never calls `preventDefault`, `stopPropagation`, or takes pointer capture.
10272
+ * Changing or disabling activation resets all active and pending gestures.
10273
+ * Throws `RangeError` for a non-finite/negative `slopPx` or a non-positive
10274
+ * `doubleDelayMs`, leaving any existing activation untouched.
10275
+ *
10276
+ * The returned disposer clears **only its own generation**, so a stale
10277
+ * Strict-Mode cleanup cannot tear down a newer registration.
10278
+ */
10279
+ setActivation(options) {
10280
+ const next = options ? new ElementActivation(
10281
+ {
10282
+ element: this.wrapper,
10283
+ camera: this.camera,
10284
+ store: this.store,
10285
+ resolveHtmlRouting: this.resolveRouting,
10286
+ isLayerVisible: (layerId) => this.layerManager.isLayerVisible(layerId),
10287
+ // Owner-side busy signal: the camera is gliding under pan inertia, or
10288
+ // this very gesture is the one that stopped the glide. Suppressing
10289
+ // both is what keeps "tap to stop a flick" from activating whatever
10290
+ // sits under the finger.
10291
+ isCameraBusy: () => this.inputHandler.isCameraCoasting()
10292
+ },
10293
+ options
10294
+ ) : null;
10295
+ this.activation?.dispose();
10296
+ this.activation = next;
10297
+ next?.onActivate((e) => this.emitActivation(e));
10298
+ const generation = ++this.activationGeneration;
10299
+ return () => {
10300
+ if (this.activationGeneration !== generation) return;
10301
+ this.activation?.dispose();
10302
+ this.activation = null;
10303
+ };
10304
+ }
10305
+ /**
10306
+ * Subscribes to element activations. Persistent and independent of
10307
+ * `setActivation`: subscribing before activation is enabled, or across a
10308
+ * replacement, keeps working. Emission iterates a snapshot with per-listener
10309
+ * try/catch. Returns an idempotent unsubscribe.
10310
+ */
10311
+ onElementActivate(listener) {
10312
+ this.activationListeners.add(listener);
10313
+ return () => {
10314
+ this.activationListeners.delete(listener);
10315
+ };
10316
+ }
10317
+ emitActivation(event) {
10318
+ for (const listener of [...this.activationListeners]) {
10319
+ try {
10320
+ listener(event);
10321
+ } catch {
10322
+ }
10323
+ }
10324
+ }
10325
+ /**
10326
+ * Fires whenever the html painter registry's active-painter set changes
10327
+ * (declare, register, or their release). Reconciliation is synchronous —
10328
+ * routing flips (and any DOM detach/remount) happen before this returns —
10329
+ * while the actual repaint of newly canvas-routed elements is deferred to
10330
+ * the next render frame via markAllLayersDirty + requestRender. Does NOT
10331
+ * touch a minimap: Viewport does not own a MinimapController, and the
10332
+ * built-in wrapper / React <Minimap /> each subscribe to the registry
10333
+ * directly.
10334
+ */
10335
+ onHtmlRegistryChanged() {
10336
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
10337
+ this.renderLoop.markAllLayersDirty();
8708
10338
  this.requestRender();
8709
10339
  }
8710
10340
  addGrid(input) {
@@ -8867,6 +10497,11 @@ var Viewport = class _Viewport {
8867
10497
  this.unsubToolChange();
8868
10498
  this.unsubToolRegister();
8869
10499
  this.unsubRecorderEnd();
10500
+ this.unsubHtmlPainters();
10501
+ this.activation?.dispose();
10502
+ this.activation = null;
10503
+ this.activationListeners.clear();
10504
+ this.htmlDiagnosticListeners.clear();
8870
10505
  this.detachSelectionSource?.();
8871
10506
  this.detachSelectionSource = null;
8872
10507
  this.selectionListeners.clear();
@@ -9562,1440 +11197,827 @@ var PingInput = class {
9562
11197
  if (this.disposed) return false;
9563
11198
  return this.emit(world);
9564
11199
  }
9565
- /** Removes all DOM listeners and cancels any pending press. Idempotent. */
9566
- dispose() {
9567
- if (this.disposed) return;
9568
- this.disposed = true;
9569
- this.cancelPress();
9570
- this.downPointers.clear();
9571
- this.element.removeEventListener("pointerdown", this.handlePointerDown);
9572
- this.element.removeEventListener("pointermove", this.handlePointerMove);
9573
- this.element.removeEventListener("pointerup", this.handlePointerUp);
9574
- this.element.removeEventListener("pointercancel", this.handlePointerCancel);
9575
- this.element.removeEventListener("pointerleave", this.handlePointerLeave);
9576
- this.optionListeners.clear();
9577
- this.pingListeners.clear();
9578
- }
9579
- toLocal(e) {
9580
- const rect = this.element.getBoundingClientRect();
9581
- return { x: e.clientX - rect.left, y: e.clientY - rect.top };
9582
- }
9583
- onPointerDown(e) {
9584
- const local = this.toLocal(e);
9585
- this.lastPointerScreen = local;
9586
- const hadPointers = this.downPointers.size > 0;
9587
- this.downPointers.add(e.pointerId);
9588
- if (hadPointers) {
9589
- this.cancelPress();
9590
- return;
9591
- }
9592
- if (!this.longPressEnabled) return;
9593
- if (e.pointerType === "mouse" && e.button !== 0) return;
9594
- this.press = {
9595
- pointerId: e.pointerId,
9596
- x: local.x,
9597
- y: local.y,
9598
- timer: setTimeout(() => this.firePress(), this.longPressMs)
9599
- };
9600
- }
9601
- onPointerMove(e) {
9602
- const local = this.toLocal(e);
9603
- this.lastPointerScreen = local;
9604
- if (this.press === null || e.pointerId !== this.press.pointerId) return;
9605
- const dx = local.x - this.press.x;
9606
- const dy = local.y - this.press.y;
9607
- if (Math.hypot(dx, dy) > this.slopPx) this.cancelPress();
9608
- }
9609
- onPointerEnd(e) {
9610
- this.downPointers.delete(e.pointerId);
9611
- if (this.press !== null && e.pointerId === this.press.pointerId) this.cancelPress();
9612
- }
9613
- cancelPress() {
9614
- if (this.press === null) return;
9615
- clearTimeout(this.press.timer);
9616
- this.press = null;
9617
- }
9618
- firePress() {
9619
- if (this.press === null) return;
9620
- const screen = { x: this.press.x, y: this.press.y };
9621
- this.press = null;
9622
- this.emit(this.host.screenToWorld(screen));
9623
- }
9624
- emit(world) {
9625
- if (this.shouldPing && !this.shouldPing()) return false;
9626
- const t = this.now();
9627
- if (t - this.lastEmitAt < this.minIntervalMs) return false;
9628
- this.lastEmitAt = t;
9629
- const emission = {
9630
- x: world.x,
9631
- y: world.y,
9632
- color: this.color,
9633
- durationMs: this.durationMs,
9634
- radius: this.radius
9635
- };
9636
- for (const listener of this.pingListeners) {
9637
- try {
9638
- listener(emission);
9639
- } catch {
9640
- }
9641
- }
9642
- return true;
9643
- }
9644
- };
9645
-
9646
- // src/canvas/camera-view.ts
9647
- function assertValidView(view) {
9648
- const { x, y, w, h } = view;
9649
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
9650
- throw new Error("[fieldnotes] CameraView requires finite x, y, w, and h");
9651
- }
9652
- if (w <= 0 || h <= 0) {
9653
- throw new Error("[fieldnotes] CameraView requires positive w and h");
9654
- }
9655
- }
9656
- function assertCanvasDims(canvasW, canvasH) {
9657
- if (!Number.isFinite(canvasW) || !Number.isFinite(canvasH)) {
9658
- throw new Error("[fieldnotes] canvas dimensions must be finite");
9659
- }
9660
- if (canvasW < 0 || canvasH < 0) {
9661
- throw new Error("[fieldnotes] canvas dimensions must not be negative");
9662
- }
9663
- }
9664
- function canvasDimsUsable(canvasW, canvasH) {
9665
- return Number.isFinite(canvasW) && Number.isFinite(canvasH) && canvasW >= 0 && canvasH >= 0;
9666
- }
9667
- function captureCameraView(viewport) {
9668
- const rect = viewport.getVisibleRect();
9669
- return { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
9670
- }
9671
- function fitZoomForView(view, canvasW, canvasH) {
9672
- assertValidView(view);
9673
- assertCanvasDims(canvasW, canvasH);
9674
- if (canvasW === 0 || canvasH === 0) {
9675
- throw new Error("[fieldnotes] fitZoomForView requires a non-zero canvas size");
9676
- }
9677
- return Math.min(canvasW / view.w, canvasH / view.h);
9678
- }
9679
- function cameraOriginForView(view, zoom, canvasW, canvasH) {
9680
- return {
9681
- x: canvasW / 2 - (view.x + view.w / 2) * zoom,
9682
- y: canvasH / 2 - (view.y + view.h / 2) * zoom
9683
- };
9684
- }
9685
- function applyCameraView(camera, view, canvasW, canvasH) {
9686
- assertValidView(view);
9687
- assertCanvasDims(canvasW, canvasH);
9688
- if (canvasW === 0 || canvasH === 0) return;
9689
- camera.setZoom(fitZoomForView(view, canvasW, canvasH));
9690
- const origin = cameraOriginForView(view, camera.zoom, canvasW, canvasH);
9691
- camera.moveTo(origin.x, origin.y);
9692
- }
9693
-
9694
- // src/canvas/camera-animator.ts
9695
- var DEFAULT_DURATION_MS3 = 400;
9696
- var FRAMED_EPSILON = 1e-6;
9697
- var easeOutCubic2 = (t) => 1 - Math.pow(1 - t, 3);
9698
- function lerp(a, b, k) {
9699
- return a + (b - a) * k;
9700
- }
9701
- function lerpView(from, to, k) {
9702
- return {
9703
- x: lerp(from.x, to.x, k),
9704
- y: lerp(from.y, to.y, k),
9705
- w: lerp(from.w, to.w, k),
9706
- h: lerp(from.h, to.h, k)
9707
- };
9708
- }
9709
- function viewsClose(a, b) {
9710
- return Math.abs(a.x - b.x) < FRAMED_EPSILON && Math.abs(a.y - b.y) < FRAMED_EPSILON && Math.abs(a.w - b.w) < FRAMED_EPSILON && Math.abs(a.h - b.h) < FRAMED_EPSILON;
9711
- }
9712
- var CameraAnimator = class {
9713
- camera;
9714
- getCanvasSize;
9715
- frames;
9716
- now;
9717
- durationMs;
9718
- easing;
9719
- rafId = null;
9720
- from = null;
9721
- to = null;
9722
- startedAt = 0;
9723
- endListeners = /* @__PURE__ */ new Set();
9724
- /**
9725
- * Monotonic operation counter. `animateTo`/`jumpTo` claim a generation
9726
- * before emitting 'superseded'; if an onEnd listener starts a newer
9727
- * operation during that emit, the outer call sees a bumped counter and
9728
- * bails instead of overwriting the nested animation's state. Without this,
9729
- * the nested animation would run to completion having never reported an end
9730
- * reason, breaking the exactly-one guarantee the spec makes.
9731
- */
9732
- generation = 0;
9733
- lastWrite = null;
9734
- disposed = false;
9735
- detachListeners = null;
9736
- constructor(element, camera, options) {
9737
- if (options.frames !== void 0) {
9738
- const { requestFrame, cancelFrame } = options.frames;
9739
- if (typeof requestFrame !== "function" || typeof cancelFrame !== "function") {
9740
- throw new Error(
9741
- "[fieldnotes] CameraAnimator: `frames` must supply both requestFrame and cancelFrame"
9742
- );
9743
- }
9744
- }
9745
- this.camera = camera;
9746
- this.getCanvasSize = options.getCanvasSize;
9747
- this.frames = options.frames ?? {
9748
- requestFrame: (cb) => requestAnimationFrame(cb),
9749
- cancelFrame: (id) => cancelAnimationFrame(id)
9750
- };
9751
- this.now = options.now ?? (() => performance.now());
9752
- this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
9753
- this.easing = options.easing ?? easeOutCubic2;
9754
- if (options.interactive ?? true) {
9755
- const onUserInput = () => {
9756
- this.end("cancelled");
9757
- };
9758
- const types = ["pointerdown", "wheel", "keydown"];
9759
- for (const type of types) {
9760
- element.addEventListener(type, onUserInput, { passive: true });
9761
- }
9762
- this.detachListeners = () => {
9763
- for (const type of types) {
9764
- element.removeEventListener(type, onUserInput);
9765
- }
9766
- };
9767
- }
9768
- }
9769
- get animating() {
9770
- return this.to !== null;
9771
- }
9772
- onEnd(listener) {
9773
- this.endListeners.add(listener);
9774
- return () => this.endListeners.delete(listener);
9775
- }
9776
- animateTo(view) {
9777
- const size = this.validateAndMeasure(view);
9778
- if (size === null) return;
9779
- const current = this.camera.getVisibleRect(size.w, size.h);
9780
- const generation = ++this.generation;
9781
- this.end("superseded");
9782
- if (this.generation !== generation) return;
9783
- if (viewsClose(current, view)) {
9784
- applyCameraView(this.camera, view, size.w, size.h);
9785
- this.emit("complete");
9786
- return;
9787
- }
9788
- this.from = current;
9789
- this.to = view;
9790
- this.startedAt = this.now();
9791
- this.lastWrite = null;
9792
- this.rafId = this.frames.requestFrame(this.step);
9793
- }
9794
- jumpTo(view) {
9795
- const size = this.validateAndMeasure(view);
9796
- if (size === null) return;
9797
- const generation = ++this.generation;
9798
- this.end("superseded");
9799
- if (this.generation !== generation) return;
9800
- applyCameraView(this.camera, view, size.w, size.h);
9801
- this.lastWrite = null;
9802
- }
9803
- cancel() {
9804
- if (this.disposed) return;
9805
- this.end("cancelled");
9806
- }
9807
- /**
9808
- * Terminal. Order is load-bearing: the flag is set BEFORE any listener runs,
9809
- * because an onEnd listener can call animateTo during the disposal callback.
9810
- * With the flag set last, that call would start a real animation which the
9811
- * listener clear then silently discards — a second animation with no end
9812
- * reason, breaking the exactly-one guarantee.
9813
- */
11200
+ /** Removes all DOM listeners and cancels any pending press. Idempotent. */
9814
11201
  dispose() {
9815
11202
  if (this.disposed) return;
9816
11203
  this.disposed = true;
9817
- this.generation++;
9818
- const wasAnimating = this.to !== null;
9819
- this.clearFrame();
9820
- this.from = null;
9821
- this.to = null;
9822
- this.lastWrite = null;
9823
- if (wasAnimating) this.emit("cancelled");
9824
- this.endListeners.clear();
9825
- this.detachListeners?.();
9826
- this.detachListeners = null;
11204
+ this.cancelPress();
11205
+ this.downPointers.clear();
11206
+ this.element.removeEventListener("pointerdown", this.handlePointerDown);
11207
+ this.element.removeEventListener("pointermove", this.handlePointerMove);
11208
+ this.element.removeEventListener("pointerup", this.handlePointerUp);
11209
+ this.element.removeEventListener("pointercancel", this.handlePointerCancel);
11210
+ this.element.removeEventListener("pointerleave", this.handlePointerLeave);
11211
+ this.optionListeners.clear();
11212
+ this.pingListeners.clear();
9827
11213
  }
9828
- /**
9829
- * Steps 1-3 of the public-call contract. Returns null when the caller must
9830
- * stop, having already handled termination.
9831
- *
9832
- * The disposed check precedes validation deliberately: ordering it after
9833
- * would make `disposed.animateTo(invalidView)` both required to throw and
9834
- * required to stay silent. Disposal wins — a terminal animator is inert for
9835
- * every input, and post-disposal calls are exactly the racy teardown paths
9836
- * where a throw is least useful.
9837
- */
9838
- validateAndMeasure(view) {
9839
- if (this.disposed) return null;
9840
- assertValidView(view);
9841
- const size = this.getCanvasSize();
9842
- assertCanvasDims(size.w, size.h);
9843
- if (size.w === 0 || size.h === 0) {
9844
- this.end("cancelled");
9845
- return null;
9846
- }
9847
- return size;
11214
+ toLocal(e) {
11215
+ const rect = this.element.getBoundingClientRect();
11216
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
9848
11217
  }
9849
- step = () => {
9850
- if (this.disposed || this.to === null || this.from === null) return;
9851
- if (this.foreignWrite()) {
9852
- this.end("cancelled");
9853
- return;
9854
- }
9855
- const size = this.getCanvasSize();
9856
- if (!canvasDimsUsable(size.w, size.h)) {
9857
- this.end("cancelled");
9858
- return;
9859
- }
9860
- if (size.w === 0 || size.h === 0) {
9861
- this.end("cancelled");
9862
- return;
9863
- }
9864
- const elapsed = this.now() - this.startedAt;
9865
- const t = this.durationMs <= 0 ? 1 : Math.min(1, elapsed / this.durationMs);
9866
- const view = lerpView(this.from, this.to, this.easing(t));
9867
- applyCameraView(this.camera, view, size.w, size.h);
9868
- this.recordWrite();
9869
- if (t >= 1) {
9870
- this.end("complete");
11218
+ onPointerDown(e) {
11219
+ const local = this.toLocal(e);
11220
+ this.lastPointerScreen = local;
11221
+ const hadPointers = this.downPointers.size > 0;
11222
+ this.downPointers.add(e.pointerId);
11223
+ if (hadPointers) {
11224
+ this.cancelPress();
9871
11225
  return;
9872
11226
  }
9873
- this.rafId = this.frames.requestFrame(this.step);
9874
- };
9875
- recordWrite() {
9876
- this.lastWrite = {
9877
- x: this.camera.position.x,
9878
- y: this.camera.position.y,
9879
- zoom: this.camera.zoom
11227
+ if (!this.longPressEnabled) return;
11228
+ if (e.pointerType === "mouse" && e.button !== 0) return;
11229
+ this.press = {
11230
+ pointerId: e.pointerId,
11231
+ x: local.x,
11232
+ y: local.y,
11233
+ timer: setTimeout(() => this.firePress(), this.longPressMs)
9880
11234
  };
9881
11235
  }
9882
- foreignWrite() {
9883
- if (this.lastWrite === null) return false;
9884
- const eps = 1e-6;
9885
- return Math.abs(this.camera.position.x - this.lastWrite.x) > eps || Math.abs(this.camera.position.y - this.lastWrite.y) > eps || Math.abs(this.camera.zoom - this.lastWrite.zoom) > eps;
11236
+ onPointerMove(e) {
11237
+ const local = this.toLocal(e);
11238
+ this.lastPointerScreen = local;
11239
+ if (this.press === null || e.pointerId !== this.press.pointerId) return;
11240
+ const dx = local.x - this.press.x;
11241
+ const dy = local.y - this.press.y;
11242
+ if (Math.hypot(dx, dy) > this.slopPx) this.cancelPress();
9886
11243
  }
9887
- /** Terminates an in-flight animation with `reason`. No-op when idle. */
9888
- end(reason) {
9889
- if (this.disposed || this.to === null) return;
9890
- this.clearFrame();
9891
- this.from = null;
9892
- this.to = null;
9893
- this.lastWrite = null;
9894
- this.emit(reason);
11244
+ onPointerEnd(e) {
11245
+ this.downPointers.delete(e.pointerId);
11246
+ if (this.press !== null && e.pointerId === this.press.pointerId) this.cancelPress();
9895
11247
  }
9896
- clearFrame() {
9897
- if (this.rafId !== null) {
9898
- this.frames.cancelFrame(this.rafId);
9899
- this.rafId = null;
9900
- }
11248
+ cancelPress() {
11249
+ if (this.press === null) return;
11250
+ clearTimeout(this.press.timer);
11251
+ this.press = null;
9901
11252
  }
9902
- emit(reason) {
9903
- for (const listener of [...this.endListeners]) {
11253
+ firePress() {
11254
+ if (this.press === null) return;
11255
+ const screen = { x: this.press.x, y: this.press.y };
11256
+ this.press = null;
11257
+ this.emit(this.host.screenToWorld(screen));
11258
+ }
11259
+ emit(world) {
11260
+ if (this.shouldPing && !this.shouldPing()) return false;
11261
+ const t = this.now();
11262
+ if (t - this.lastEmitAt < this.minIntervalMs) return false;
11263
+ this.lastEmitAt = t;
11264
+ const emission = {
11265
+ x: world.x,
11266
+ y: world.y,
11267
+ color: this.color,
11268
+ durationMs: this.durationMs,
11269
+ radius: this.radius
11270
+ };
11271
+ for (const listener of this.pingListeners) {
9904
11272
  try {
9905
- listener(reason);
11273
+ listener(emission);
9906
11274
  } catch {
9907
11275
  }
9908
11276
  }
9909
- }
9910
- };
9911
-
9912
- // src/canvas/focus-presence.ts
9913
- var FOCUS_PRESENCE_KIND = "focus";
9914
- var AUDIENCES = ["all", "players", "display"];
9915
- function isPositiveFinite(value) {
9916
- return typeof value === "number" && Number.isFinite(value) && value > 0;
9917
- }
9918
- function isFiniteNumber2(value) {
9919
- return typeof value === "number" && Number.isFinite(value);
9920
- }
9921
- function isFocusPresence(data) {
9922
- if (typeof data !== "object" || data === null) return false;
9923
- const payload = data;
9924
- if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
9925
- if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
9926
- if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
9927
- if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
9928
- return false;
9929
- }
9930
- if (payload.color !== void 0 && typeof payload.color !== "string") return false;
9931
- return true;
9932
- }
9933
- function toFocusPresence(view, audience, color) {
9934
- return {
9935
- kind: FOCUS_PRESENCE_KIND,
9936
- x: view.x,
9937
- y: view.y,
9938
- w: view.w,
9939
- h: view.h,
9940
- audience,
9941
- ...color === void 0 ? {} : { color }
9942
- };
9943
- }
9944
-
9945
- // src/canvas/remote-focus-receiver.ts
9946
- function audienceIncludes(audience, role) {
9947
- if (role === "dm") return false;
9948
- if (audience === "all") return true;
9949
- if (audience === "players") return role === "player";
9950
- return role === "display";
9951
- }
9952
- var RemoteFocusReceiver = class {
9953
- role;
9954
- animator;
9955
- animate;
9956
- pulseColor;
9957
- overlay;
9958
- disposed = false;
9959
- constructor(host, options) {
9960
- this.role = options.role;
9961
- this.animator = options.animator;
9962
- this.animate = options.animate ?? true;
9963
- this.pulseColor = options.pulseColor;
9964
- this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
9965
- ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
9966
- ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
9967
- ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
9968
- maxPingsPerSender: 1
9969
- }) : null;
9970
- }
9971
- /**
9972
- * Applies a presence payload from `sender`. Returns `false` for payloads
9973
- * that are not focus frames, or are addressed to a different role, so hosts
9974
- * can feed every presence frame through without disturbing other handlers.
9975
- */
9976
- apply(from, data) {
9977
- if (this.disposed || !isFocusPresence(data)) return false;
9978
- if (!audienceIncludes(data.audience, this.role)) return false;
9979
- const view = { x: data.x, y: data.y, w: data.w, h: data.h };
9980
- if (this.animate) {
9981
- this.animator.animateTo(view);
9982
- } else {
9983
- this.animator.jumpTo(view);
9984
- }
9985
- this.overlay?.apply(from, {
9986
- kind: "ping",
9987
- x: view.x + view.w / 2,
9988
- y: view.y + view.h / 2,
9989
- color: data.color ?? this.pulseColor
9990
- });
9991
11277
  return true;
9992
11278
  }
9993
- /** Idempotent. Does NOT dispose the animator — the host owns that. */
9994
- dispose() {
9995
- if (this.disposed) return;
9996
- this.disposed = true;
9997
- this.overlay?.dispose();
9998
- }
9999
11279
  };
10000
11280
 
10001
- // src/tools/hand-tool.ts
10002
- var HandTool = class {
10003
- name = "hand";
10004
- panning = false;
10005
- lastScreen = { x: 0, y: 0 };
10006
- onActivate(ctx) {
10007
- ctx.setCursor?.("grab");
10008
- }
10009
- onDeactivate(ctx) {
10010
- ctx.setCursor?.("default");
11281
+ // src/canvas/camera-view.ts
11282
+ function assertValidView(view) {
11283
+ const { x, y, w, h } = view;
11284
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
11285
+ throw new Error("[fieldnotes] CameraView requires finite x, y, w, and h");
10011
11286
  }
10012
- onPointerDown(state, ctx) {
10013
- this.panning = true;
10014
- this.lastScreen = { x: state.x, y: state.y };
10015
- ctx.setCursor?.("grabbing");
11287
+ if (w <= 0 || h <= 0) {
11288
+ throw new Error("[fieldnotes] CameraView requires positive w and h");
10016
11289
  }
10017
- onPointerMove(state, ctx) {
10018
- if (!this.panning) return;
10019
- const dx = state.x - this.lastScreen.x;
10020
- const dy = state.y - this.lastScreen.y;
10021
- this.lastScreen = { x: state.x, y: state.y };
10022
- ctx.camera.pan(dx, dy);
11290
+ }
11291
+ function assertCanvasDims(canvasW, canvasH) {
11292
+ if (!Number.isFinite(canvasW) || !Number.isFinite(canvasH)) {
11293
+ throw new Error("[fieldnotes] canvas dimensions must be finite");
10023
11294
  }
10024
- onPointerUp(_state, ctx) {
10025
- this.panning = false;
10026
- ctx.setCursor?.("grab");
11295
+ if (canvasW < 0 || canvasH < 0) {
11296
+ throw new Error("[fieldnotes] canvas dimensions must not be negative");
10027
11297
  }
10028
- };
11298
+ }
11299
+ function canvasDimsUsable(canvasW, canvasH) {
11300
+ return Number.isFinite(canvasW) && Number.isFinite(canvasH) && canvasW >= 0 && canvasH >= 0;
11301
+ }
11302
+ function captureCameraView(viewport) {
11303
+ const rect = viewport.getVisibleRect();
11304
+ return { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
11305
+ }
11306
+ function fitZoomForView(view, canvasW, canvasH) {
11307
+ assertValidView(view);
11308
+ assertCanvasDims(canvasW, canvasH);
11309
+ if (canvasW === 0 || canvasH === 0) {
11310
+ throw new Error("[fieldnotes] fitZoomForView requires a non-zero canvas size");
11311
+ }
11312
+ return Math.min(canvasW / view.w, canvasH / view.h);
11313
+ }
11314
+ function cameraOriginForView(view, zoom, canvasW, canvasH) {
11315
+ return {
11316
+ x: canvasW / 2 - (view.x + view.w / 2) * zoom,
11317
+ y: canvasH / 2 - (view.y + view.h / 2) * zoom
11318
+ };
11319
+ }
11320
+ function applyCameraView(camera, view, canvasW, canvasH) {
11321
+ assertValidView(view);
11322
+ assertCanvasDims(canvasW, canvasH);
11323
+ if (canvasW === 0 || canvasH === 0) return;
11324
+ camera.setZoom(fitZoomForView(view, canvasW, canvasH));
11325
+ const origin = cameraOriginForView(view, camera.zoom, canvasW, canvasH);
11326
+ camera.moveTo(origin.x, origin.y);
11327
+ }
10029
11328
 
10030
- // src/tools/pencil-tool.ts
10031
- var MIN_POINTS_FOR_STROKE = 2;
10032
- var DEFAULT_SMOOTHING = 1.5;
10033
- var DEFAULT_PRESSURE = 0.5;
10034
- var DEFAULT_MIN_POINT_DISTANCE = 3;
10035
- var DEFAULT_PROGRESSIVE_THRESHOLD = 200;
10036
- var PROGRESSIVE_HOT_ZONE = 30;
10037
- var PencilTool = class {
10038
- name;
10039
- drawing = false;
10040
- points = [];
10041
- color;
10042
- width;
10043
- smoothing;
10044
- minPointDistance;
10045
- progressiveThreshold;
10046
- nextSimplifyAt;
10047
- opacity;
10048
- blendMode;
10049
- optionListeners = /* @__PURE__ */ new Set();
10050
- constructor(options = {}) {
10051
- this.name = options.name ?? "pencil";
10052
- this.color = options.color ?? "#000000";
10053
- this.width = options.width ?? 2;
10054
- this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
10055
- this.minPointDistance = options.minPointDistance ?? DEFAULT_MIN_POINT_DISTANCE;
10056
- this.progressiveThreshold = options.progressiveSimplifyThreshold ?? DEFAULT_PROGRESSIVE_THRESHOLD;
10057
- this.nextSimplifyAt = this.progressiveThreshold;
10058
- this.opacity = options.opacity ?? 1;
10059
- this.blendMode = options.blendMode;
11329
+ // src/canvas/camera-animator.ts
11330
+ var DEFAULT_DURATION_MS3 = 400;
11331
+ var FRAMED_EPSILON = 1e-6;
11332
+ var easeOutCubic2 = (t) => 1 - Math.pow(1 - t, 3);
11333
+ function lerp(a, b, k) {
11334
+ return a + (b - a) * k;
11335
+ }
11336
+ function lerpView(from, to, k) {
11337
+ return {
11338
+ x: lerp(from.x, to.x, k),
11339
+ y: lerp(from.y, to.y, k),
11340
+ w: lerp(from.w, to.w, k),
11341
+ h: lerp(from.h, to.h, k)
11342
+ };
11343
+ }
11344
+ function viewsClose(a, b) {
11345
+ return Math.abs(a.x - b.x) < FRAMED_EPSILON && Math.abs(a.y - b.y) < FRAMED_EPSILON && Math.abs(a.w - b.w) < FRAMED_EPSILON && Math.abs(a.h - b.h) < FRAMED_EPSILON;
11346
+ }
11347
+ var CameraAnimator = class {
11348
+ camera;
11349
+ getCanvasSize;
11350
+ frames;
11351
+ now;
11352
+ durationMs;
11353
+ easing;
11354
+ rafId = null;
11355
+ from = null;
11356
+ to = null;
11357
+ startedAt = 0;
11358
+ endListeners = /* @__PURE__ */ new Set();
11359
+ /**
11360
+ * Monotonic operation counter. `animateTo`/`jumpTo` claim a generation
11361
+ * before emitting 'superseded'; if an onEnd listener starts a newer
11362
+ * operation during that emit, the outer call sees a bumped counter and
11363
+ * bails instead of overwriting the nested animation's state. Without this,
11364
+ * the nested animation would run to completion having never reported an end
11365
+ * reason, breaking the exactly-one guarantee the spec makes.
11366
+ */
11367
+ generation = 0;
11368
+ lastWrite = null;
11369
+ disposed = false;
11370
+ detachListeners = null;
11371
+ constructor(element, camera, options) {
11372
+ if (options.frames !== void 0) {
11373
+ const { requestFrame, cancelFrame } = options.frames;
11374
+ if (typeof requestFrame !== "function" || typeof cancelFrame !== "function") {
11375
+ throw new Error(
11376
+ "[fieldnotes] CameraAnimator: `frames` must supply both requestFrame and cancelFrame"
11377
+ );
11378
+ }
11379
+ }
11380
+ this.camera = camera;
11381
+ this.getCanvasSize = options.getCanvasSize;
11382
+ this.frames = options.frames ?? {
11383
+ requestFrame: (cb) => requestAnimationFrame(cb),
11384
+ cancelFrame: (id) => cancelAnimationFrame(id)
11385
+ };
11386
+ this.now = options.now ?? (() => performance.now());
11387
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
11388
+ this.easing = options.easing ?? easeOutCubic2;
11389
+ if (options.interactive ?? true) {
11390
+ const onUserInput = () => {
11391
+ this.end("cancelled");
11392
+ };
11393
+ const types = ["pointerdown", "wheel", "keydown"];
11394
+ for (const type of types) {
11395
+ element.addEventListener(type, onUserInput, { passive: true });
11396
+ }
11397
+ this.detachListeners = () => {
11398
+ for (const type of types) {
11399
+ element.removeEventListener(type, onUserInput);
11400
+ }
11401
+ };
11402
+ }
10060
11403
  }
10061
- onActivate(ctx) {
10062
- ctx.setCursor?.("crosshair");
11404
+ get animating() {
11405
+ return this.to !== null;
10063
11406
  }
10064
- onDeactivate(ctx) {
10065
- ctx.setCursor?.("default");
11407
+ onEnd(listener) {
11408
+ this.endListeners.add(listener);
11409
+ return () => this.endListeners.delete(listener);
10066
11410
  }
10067
- getOptions() {
10068
- return {
10069
- color: this.color,
10070
- width: this.width,
10071
- smoothing: this.smoothing,
10072
- minPointDistance: this.minPointDistance,
10073
- progressiveSimplifyThreshold: this.progressiveThreshold,
10074
- opacity: this.opacity,
10075
- blendMode: this.blendMode
10076
- };
11411
+ animateTo(view) {
11412
+ const size = this.validateAndMeasure(view);
11413
+ if (size === null) return;
11414
+ const current = this.camera.getVisibleRect(size.w, size.h);
11415
+ const generation = ++this.generation;
11416
+ this.end("superseded");
11417
+ if (this.generation !== generation) return;
11418
+ if (viewsClose(current, view)) {
11419
+ applyCameraView(this.camera, view, size.w, size.h);
11420
+ this.emit("complete");
11421
+ return;
11422
+ }
11423
+ this.from = current;
11424
+ this.to = view;
11425
+ this.startedAt = this.now();
11426
+ this.lastWrite = null;
11427
+ this.rafId = this.frames.requestFrame(this.step);
10077
11428
  }
10078
- onOptionsChange(listener) {
10079
- this.optionListeners.add(listener);
10080
- return () => this.optionListeners.delete(listener);
11429
+ jumpTo(view) {
11430
+ const size = this.validateAndMeasure(view);
11431
+ if (size === null) return;
11432
+ const generation = ++this.generation;
11433
+ this.end("superseded");
11434
+ if (this.generation !== generation) return;
11435
+ applyCameraView(this.camera, view, size.w, size.h);
11436
+ this.lastWrite = null;
10081
11437
  }
10082
- setOptions(options) {
10083
- if (options.color !== void 0) this.color = options.color;
10084
- if (options.width !== void 0) this.width = options.width;
10085
- if (options.smoothing !== void 0) this.smoothing = options.smoothing;
10086
- if (options.minPointDistance !== void 0) this.minPointDistance = options.minPointDistance;
10087
- if (options.progressiveSimplifyThreshold !== void 0)
10088
- this.progressiveThreshold = options.progressiveSimplifyThreshold;
10089
- if (options.opacity !== void 0) this.opacity = options.opacity;
10090
- if (options.blendMode !== void 0) this.blendMode = options.blendMode;
10091
- this.notifyOptionsChange();
11438
+ cancel() {
11439
+ if (this.disposed) return;
11440
+ this.end("cancelled");
10092
11441
  }
10093
- onPointerDown(state, ctx) {
10094
- this.drawing = true;
10095
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
10096
- const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
10097
- this.points = [{ x: world.x, y: world.y, pressure }];
10098
- this.nextSimplifyAt = this.progressiveThreshold;
11442
+ /**
11443
+ * Terminal. Order is load-bearing: the flag is set BEFORE any listener runs,
11444
+ * because an onEnd listener can call animateTo during the disposal callback.
11445
+ * With the flag set last, that call would start a real animation which the
11446
+ * listener clear then silently discards a second animation with no end
11447
+ * reason, breaking the exactly-one guarantee.
11448
+ */
11449
+ dispose() {
11450
+ if (this.disposed) return;
11451
+ this.disposed = true;
11452
+ this.generation++;
11453
+ const wasAnimating = this.to !== null;
11454
+ this.clearFrame();
11455
+ this.from = null;
11456
+ this.to = null;
11457
+ this.lastWrite = null;
11458
+ if (wasAnimating) this.emit("cancelled");
11459
+ this.endListeners.clear();
11460
+ this.detachListeners?.();
11461
+ this.detachListeners = null;
10099
11462
  }
10100
- onPointerMove(state, ctx) {
10101
- if (!this.drawing) return;
10102
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
10103
- const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
10104
- const last = this.points[this.points.length - 1];
10105
- if (last) {
10106
- const dx = world.x - last.x;
10107
- const dy = world.y - last.y;
10108
- if (dx * dx + dy * dy < this.minPointDistance * this.minPointDistance) return;
10109
- }
10110
- this.points.push({ x: world.x, y: world.y, pressure });
10111
- if (this.points.length > this.nextSimplifyAt) {
10112
- const hotZone = this.points.slice(-PROGRESSIVE_HOT_ZONE);
10113
- const coldZone = this.points.slice(0, -PROGRESSIVE_HOT_ZONE);
10114
- const simplified = simplifyPoints(coldZone, this.smoothing * 2);
10115
- this.points = [...simplified, ...hotZone];
10116
- this.nextSimplifyAt = this.points.length + this.progressiveThreshold;
11463
+ /**
11464
+ * Steps 1-3 of the public-call contract. Returns null when the caller must
11465
+ * stop, having already handled termination.
11466
+ *
11467
+ * The disposed check precedes validation deliberately: ordering it after
11468
+ * would make `disposed.animateTo(invalidView)` both required to throw and
11469
+ * required to stay silent. Disposal wins — a terminal animator is inert for
11470
+ * every input, and post-disposal calls are exactly the racy teardown paths
11471
+ * where a throw is least useful.
11472
+ */
11473
+ validateAndMeasure(view) {
11474
+ if (this.disposed) return null;
11475
+ assertValidView(view);
11476
+ const size = this.getCanvasSize();
11477
+ assertCanvasDims(size.w, size.h);
11478
+ if (size.w === 0 || size.h === 0) {
11479
+ this.end("cancelled");
11480
+ return null;
10117
11481
  }
10118
- ctx.requestRender();
11482
+ return size;
10119
11483
  }
10120
- onPointerUp(_state, ctx) {
10121
- if (!this.drawing) return;
10122
- this.drawing = false;
10123
- if (this.points.length < MIN_POINTS_FOR_STROKE) {
10124
- this.points = [];
11484
+ step = () => {
11485
+ if (this.disposed || this.to === null || this.from === null) return;
11486
+ if (this.foreignWrite()) {
11487
+ this.end("cancelled");
10125
11488
  return;
10126
11489
  }
10127
- const simplified = simplifyPoints(this.points, this.smoothing);
10128
- const stroke = createStroke({
10129
- points: simplified,
10130
- color: this.color,
10131
- width: this.width,
10132
- layerId: ctx.activeLayerId ?? "",
10133
- opacity: this.opacity,
10134
- blendMode: this.blendMode
10135
- });
10136
- ctx.store.add(stroke);
10137
- computeStrokeSegments(stroke);
10138
- this.points = [];
10139
- ctx.requestRender();
11490
+ const size = this.getCanvasSize();
11491
+ if (!canvasDimsUsable(size.w, size.h)) {
11492
+ this.end("cancelled");
11493
+ return;
11494
+ }
11495
+ if (size.w === 0 || size.h === 0) {
11496
+ this.end("cancelled");
11497
+ return;
11498
+ }
11499
+ const elapsed = this.now() - this.startedAt;
11500
+ const t = this.durationMs <= 0 ? 1 : Math.min(1, elapsed / this.durationMs);
11501
+ const view = lerpView(this.from, this.to, this.easing(t));
11502
+ applyCameraView(this.camera, view, size.w, size.h);
11503
+ this.recordWrite();
11504
+ if (t >= 1) {
11505
+ this.end("complete");
11506
+ return;
11507
+ }
11508
+ this.rafId = this.frames.requestFrame(this.step);
11509
+ };
11510
+ recordWrite() {
11511
+ this.lastWrite = {
11512
+ x: this.camera.position.x,
11513
+ y: this.camera.position.y,
11514
+ zoom: this.camera.zoom
11515
+ };
10140
11516
  }
10141
- notifyOptionsChange() {
10142
- for (const listener of this.optionListeners) listener();
11517
+ foreignWrite() {
11518
+ if (this.lastWrite === null) return false;
11519
+ const eps = 1e-6;
11520
+ return Math.abs(this.camera.position.x - this.lastWrite.x) > eps || Math.abs(this.camera.position.y - this.lastWrite.y) > eps || Math.abs(this.camera.zoom - this.lastWrite.zoom) > eps;
10143
11521
  }
10144
- renderOverlay(ctx) {
10145
- if (!this.drawing || this.points.length < 2) return;
10146
- ctx.save();
10147
- ctx.strokeStyle = this.color;
10148
- ctx.lineCap = "round";
10149
- ctx.lineJoin = "round";
10150
- ctx.globalAlpha = this.blendMode ? this.opacity : 0.8;
10151
- if (this.blendMode) ctx.globalCompositeOperation = this.blendMode;
10152
- const segments = smoothToSegments(this.points);
10153
- for (const seg of segments) {
10154
- const w = (pressureToWidth(seg.start.pressure, this.width) + pressureToWidth(seg.end.pressure, this.width)) / 2;
10155
- ctx.lineWidth = w;
10156
- ctx.beginPath();
10157
- ctx.moveTo(seg.start.x, seg.start.y);
10158
- ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
10159
- ctx.stroke();
11522
+ /** Terminates an in-flight animation with `reason`. No-op when idle. */
11523
+ end(reason) {
11524
+ if (this.disposed || this.to === null) return;
11525
+ this.clearFrame();
11526
+ this.from = null;
11527
+ this.to = null;
11528
+ this.lastWrite = null;
11529
+ this.emit(reason);
11530
+ }
11531
+ clearFrame() {
11532
+ if (this.rafId !== null) {
11533
+ this.frames.cancelFrame(this.rafId);
11534
+ this.rafId = null;
11535
+ }
11536
+ }
11537
+ emit(reason) {
11538
+ for (const listener of [...this.endListeners]) {
11539
+ try {
11540
+ listener(reason);
11541
+ } catch {
11542
+ }
10160
11543
  }
10161
- ctx.restore();
10162
11544
  }
10163
11545
  };
10164
11546
 
10165
- // src/elements/stroke-hit.ts
10166
- function hitTestStroke(stroke, point, radius) {
10167
- const bounds = getElementBounds(stroke);
10168
- if (!bounds) return false;
10169
- if (point.x < bounds.x - radius || point.x > bounds.x + bounds.w + radius || point.y < bounds.y - radius || point.y > bounds.y + bounds.h + radius) {
11547
+ // src/canvas/focus-presence.ts
11548
+ var FOCUS_PRESENCE_KIND = "focus";
11549
+ var AUDIENCES = ["all", "players", "display"];
11550
+ function isPositiveFinite(value) {
11551
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
11552
+ }
11553
+ function isFiniteNumber2(value) {
11554
+ return typeof value === "number" && Number.isFinite(value);
11555
+ }
11556
+ function isFocusPresence(data) {
11557
+ if (typeof data !== "object" || data === null) return false;
11558
+ const payload = data;
11559
+ if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
11560
+ if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
11561
+ if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
11562
+ if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
10170
11563
  return false;
10171
11564
  }
10172
- const radiusSq = radius * radius;
10173
- const local = { x: point.x - stroke.position.x, y: point.y - stroke.position.y };
10174
- const { segments } = getStrokeRenderData(stroke);
10175
- if (segments.length === 0) {
10176
- const p = stroke.points[0];
10177
- if (!p) return false;
10178
- const dx = p.x - local.x;
10179
- const dy = p.y - local.y;
10180
- return dx * dx + dy * dy <= radiusSq;
10181
- }
10182
- for (const seg of segments) {
10183
- if (distSqToSegment(local, seg.start, seg.end) <= radiusSq) return true;
10184
- }
10185
- return false;
11565
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11566
+ return true;
10186
11567
  }
10187
-
10188
- // src/elements/stroke-erase.ts
10189
- function lerp2(a, b, t) {
11568
+ function toFocusPresence(view, audience, color) {
10190
11569
  return {
10191
- x: a.x + (b.x - a.x) * t,
10192
- y: a.y + (b.y - a.y) * t,
10193
- pressure: a.pressure + (b.pressure - a.pressure) * t
10194
- };
10195
- }
10196
- function erasePoints(points, eraser, radius) {
10197
- const r2 = radius * radius;
10198
- if (points.length < 2) {
10199
- const p = points[0];
10200
- if (p && (p.x - eraser.x) ** 2 + (p.y - eraser.y) ** 2 <= r2) return [];
10201
- return null;
10202
- }
10203
- const runs = [];
10204
- let current = [];
10205
- let erased = false;
10206
- const flush = () => {
10207
- if (current.length >= 2) runs.push(current);
10208
- current = [];
11570
+ kind: FOCUS_PRESENCE_KIND,
11571
+ x: view.x,
11572
+ y: view.y,
11573
+ w: view.w,
11574
+ h: view.h,
11575
+ audience,
11576
+ ...color === void 0 ? {} : { color }
10209
11577
  };
10210
- for (let i = 0; i < points.length - 1; i++) {
10211
- const a = points[i];
10212
- const b = points[i + 1];
10213
- if (!a || !b) continue;
10214
- const dx = b.x - a.x;
10215
- const dy = b.y - a.y;
10216
- const fx = a.x - eraser.x;
10217
- const fy = a.y - eraser.y;
10218
- const A = dx * dx + dy * dy;
10219
- const B = 2 * (fx * dx + fy * dy);
10220
- const C = fx * fx + fy * fy - r2;
10221
- let tLo = 1;
10222
- let tHi = 0;
10223
- if (A === 0) {
10224
- if (C <= 0) {
10225
- tLo = 0;
10226
- tHi = 1;
10227
- }
10228
- } else {
10229
- const disc = B * B - 4 * A * C;
10230
- if (disc >= 0) {
10231
- const sq = Math.sqrt(disc);
10232
- const lo = Math.max(0, (-B - sq) / (2 * A));
10233
- const hi = Math.min(1, (-B + sq) / (2 * A));
10234
- if (lo < hi) {
10235
- tLo = lo;
10236
- tHi = hi;
10237
- }
10238
- }
10239
- }
10240
- if (tLo > tHi) {
10241
- if (current.length === 0) current.push(a);
10242
- current.push(b);
10243
- continue;
10244
- }
10245
- erased = true;
10246
- if (tLo > 0) {
10247
- if (current.length === 0) current.push(a);
10248
- current.push(lerp2(a, b, tLo));
10249
- flush();
10250
- } else {
10251
- flush();
10252
- }
10253
- if (tHi < 1) {
10254
- current = [lerp2(a, b, tHi), b];
10255
- }
10256
- }
10257
- flush();
10258
- return erased ? runs : null;
10259
11578
  }
10260
11579
 
10261
- // src/tools/eraser-tool.ts
10262
- var DEFAULT_RADIUS3 = 20;
10263
- function makeEraserCursor(radius) {
10264
- const size = radius * 2;
10265
- const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}'><circle cx='${radius}' cy='${radius}' r='${radius - 1}' fill='none' stroke='%23666' stroke-width='1.5'/></svg>`;
10266
- return `url("data:image/svg+xml,${svg}") ${radius} ${radius}, crosshair`;
11580
+ // src/canvas/remote-focus-receiver.ts
11581
+ function audienceIncludes(audience, role) {
11582
+ if (role === "dm") return false;
11583
+ if (audience === "all") return true;
11584
+ if (audience === "players") return role === "player";
11585
+ return role === "display";
10267
11586
  }
10268
- var EraserTool = class {
10269
- name = "eraser";
10270
- erasing = false;
10271
- radius;
10272
- cursor;
10273
- mode;
10274
- constructor(options = {}) {
10275
- this.radius = options.radius ?? DEFAULT_RADIUS3;
10276
- this.cursor = makeEraserCursor(this.radius);
10277
- this.mode = options.mode ?? "partial";
10278
- }
10279
- getOptions() {
10280
- return { radius: this.radius, mode: this.mode };
11587
+ var RemoteFocusReceiver = class {
11588
+ role;
11589
+ animator;
11590
+ animate;
11591
+ pulseColor;
11592
+ overlay;
11593
+ disposed = false;
11594
+ constructor(host, options) {
11595
+ this.role = options.role;
11596
+ this.animator = options.animator;
11597
+ this.animate = options.animate ?? true;
11598
+ this.pulseColor = options.pulseColor;
11599
+ this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
11600
+ ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
11601
+ ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
11602
+ ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
11603
+ maxPingsPerSender: 1
11604
+ }) : null;
10281
11605
  }
10282
- setOptions(options) {
10283
- if (options.mode !== void 0) this.mode = options.mode;
10284
- if (options.radius !== void 0) {
10285
- this.radius = options.radius;
10286
- this.cursor = makeEraserCursor(this.radius);
11606
+ /**
11607
+ * Applies a presence payload from `sender`. Returns `false` for payloads
11608
+ * that are not focus frames, or are addressed to a different role, so hosts
11609
+ * can feed every presence frame through without disturbing other handlers.
11610
+ */
11611
+ apply(from, data) {
11612
+ if (this.disposed || !isFocusPresence(data)) return false;
11613
+ if (!audienceIncludes(data.audience, this.role)) return false;
11614
+ const view = { x: data.x, y: data.y, w: data.w, h: data.h };
11615
+ if (this.animate) {
11616
+ this.animator.animateTo(view);
11617
+ } else {
11618
+ this.animator.jumpTo(view);
10287
11619
  }
11620
+ this.overlay?.apply(from, {
11621
+ kind: "ping",
11622
+ x: view.x + view.w / 2,
11623
+ y: view.y + view.h / 2,
11624
+ color: data.color ?? this.pulseColor
11625
+ });
11626
+ return true;
11627
+ }
11628
+ /** Idempotent. Does NOT dispose the animator — the host owns that. */
11629
+ dispose() {
11630
+ if (this.disposed) return;
11631
+ this.disposed = true;
11632
+ this.overlay?.dispose();
10288
11633
  }
11634
+ };
11635
+
11636
+ // src/tools/hand-tool.ts
11637
+ var HandTool = class {
11638
+ name = "hand";
11639
+ panning = false;
11640
+ lastScreen = { x: 0, y: 0 };
10289
11641
  onActivate(ctx) {
10290
- ctx.setCursor?.(this.cursor);
11642
+ ctx.setCursor?.("grab");
10291
11643
  }
10292
11644
  onDeactivate(ctx) {
10293
11645
  ctx.setCursor?.("default");
10294
11646
  }
10295
11647
  onPointerDown(state, ctx) {
10296
- this.erasing = true;
10297
- this.eraseAt(state, ctx);
11648
+ this.panning = true;
11649
+ this.lastScreen = { x: state.x, y: state.y };
11650
+ ctx.setCursor?.("grabbing");
10298
11651
  }
10299
11652
  onPointerMove(state, ctx) {
10300
- if (!this.erasing) return;
10301
- this.eraseAt(state, ctx);
10302
- }
10303
- onPointerUp(_state, _ctx) {
10304
- this.erasing = false;
10305
- }
10306
- eraseAt(state, ctx) {
10307
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
10308
- const worldRadius = this.radius / ctx.camera.zoom;
10309
- const queryBounds = {
10310
- x: world.x - worldRadius,
10311
- y: world.y - worldRadius,
10312
- w: worldRadius * 2,
10313
- h: worldRadius * 2
10314
- };
10315
- const candidates = ctx.store.queryRect(queryBounds);
10316
- let erased = false;
10317
- for (const el of candidates) {
10318
- if (el.type !== "stroke") continue;
10319
- if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
10320
- if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
10321
- if (!this.strokeIntersects(el, world, worldRadius)) continue;
10322
- if (this.mode === "stroke") {
10323
- ctx.store.remove(el.id);
10324
- erased = true;
10325
- continue;
10326
- }
10327
- const localEraser = { x: world.x - el.position.x, y: world.y - el.position.y };
10328
- const runs = erasePoints(el.points, localEraser, worldRadius);
10329
- if (runs === null) continue;
10330
- ctx.store.remove(el.id);
10331
- for (const run of runs) {
10332
- ctx.store.add(
10333
- createStroke({
10334
- points: run,
10335
- color: el.color,
10336
- width: el.width,
10337
- opacity: el.opacity,
10338
- layerId: el.layerId,
10339
- zIndex: el.zIndex,
10340
- position: el.position
10341
- })
10342
- );
10343
- }
10344
- erased = true;
10345
- }
10346
- if (erased) ctx.requestRender();
11653
+ if (!this.panning) return;
11654
+ const dx = state.x - this.lastScreen.x;
11655
+ const dy = state.y - this.lastScreen.y;
11656
+ this.lastScreen = { x: state.x, y: state.y };
11657
+ ctx.camera.pan(dx, dy);
10347
11658
  }
10348
- strokeIntersects(stroke, point, worldRadius) {
10349
- return hitTestStroke(stroke, point, worldRadius);
11659
+ onPointerUp(_state, ctx) {
11660
+ this.panning = false;
11661
+ ctx.setCursor?.("grab");
10350
11662
  }
10351
11663
  };
10352
11664
 
10353
- // src/elements/group.ts
10354
- function expandToGroups(ids, elements) {
10355
- const byId = new Map(elements.map((e) => [e.id, e]));
10356
- const groupIds = /* @__PURE__ */ new Set();
10357
- for (const id of ids) {
10358
- const g = byId.get(id)?.groupId;
10359
- if (g) groupIds.add(g);
11665
+ // src/tools/pencil-tool.ts
11666
+ var MIN_POINTS_FOR_STROKE = 2;
11667
+ var DEFAULT_SMOOTHING = 1.5;
11668
+ var DEFAULT_PRESSURE = 0.5;
11669
+ var DEFAULT_MIN_POINT_DISTANCE = 3;
11670
+ var DEFAULT_PROGRESSIVE_THRESHOLD = 200;
11671
+ var PROGRESSIVE_HOT_ZONE = 30;
11672
+ var PencilTool = class {
11673
+ name;
11674
+ drawing = false;
11675
+ points = [];
11676
+ color;
11677
+ width;
11678
+ smoothing;
11679
+ minPointDistance;
11680
+ progressiveThreshold;
11681
+ nextSimplifyAt;
11682
+ opacity;
11683
+ blendMode;
11684
+ optionListeners = /* @__PURE__ */ new Set();
11685
+ constructor(options = {}) {
11686
+ this.name = options.name ?? "pencil";
11687
+ this.color = options.color ?? "#000000";
11688
+ this.width = options.width ?? 2;
11689
+ this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
11690
+ this.minPointDistance = options.minPointDistance ?? DEFAULT_MIN_POINT_DISTANCE;
11691
+ this.progressiveThreshold = options.progressiveSimplifyThreshold ?? DEFAULT_PROGRESSIVE_THRESHOLD;
11692
+ this.nextSimplifyAt = this.progressiveThreshold;
11693
+ this.opacity = options.opacity ?? 1;
11694
+ this.blendMode = options.blendMode;
10360
11695
  }
10361
- if (groupIds.size === 0) return ids;
10362
- const idSet = new Set(ids);
10363
- const result = [...ids];
10364
- for (const el of elements) {
10365
- if (el.groupId && groupIds.has(el.groupId) && !idSet.has(el.id)) {
10366
- result.push(el.id);
10367
- idSet.add(el.id);
10368
- }
11696
+ onActivate(ctx) {
11697
+ ctx.setCursor?.("crosshair");
10369
11698
  }
10370
- return result;
10371
- }
10372
-
10373
- // src/tools/arrow-handles.ts
10374
- var BIND_THRESHOLD = 20;
10375
- var HANDLE_RADIUS = 5;
10376
- var HANDLE_HIT_PADDING = 4;
10377
- var ARROW_HANDLE_CURSORS = {
10378
- start: "crosshair",
10379
- end: "crosshair",
10380
- mid: "grab"
10381
- };
10382
- function getArrowHandleCursor(handle, active) {
10383
- if (handle === "mid" && active) return "grabbing";
10384
- return ARROW_HANDLE_CURSORS[handle];
10385
- }
10386
- function getArrowHandlePositions(arrow) {
10387
- const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
10388
- return [
10389
- ["start", arrow.from],
10390
- ["mid", mid],
10391
- ["end", arrow.to]
10392
- ];
10393
- }
10394
- function hitTestArrowHandles(world, selectedIds, ctx) {
10395
- if (selectedIds.length === 0) return null;
10396
- const zoom = ctx.camera.zoom;
10397
- const hitRadius = (HANDLE_RADIUS + HANDLE_HIT_PADDING) / zoom;
10398
- for (const id of selectedIds) {
10399
- const el = ctx.store.getById(id);
10400
- if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
10401
- continue;
10402
- const handles = getArrowHandlePositions(el);
10403
- for (const [handle, pos] of handles) {
10404
- const dx = world.x - pos.x;
10405
- const dy = world.y - pos.y;
10406
- if (dx * dx + dy * dy <= hitRadius * hitRadius) {
10407
- return { elementId: id, handle };
10408
- }
10409
- }
11699
+ onDeactivate(ctx) {
11700
+ ctx.setCursor?.("default");
10410
11701
  }
10411
- return null;
10412
- }
10413
- function applyArrowHandleDrag(handle, elementId, world, ctx) {
10414
- const el = ctx.store.getById(elementId);
10415
- if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) return;
10416
- const threshold = BIND_THRESHOLD / ctx.camera.zoom;
10417
- const layerFilter = (candidate) => candidate.layerId === el.layerId;
10418
- switch (handle) {
10419
- case "start": {
10420
- const excludeId = el.toBinding?.elementId;
10421
- const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
10422
- if (target) {
10423
- const center2 = getElementCenter(target);
10424
- ctx.store.update(elementId, {
10425
- from: center2,
10426
- position: center2,
10427
- fromBinding: { elementId: target.id }
10428
- });
10429
- } else {
10430
- ctx.store.update(elementId, {
10431
- from: { x: world.x, y: world.y },
10432
- position: { x: world.x, y: world.y },
10433
- fromBinding: void 0
10434
- });
10435
- }
10436
- break;
10437
- }
10438
- case "end": {
10439
- const excludeId = el.fromBinding?.elementId;
10440
- const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
10441
- if (target) {
10442
- const center2 = getElementCenter(target);
10443
- ctx.store.update(elementId, {
10444
- to: center2,
10445
- toBinding: { elementId: target.id }
10446
- });
10447
- } else {
10448
- ctx.store.update(elementId, {
10449
- to: { x: world.x, y: world.y },
10450
- toBinding: void 0
10451
- });
10452
- }
10453
- break;
10454
- }
10455
- case "mid": {
10456
- const bend = getBendFromPoint(el.from, el.to, world);
10457
- ctx.store.update(elementId, { bend });
10458
- break;
10459
- }
11702
+ getOptions() {
11703
+ return {
11704
+ color: this.color,
11705
+ width: this.width,
11706
+ smoothing: this.smoothing,
11707
+ minPointDistance: this.minPointDistance,
11708
+ progressiveSimplifyThreshold: this.progressiveThreshold,
11709
+ opacity: this.opacity,
11710
+ blendMode: this.blendMode
11711
+ };
11712
+ }
11713
+ onOptionsChange(listener) {
11714
+ this.optionListeners.add(listener);
11715
+ return () => this.optionListeners.delete(listener);
10460
11716
  }
10461
- ctx.requestRender();
10462
- }
10463
- function getArrowHandleDragTarget(handle, elementId, world, ctx) {
10464
- if (handle === "mid") return null;
10465
- const el = ctx.store.getById(elementId);
10466
- if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) {
10467
- return null;
11717
+ setOptions(options) {
11718
+ if (options.color !== void 0) this.color = options.color;
11719
+ if (options.width !== void 0) this.width = options.width;
11720
+ if (options.smoothing !== void 0) this.smoothing = options.smoothing;
11721
+ if (options.minPointDistance !== void 0) this.minPointDistance = options.minPointDistance;
11722
+ if (options.progressiveSimplifyThreshold !== void 0)
11723
+ this.progressiveThreshold = options.progressiveSimplifyThreshold;
11724
+ if (options.opacity !== void 0) this.opacity = options.opacity;
11725
+ if (options.blendMode !== void 0) this.blendMode = options.blendMode;
11726
+ this.notifyOptionsChange();
10468
11727
  }
10469
- const threshold = BIND_THRESHOLD / ctx.camera.zoom;
10470
- const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
10471
- const layerFilter = (candidate) => candidate.layerId === el.layerId;
10472
- const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
10473
- if (!target) return null;
10474
- return getElementBounds(target);
10475
- }
10476
- function renderArrowHandles(canvasCtx, arrow, zoom) {
10477
- const radius = HANDLE_RADIUS / zoom;
10478
- const handles = getArrowHandlePositions(arrow);
10479
- canvasCtx.setLineDash([]);
10480
- canvasCtx.lineWidth = 1.5 / zoom;
10481
- for (const [handle, pos] of handles) {
10482
- canvasCtx.fillStyle = handle === "mid" ? "#2196F3" : "#ffffff";
10483
- canvasCtx.strokeStyle = "#2196F3";
10484
- canvasCtx.beginPath();
10485
- canvasCtx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
10486
- canvasCtx.fill();
10487
- canvasCtx.stroke();
11728
+ onPointerDown(state, ctx) {
11729
+ this.drawing = true;
11730
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11731
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
11732
+ this.points = [{ x: world.x, y: world.y, pressure }];
11733
+ this.nextSimplifyAt = this.progressiveThreshold;
10488
11734
  }
10489
- }
10490
- function renderArrowHoverHandle(canvasCtx, arrow, zoom) {
10491
- const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
10492
- const radius = HANDLE_RADIUS / zoom;
10493
- canvasCtx.fillStyle = "#2196F3";
10494
- canvasCtx.strokeStyle = "#2196F3";
10495
- canvasCtx.lineWidth = 1.5 / zoom;
10496
- canvasCtx.beginPath();
10497
- canvasCtx.arc(mid.x, mid.y, radius, 0, Math.PI * 2);
10498
- canvasCtx.fill();
10499
- canvasCtx.stroke();
10500
- }
10501
-
10502
- // src/elements/snap-guides.ts
10503
- function xAnchors(b) {
10504
- return { lo: b.x, mid: b.x + b.w / 2, hi: b.x + b.w };
10505
- }
10506
- function yAnchors(b) {
10507
- return { lo: b.y, mid: b.y + b.h / 2, hi: b.y + b.h };
10508
- }
10509
- function bestAxisSnap(moving, targets, anchorsFn, threshold) {
10510
- let best = null;
10511
- for (const t of targets) {
10512
- const ta = anchorsFn(t);
10513
- const pairs = [
10514
- // colinear alignment: same-type edges/centers line up
10515
- [ta.lo - moving.lo, ta.lo],
10516
- [ta.mid - moving.mid, ta.mid],
10517
- [ta.hi - moving.hi, ta.hi],
10518
- // abutment: the moving box sits flush against the target's opposite edge
10519
- [ta.lo - moving.hi, ta.lo],
10520
- [ta.hi - moving.lo, ta.hi]
10521
- ];
10522
- for (const [delta, position] of pairs) {
10523
- const abs = Math.abs(delta);
10524
- if (abs <= threshold && (best === null || abs < Math.abs(best.delta))) {
10525
- best = { delta, position };
10526
- }
11735
+ onPointerMove(state, ctx) {
11736
+ if (!this.drawing) return;
11737
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11738
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
11739
+ const last = this.points[this.points.length - 1];
11740
+ if (last) {
11741
+ const dx = world.x - last.x;
11742
+ const dy = world.y - last.y;
11743
+ if (dx * dx + dy * dy < this.minPointDistance * this.minPointDistance) return;
11744
+ }
11745
+ this.points.push({ x: world.x, y: world.y, pressure });
11746
+ if (this.points.length > this.nextSimplifyAt) {
11747
+ const hotZone = this.points.slice(-PROGRESSIVE_HOT_ZONE);
11748
+ const coldZone = this.points.slice(0, -PROGRESSIVE_HOT_ZONE);
11749
+ const simplified = simplifyPoints(coldZone, this.smoothing * 2);
11750
+ this.points = [...simplified, ...hotZone];
11751
+ this.nextSimplifyAt = this.points.length + this.progressiveThreshold;
10527
11752
  }
11753
+ ctx.requestRender();
10528
11754
  }
10529
- return best;
10530
- }
10531
- function computeSnapGuides(moving, targets, threshold) {
10532
- const xSnap = bestAxisSnap(xAnchors(moving), targets, xAnchors, threshold);
10533
- const ySnap = bestAxisSnap(yAnchors(moving), targets, yAnchors, threshold);
10534
- const guides = [];
10535
- if (xSnap) guides.push({ axis: "x", position: xSnap.position });
10536
- if (ySnap) guides.push({ axis: "y", position: ySnap.position });
10537
- return { dx: xSnap?.delta ?? 0, dy: ySnap?.delta ?? 0, guides };
10538
- }
10539
-
10540
- // src/tools/select-overlay.ts
10541
- var HANDLE_SIZE = 8;
10542
- var HANDLE_HIT_PADDING2 = 4;
10543
- var SELECTION_PAD = 4;
10544
- var ROTATE_HANDLE_OFFSET = 24;
10545
- var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
10546
- var HANDLE_CURSORS = {
10547
- nw: "nwse-resize",
10548
- se: "nwse-resize",
10549
- ne: "nesw-resize",
10550
- sw: "nesw-resize"
10551
- };
10552
- function getOverlayLayout(el, zoom) {
10553
- const bounds = getElementBounds(el);
10554
- if (!bounds) return null;
10555
- const angle = el.rotation ?? 0;
10556
- const pad = SELECTION_PAD / zoom;
10557
- const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
10558
- const raw = [
10559
- ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
10560
- ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
10561
- ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
10562
- ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
10563
- ];
10564
- const corners = raw.map(
10565
- ([h, p]) => [h, rotatePoint(p, center2, angle)]
10566
- );
10567
- const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
10568
- const rotateHandle = rotatePoint(topMid, center2, angle);
10569
- return { center: center2, corners, rotateHandle, angle };
10570
- }
10571
- function templateAimKnob(el, zoom) {
10572
- if (el.type !== "template") return null;
10573
- if (el.templateShape !== "cone" && el.templateShape !== "line" && el.templateShape !== "rectangle")
10574
- return null;
10575
- const gap = ROTATE_HANDLE_OFFSET / zoom;
10576
- const dist = el.radius + gap;
10577
- const origin = el.position;
10578
- return {
10579
- origin,
10580
- knob: {
10581
- x: origin.x + dist * Math.cos(el.angle),
10582
- y: origin.y + dist * Math.sin(el.angle)
11755
+ onPointerUp(_state, ctx) {
11756
+ if (!this.drawing) return;
11757
+ this.drawing = false;
11758
+ if (this.points.length < MIN_POINTS_FOR_STROKE) {
11759
+ this.points = [];
11760
+ return;
10583
11761
  }
10584
- };
10585
- }
10586
- function getHandlePositions(bounds) {
10587
- return [
10588
- ["nw", { x: bounds.x, y: bounds.y }],
10589
- ["ne", { x: bounds.x + bounds.w, y: bounds.y }],
10590
- ["sw", { x: bounds.x, y: bounds.y + bounds.h }],
10591
- ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
10592
- ];
10593
- }
10594
- function topMidpoint(layout) {
10595
- const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
10596
- const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
10597
- return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
10598
- }
10599
- function drawLockBadge(ctx, at, zoom) {
10600
- const r = 9 / zoom;
10601
- ctx.save();
10602
- ctx.setLineDash([]);
10603
- ctx.beginPath();
10604
- ctx.arc(at.x, at.y, r, 0, Math.PI * 2);
10605
- ctx.fillStyle = "#ffffff";
10606
- ctx.fill();
10607
- ctx.strokeStyle = "#2196F3";
10608
- ctx.lineWidth = 1.5 / zoom;
10609
- ctx.stroke();
10610
- const bw = 8 / zoom;
10611
- const bh = 6 / zoom;
10612
- ctx.fillStyle = "#2196F3";
10613
- ctx.fillRect(at.x - bw / 2, at.y - bh / 2 + 1 / zoom, bw, bh);
10614
- ctx.beginPath();
10615
- ctx.arc(at.x, at.y - bh / 2 + 1 / zoom, 2.5 / zoom, Math.PI, 0);
10616
- ctx.lineWidth = 1.4 / zoom;
10617
- ctx.stroke();
10618
- ctx.restore();
10619
- }
10620
- function renderMarquee(ctx, rect) {
10621
- ctx.save();
10622
- ctx.strokeStyle = "#2196F3";
10623
- ctx.fillStyle = "rgba(33, 150, 243, 0.08)";
10624
- ctx.lineWidth = 1;
10625
- ctx.setLineDash([4, 4]);
10626
- ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
10627
- ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
10628
- ctx.restore();
10629
- }
10630
- function renderBindingHighlights(ctx, arrow, zoom, store) {
10631
- if (!arrow.fromBinding && !arrow.toBinding) return;
10632
- const pad = SELECTION_PAD / zoom;
10633
- ctx.save();
10634
- ctx.strokeStyle = "#2196F3";
10635
- ctx.lineWidth = 2 / zoom;
10636
- ctx.setLineDash([]);
10637
- const drawn = /* @__PURE__ */ new Set();
10638
- for (const binding of [arrow.fromBinding, arrow.toBinding]) {
10639
- if (!binding || drawn.has(binding.elementId)) continue;
10640
- drawn.add(binding.elementId);
10641
- const target = store.getById(binding.elementId);
10642
- if (!target) continue;
10643
- const bounds = getElementBounds(target);
10644
- if (!bounds) continue;
10645
- ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
11762
+ const simplified = simplifyPoints(this.points, this.smoothing);
11763
+ const stroke = createStroke({
11764
+ points: simplified,
11765
+ color: this.color,
11766
+ width: this.width,
11767
+ layerId: ctx.activeLayerId ?? "",
11768
+ opacity: this.opacity,
11769
+ blendMode: this.blendMode
11770
+ });
11771
+ ctx.store.add(stroke);
11772
+ computeStrokeSegments(stroke);
11773
+ this.points = [];
11774
+ ctx.requestRender();
11775
+ }
11776
+ notifyOptionsChange() {
11777
+ for (const listener of this.optionListeners) listener();
11778
+ }
11779
+ renderOverlay(ctx) {
11780
+ if (!this.drawing || this.points.length < 2) return;
11781
+ ctx.save();
11782
+ ctx.strokeStyle = this.color;
11783
+ ctx.lineCap = "round";
11784
+ ctx.lineJoin = "round";
11785
+ ctx.globalAlpha = this.blendMode ? this.opacity : 0.8;
11786
+ if (this.blendMode) ctx.globalCompositeOperation = this.blendMode;
11787
+ const segments = smoothToSegments(this.points);
11788
+ for (const seg of segments) {
11789
+ const w = (pressureToWidth(seg.start.pressure, this.width) + pressureToWidth(seg.end.pressure, this.width)) / 2;
11790
+ ctx.lineWidth = w;
11791
+ ctx.beginPath();
11792
+ ctx.moveTo(seg.start.x, seg.start.y);
11793
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
11794
+ ctx.stroke();
11795
+ }
11796
+ ctx.restore();
10646
11797
  }
10647
- ctx.restore();
11798
+ };
11799
+
11800
+ // src/elements/stroke-erase.ts
11801
+ function lerp2(a, b, t) {
11802
+ return {
11803
+ x: a.x + (b.x - a.x) * t,
11804
+ y: a.y + (b.y - a.y) * t,
11805
+ pressure: a.pressure + (b.pressure - a.pressure) * t
11806
+ };
10648
11807
  }
10649
- function renderSelectionBoxes(ctx, p) {
10650
- if (p.selectedIds.length === 0) return;
10651
- const zoom = p.zoom;
10652
- const handleWorldSize = HANDLE_SIZE / zoom;
10653
- ctx.save();
10654
- ctx.strokeStyle = "#2196F3";
10655
- ctx.lineWidth = 1.5 / zoom;
10656
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10657
- for (const id of p.selectedIds) {
10658
- const el = p.store.getById(id);
10659
- if (!el) continue;
10660
- const locked = el.locked || (p.isLayerLocked?.(el.layerId) ?? false);
10661
- if (el.type === "arrow") {
10662
- if (!locked) renderArrowHandles(ctx, el, zoom);
10663
- renderBindingHighlights(ctx, el, zoom, p.store);
10664
- continue;
10665
- }
10666
- if (el.type === "shape" && el.shape === "line") {
10667
- if (!locked) {
10668
- ctx.setLineDash([]);
10669
- ctx.fillStyle = "#ffffff";
10670
- const r = handleWorldSize / 2;
10671
- for (const pt of lineEndpoints(el)) {
10672
- ctx.beginPath();
10673
- ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
10674
- ctx.fill();
10675
- ctx.stroke();
10676
- }
10677
- ctx.setLineDash([4 / zoom, 4 / zoom]);
11808
+ function erasePoints(points, eraser, radius) {
11809
+ const r2 = radius * radius;
11810
+ if (points.length < 2) {
11811
+ const p = points[0];
11812
+ if (p && (p.x - eraser.x) ** 2 + (p.y - eraser.y) ** 2 <= r2) return [];
11813
+ return null;
11814
+ }
11815
+ const runs = [];
11816
+ let current = [];
11817
+ let erased = false;
11818
+ const flush = () => {
11819
+ if (current.length >= 2) runs.push(current);
11820
+ current = [];
11821
+ };
11822
+ for (let i = 0; i < points.length - 1; i++) {
11823
+ const a = points[i];
11824
+ const b = points[i + 1];
11825
+ if (!a || !b) continue;
11826
+ const dx = b.x - a.x;
11827
+ const dy = b.y - a.y;
11828
+ const fx = a.x - eraser.x;
11829
+ const fy = a.y - eraser.y;
11830
+ const A = dx * dx + dy * dy;
11831
+ const B = 2 * (fx * dx + fy * dy);
11832
+ const C = fx * fx + fy * fy - r2;
11833
+ let tLo = 1;
11834
+ let tHi = 0;
11835
+ if (A === 0) {
11836
+ if (C <= 0) {
11837
+ tLo = 0;
11838
+ tHi = 1;
10678
11839
  }
10679
- continue;
10680
- }
10681
- const bounds = getElementBounds(el);
10682
- if (!bounds) continue;
10683
- const layout = getOverlayLayout(el, zoom);
10684
- if (!layout) continue;
10685
- const pad = SELECTION_PAD / zoom;
10686
- if (layout.angle === 0) {
10687
- ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
10688
11840
  } else {
10689
- const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((pp) => !!pp);
10690
- const [p0, ...others] = ordered;
10691
- if (p0) {
10692
- ctx.beginPath();
10693
- ctx.moveTo(p0.x, p0.y);
10694
- for (const pp of others) ctx.lineTo(pp.x, pp.y);
10695
- ctx.closePath();
10696
- ctx.stroke();
10697
- }
10698
- }
10699
- if (!el.locked) {
10700
- if ("size" in el) {
10701
- ctx.setLineDash([]);
10702
- ctx.fillStyle = "#ffffff";
10703
- const corners = layout.angle === 0 ? getHandlePositions(bounds) : layout.corners;
10704
- for (const [, pos] of corners) {
10705
- ctx.fillRect(
10706
- pos.x - handleWorldSize / 2,
10707
- pos.y - handleWorldSize / 2,
10708
- handleWorldSize,
10709
- handleWorldSize
10710
- );
10711
- ctx.strokeRect(
10712
- pos.x - handleWorldSize / 2,
10713
- pos.y - handleWorldSize / 2,
10714
- handleWorldSize,
10715
- handleWorldSize
10716
- );
10717
- }
10718
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10719
- } else if (el.type === "template") {
10720
- ctx.setLineDash([]);
10721
- ctx.fillStyle = "#ffffff";
10722
- if (el.templateShape === "rectangle") {
10723
- if (p.selectedIds.length === 1) {
10724
- const cos = Math.cos(el.angle);
10725
- const sin = Math.sin(el.angle);
10726
- const halfW = (el.width ?? 0) / 2;
10727
- const pts = [
10728
- [el.position.x + el.radius * cos, el.position.y + el.radius * sin],
10729
- [
10730
- el.position.x + el.radius / 2 * cos + halfW * -sin,
10731
- el.position.y + el.radius / 2 * sin + halfW * cos
10732
- ]
10733
- ];
10734
- for (const [hx, hy] of pts) {
10735
- ctx.fillRect(
10736
- hx - handleWorldSize / 2,
10737
- hy - handleWorldSize / 2,
10738
- handleWorldSize,
10739
- handleWorldSize
10740
- );
10741
- ctx.strokeRect(
10742
- hx - handleWorldSize / 2,
10743
- hy - handleWorldSize / 2,
10744
- handleWorldSize,
10745
- handleWorldSize
10746
- );
10747
- }
10748
- }
10749
- } else {
10750
- const hx = bounds.x + bounds.w;
10751
- const hy = bounds.y + bounds.h;
10752
- ctx.fillRect(
10753
- hx - handleWorldSize / 2,
10754
- hy - handleWorldSize / 2,
10755
- handleWorldSize,
10756
- handleWorldSize
10757
- );
10758
- ctx.strokeRect(
10759
- hx - handleWorldSize / 2,
10760
- hy - handleWorldSize / 2,
10761
- handleWorldSize,
10762
- handleWorldSize
10763
- );
10764
- }
10765
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10766
- if (p.selectedIds.length === 1 && (el.templateShape === "cone" || el.templateShape === "line" || el.templateShape === "rectangle")) {
10767
- const aim = templateAimKnob(el, zoom);
10768
- if (aim) {
10769
- ctx.beginPath();
10770
- ctx.moveTo(aim.origin.x, aim.origin.y);
10771
- ctx.lineTo(aim.knob.x, aim.knob.y);
10772
- ctx.stroke();
10773
- ctx.setLineDash([]);
10774
- ctx.fillStyle = "#ffffff";
10775
- ctx.beginPath();
10776
- ctx.arc(aim.knob.x, aim.knob.y, handleWorldSize / 2, 0, Math.PI * 2);
10777
- ctx.fill();
10778
- ctx.stroke();
10779
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10780
- }
11841
+ const disc = B * B - 4 * A * C;
11842
+ if (disc >= 0) {
11843
+ const sq = Math.sqrt(disc);
11844
+ const lo = Math.max(0, (-B - sq) / (2 * A));
11845
+ const hi = Math.min(1, (-B + sq) / (2 * A));
11846
+ if (lo < hi) {
11847
+ tLo = lo;
11848
+ tHi = hi;
10781
11849
  }
10782
11850
  }
10783
- if (p.selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
10784
- const stemStart = topMidpoint(layout);
10785
- const stemEnd = layout.rotateHandle;
10786
- ctx.beginPath();
10787
- ctx.moveTo(stemStart.x, stemStart.y);
10788
- ctx.lineTo(stemEnd.x, stemEnd.y);
10789
- ctx.stroke();
10790
- ctx.setLineDash([]);
10791
- ctx.fillStyle = "#ffffff";
10792
- ctx.beginPath();
10793
- ctx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
10794
- ctx.fill();
10795
- ctx.stroke();
10796
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10797
- }
10798
11851
  }
10799
- if (el.locked) {
10800
- const ne = layout.corners.find(([h]) => h === "ne")?.[1];
10801
- if (ne) drawLockBadge(ctx, ne, zoom);
11852
+ if (tLo > tHi) {
11853
+ if (current.length === 0) current.push(a);
11854
+ current.push(b);
11855
+ continue;
10802
11856
  }
10803
- }
10804
- ctx.restore();
10805
- }
10806
- function renderGuideLines(ctx, p) {
10807
- const zoom = p.zoom;
10808
- const rect = p.rect;
10809
- ctx.save();
10810
- ctx.strokeStyle = "#FF4081";
10811
- ctx.lineWidth = 1 / zoom;
10812
- ctx.setLineDash([]);
10813
- for (const g of p.guides) {
10814
- ctx.beginPath();
10815
- if (g.axis === "x") {
10816
- const y0 = rect ? rect.y : p.currentWorld.y - 1e5;
10817
- const y1 = rect ? rect.y + rect.h : p.currentWorld.y + 1e5;
10818
- ctx.moveTo(g.position, y0);
10819
- ctx.lineTo(g.position, y1);
11857
+ erased = true;
11858
+ if (tLo > 0) {
11859
+ if (current.length === 0) current.push(a);
11860
+ current.push(lerp2(a, b, tLo));
11861
+ flush();
10820
11862
  } else {
10821
- const x0 = rect ? rect.x : p.currentWorld.x - 1e5;
10822
- const x1 = rect ? rect.x + rect.w : p.currentWorld.x + 1e5;
10823
- ctx.moveTo(x0, g.position);
10824
- ctx.lineTo(x1, g.position);
11863
+ flush();
11864
+ }
11865
+ if (tHi < 1) {
11866
+ current = [lerp2(a, b, tHi), b];
10825
11867
  }
10826
- ctx.stroke();
10827
11868
  }
10828
- ctx.restore();
11869
+ flush();
11870
+ return erased ? runs : null;
10829
11871
  }
10830
11872
 
10831
- // src/tools/select-hit.ts
10832
- function hitTest(world, ctx) {
10833
- const r = 10;
10834
- const candidates = ctx.store.queryRect({ x: world.x - r, y: world.y - r, w: r * 2, h: r * 2 }).reverse();
10835
- for (const el of candidates) {
10836
- if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
10837
- if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
10838
- if (el.type === "grid") continue;
10839
- if (isInsideBounds(world, el)) return el;
11873
+ // src/tools/eraser-tool.ts
11874
+ var DEFAULT_RADIUS3 = 20;
11875
+ function makeEraserCursor(radius) {
11876
+ const size = radius * 2;
11877
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}'><circle cx='${radius}' cy='${radius}' r='${radius - 1}' fill='none' stroke='%23666' stroke-width='1.5'/></svg>`;
11878
+ return `url("data:image/svg+xml,${svg}") ${radius} ${radius}, crosshair`;
11879
+ }
11880
+ var EraserTool = class {
11881
+ name = "eraser";
11882
+ erasing = false;
11883
+ radius;
11884
+ cursor;
11885
+ mode;
11886
+ constructor(options = {}) {
11887
+ this.radius = options.radius ?? DEFAULT_RADIUS3;
11888
+ this.cursor = makeEraserCursor(this.radius);
11889
+ this.mode = options.mode ?? "partial";
10840
11890
  }
10841
- return null;
10842
- }
10843
- function isInsideBounds(point, el) {
10844
- if (el.type === "grid") return false;
10845
- const angle = el.rotation ?? 0;
10846
- if (angle !== 0) {
10847
- const b = getElementBounds(el);
10848
- if (b) {
10849
- point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
11891
+ getOptions() {
11892
+ return { radius: this.radius, mode: this.mode };
11893
+ }
11894
+ setOptions(options) {
11895
+ if (options.mode !== void 0) this.mode = options.mode;
11896
+ if (options.radius !== void 0) {
11897
+ this.radius = options.radius;
11898
+ this.cursor = makeEraserCursor(this.radius);
10850
11899
  }
10851
11900
  }
10852
- if (el.type === "shape" && el.shape === "line") {
10853
- const [a, b] = lineEndpoints(el);
10854
- const threshold = Math.max(el.strokeWidth / 2, 6);
10855
- return distSqToSegment(point, a, b) <= threshold * threshold;
11901
+ onActivate(ctx) {
11902
+ ctx.setCursor?.(this.cursor);
10856
11903
  }
10857
- if ("size" in el) {
10858
- const s = el.size;
10859
- return point.x >= el.position.x && point.x <= el.position.x + s.w && point.y >= el.position.y && point.y <= el.position.y + s.h;
11904
+ onDeactivate(ctx) {
11905
+ ctx.setCursor?.("default");
10860
11906
  }
10861
- if (el.type === "stroke") {
10862
- return hitTestStroke(el, point, 10);
11907
+ onPointerDown(state, ctx) {
11908
+ this.erasing = true;
11909
+ this.eraseAt(state, ctx);
10863
11910
  }
10864
- if (el.type === "arrow") {
10865
- return isNearBezier(point, el.from, el.to, el.bend, 10);
11911
+ onPointerMove(state, ctx) {
11912
+ if (!this.erasing) return;
11913
+ this.eraseAt(state, ctx);
10866
11914
  }
10867
- if (el.type === "template") {
10868
- const bounds = getElementBounds(el);
10869
- if (!bounds) return false;
10870
- return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
11915
+ onPointerUp(_state, _ctx) {
11916
+ this.erasing = false;
10871
11917
  }
10872
- return false;
10873
- }
10874
- function hitTestResizeHandle(world, ctx, selectedIds) {
10875
- if (selectedIds.length === 0) return null;
10876
- const zoom = ctx.camera.zoom;
10877
- const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
10878
- for (const id of selectedIds) {
10879
- const el = ctx.store.getById(id);
10880
- if (!el || !("size" in el)) continue;
10881
- if (el.locked) continue;
10882
- if (el.type === "shape" && el.shape === "line") continue;
10883
- const layout = getOverlayLayout(el, zoom);
10884
- if (!layout) continue;
10885
- for (const [handle, pos] of layout.corners) {
10886
- if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
10887
- return { elementId: id, handle };
11918
+ eraseAt(state, ctx) {
11919
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11920
+ const worldRadius = this.radius / ctx.camera.zoom;
11921
+ const queryBounds = {
11922
+ x: world.x - worldRadius,
11923
+ y: world.y - worldRadius,
11924
+ w: worldRadius * 2,
11925
+ h: worldRadius * 2
11926
+ };
11927
+ const candidates = ctx.store.queryRect(queryBounds);
11928
+ let erased = false;
11929
+ for (const el of candidates) {
11930
+ if (el.type !== "stroke") continue;
11931
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
11932
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
11933
+ if (!this.strokeIntersects(el, world, worldRadius)) continue;
11934
+ if (this.mode === "stroke") {
11935
+ ctx.store.remove(el.id);
11936
+ erased = true;
11937
+ continue;
11938
+ }
11939
+ const localEraser = { x: world.x - el.position.x, y: world.y - el.position.y };
11940
+ const runs = erasePoints(el.points, localEraser, worldRadius);
11941
+ if (runs === null) continue;
11942
+ ctx.store.remove(el.id);
11943
+ for (const run of runs) {
11944
+ ctx.store.add(
11945
+ createStroke({
11946
+ points: run,
11947
+ color: el.color,
11948
+ width: el.width,
11949
+ opacity: el.opacity,
11950
+ layerId: el.layerId,
11951
+ zIndex: el.zIndex,
11952
+ position: el.position
11953
+ })
11954
+ );
10888
11955
  }
11956
+ erased = true;
10889
11957
  }
11958
+ if (erased) ctx.requestRender();
10890
11959
  }
10891
- return null;
10892
- }
10893
- function hitTestRotateHandle(world, ctx, selectedIds) {
10894
- if (selectedIds.length !== 1) return null;
10895
- const id = selectedIds[0];
10896
- if (!id) return null;
10897
- const el = ctx.store.getById(id);
10898
- if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
10899
- const layout = getOverlayLayout(el, ctx.camera.zoom);
10900
- if (!layout) return null;
10901
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
10902
- const dx = world.x - layout.rotateHandle.x;
10903
- const dy = world.y - layout.rotateHandle.y;
10904
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
10905
- }
10906
- function hitTestLineHandles(world, ctx, selectedIds) {
10907
- if (selectedIds.length === 0) return null;
10908
- const zoom = ctx.camera.zoom;
10909
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
10910
- const r2 = r * r;
10911
- for (const id of selectedIds) {
10912
- const el = ctx.store.getById(id);
10913
- if (!el || el.type !== "shape" || el.shape !== "line" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
10914
- continue;
10915
- const [a, b] = lineEndpoints(el);
10916
- if ((world.x - a.x) ** 2 + (world.y - a.y) ** 2 <= r2) return { elementId: id, fixed: b };
10917
- if ((world.x - b.x) ** 2 + (world.y - b.y) ** 2 <= r2) return { elementId: id, fixed: a };
11960
+ strokeIntersects(stroke, point, worldRadius) {
11961
+ return hitTestStroke(stroke, point, worldRadius);
10918
11962
  }
10919
- return null;
10920
- }
10921
- function hitTestTemplateResizeHandle(world, ctx, selectedIds) {
10922
- if (selectedIds.length === 0) return null;
10923
- const zoom = ctx.camera.zoom;
10924
- const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
10925
- for (const id of selectedIds) {
10926
- const el = ctx.store.getById(id);
10927
- if (!el || el.type !== "template") continue;
10928
- if (el.templateShape === "rectangle") continue;
10929
- const bounds = getElementBounds(el);
10930
- if (!bounds) continue;
10931
- const hx = bounds.x + bounds.w;
10932
- const hy = bounds.y + bounds.h;
10933
- if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
10934
- return id;
11963
+ };
11964
+
11965
+ // src/elements/group.ts
11966
+ function expandToGroups(ids, elements) {
11967
+ const byId = new Map(elements.map((e) => [e.id, e]));
11968
+ const groupIds = /* @__PURE__ */ new Set();
11969
+ for (const id of ids) {
11970
+ const g = byId.get(id)?.groupId;
11971
+ if (g) groupIds.add(g);
11972
+ }
11973
+ if (groupIds.size === 0) return ids;
11974
+ const idSet = new Set(ids);
11975
+ const result = [...ids];
11976
+ for (const el of elements) {
11977
+ if (el.groupId && groupIds.has(el.groupId) && !idSet.has(el.id)) {
11978
+ result.push(el.id);
11979
+ idSet.add(el.id);
10935
11980
  }
10936
11981
  }
10937
- return null;
10938
- }
10939
- function hitTestTemplateAimHandle(world, ctx, selectedIds) {
10940
- if (selectedIds.length !== 1) return null;
10941
- const id = selectedIds[0];
10942
- if (!id) return null;
10943
- const el = ctx.store.getById(id);
10944
- if (!el || el.locked) return null;
10945
- const knob = templateAimKnob(el, ctx.camera.zoom);
10946
- if (!knob) return null;
10947
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
10948
- const dx = world.x - knob.knob.x;
10949
- const dy = world.y - knob.knob.y;
10950
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
11982
+ return result;
10951
11983
  }
10952
- function hitTestRectangleLengthHandle(world, ctx, selectedIds) {
10953
- if (selectedIds.length !== 1) return null;
10954
- const id = selectedIds[0];
10955
- if (!id) return null;
10956
- const el = ctx.store.getById(id);
10957
- if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
10958
- const zoom = ctx.camera.zoom;
10959
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
10960
- const hx = el.position.x + el.radius * Math.cos(el.angle);
10961
- const hy = el.position.y + el.radius * Math.sin(el.angle);
10962
- const dx = world.x - hx;
10963
- const dy = world.y - hy;
10964
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
11984
+
11985
+ // src/elements/snap-guides.ts
11986
+ function xAnchors(b) {
11987
+ return { lo: b.x, mid: b.x + b.w / 2, hi: b.x + b.w };
10965
11988
  }
10966
- function hitTestRectangleWidthHandle(world, ctx, selectedIds) {
10967
- if (selectedIds.length !== 1) return null;
10968
- const id = selectedIds[0];
10969
- if (!id) return null;
10970
- const el = ctx.store.getById(id);
10971
- if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
10972
- const zoom = ctx.camera.zoom;
10973
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
10974
- const cos = Math.cos(el.angle);
10975
- const sin = Math.sin(el.angle);
10976
- const halfW = (el.width ?? 0) / 2;
10977
- const hx = el.position.x + el.radius / 2 * cos + halfW * -sin;
10978
- const hy = el.position.y + el.radius / 2 * sin + halfW * cos;
10979
- const dx = world.x - hx;
10980
- const dy = world.y - hy;
10981
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
11989
+ function yAnchors(b) {
11990
+ return { lo: b.y, mid: b.y + b.h / 2, hi: b.y + b.h };
10982
11991
  }
10983
- function findElementsInRect(marquee, ctx) {
10984
- const candidates = ctx.store.queryRect(marquee);
10985
- const ids = [];
10986
- for (const el of candidates) {
10987
- if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
10988
- if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
10989
- if (el.type === "grid") continue;
10990
- const bounds = getElementBounds(el);
10991
- if (bounds && rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
10992
- ids.push(el.id);
11992
+ function bestAxisSnap(moving, targets, anchorsFn, threshold) {
11993
+ let best = null;
11994
+ for (const t of targets) {
11995
+ const ta = anchorsFn(t);
11996
+ const pairs = [
11997
+ // colinear alignment: same-type edges/centers line up
11998
+ [ta.lo - moving.lo, ta.lo],
11999
+ [ta.mid - moving.mid, ta.mid],
12000
+ [ta.hi - moving.hi, ta.hi],
12001
+ // abutment: the moving box sits flush against the target's opposite edge
12002
+ [ta.lo - moving.hi, ta.lo],
12003
+ [ta.hi - moving.lo, ta.hi]
12004
+ ];
12005
+ for (const [delta, position] of pairs) {
12006
+ const abs = Math.abs(delta);
12007
+ if (abs <= threshold && (best === null || abs < Math.abs(best.delta))) {
12008
+ best = { delta, position };
12009
+ }
10993
12010
  }
10994
12011
  }
10995
- return ids;
12012
+ return best;
10996
12013
  }
10997
- function rectsOverlap(a, b) {
10998
- return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
12014
+ function computeSnapGuides(moving, targets, threshold) {
12015
+ const xSnap = bestAxisSnap(xAnchors(moving), targets, xAnchors, threshold);
12016
+ const ySnap = bestAxisSnap(yAnchors(moving), targets, yAnchors, threshold);
12017
+ const guides = [];
12018
+ if (xSnap) guides.push({ axis: "x", position: xSnap.position });
12019
+ if (ySnap) guides.push({ axis: "y", position: ySnap.position });
12020
+ return { dx: xSnap?.delta ?? 0, dy: ySnap?.delta ?? 0, guides };
10999
12021
  }
11000
12022
 
11001
12023
  // src/tools/select-resize.ts
@@ -12826,7 +13848,7 @@ var PingTool = class {
12826
13848
  };
12827
13849
 
12828
13850
  // src/index.ts
12829
- var VERSION = "0.61.0";
13851
+ var VERSION = "0.62.0";
12830
13852
  export {
12831
13853
  ArrowTool,
12832
13854
  AutoSave,
@@ -12838,6 +13860,8 @@ export {
12838
13860
  FOCUS_PRESENCE_KIND,
12839
13861
  HandTool,
12840
13862
  HistoryStack,
13863
+ HtmlPainterMissingError,
13864
+ HtmlPainterRegistry,
12841
13865
  ImageTool,
12842
13866
  IndexedDBAdapter,
12843
13867
  LASER_TRAIL_PRESENCE_KIND,
@@ -12901,6 +13925,7 @@ export {
12901
13925
  isMeasurePresence,
12902
13926
  isNearBezier,
12903
13927
  isPingPresence,
13928
+ resolveHtmlRouting,
12904
13929
  setFontSize,
12905
13930
  smartSnap,
12906
13931
  snapPoint,