@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.cjs CHANGED
@@ -30,6 +30,8 @@ __export(index_exports, {
30
30
  FOCUS_PRESENCE_KIND: () => FOCUS_PRESENCE_KIND,
31
31
  HandTool: () => HandTool,
32
32
  HistoryStack: () => HistoryStack,
33
+ HtmlPainterMissingError: () => HtmlPainterMissingError,
34
+ HtmlPainterRegistry: () => HtmlPainterRegistry,
33
35
  ImageTool: () => ImageTool,
34
36
  IndexedDBAdapter: () => IndexedDBAdapter,
35
37
  LASER_TRAIL_PRESENCE_KIND: () => LASER_TRAIL_PRESENCE_KIND,
@@ -93,6 +95,7 @@ __export(index_exports, {
93
95
  isMeasurePresence: () => isMeasurePresence,
94
96
  isNearBezier: () => isNearBezier,
95
97
  isPingPresence: () => isPingPresence,
98
+ resolveHtmlRouting: () => resolveHtmlRouting,
96
99
  setFontSize: () => setFontSize,
97
100
  smartSnap: () => smartSnap,
98
101
  snapPoint: () => snapPoint,
@@ -1885,6 +1888,10 @@ var PanInertia = class {
1885
1888
  cancel() {
1886
1889
  this.reset();
1887
1890
  }
1891
+ /** Whether a coast frame is scheduled, i.e. the camera is still gliding. */
1892
+ isCoasting() {
1893
+ return this.rafId !== null;
1894
+ }
1888
1895
  step = () => {
1889
1896
  if (this.rafId === null) return;
1890
1897
  this.deps.pan(this.vx, this.vy);
@@ -1998,6 +2005,7 @@ var InputHandler = class {
1998
2005
  openContextMenu;
1999
2006
  panInertia;
2000
2007
  panInertiaEnabled;
2008
+ coastStoppedByPointer = false;
2001
2009
  setToolManager(toolManager, toolContext) {
2002
2010
  this.toolManager = toolManager;
2003
2011
  this.toolContext = toolContext;
@@ -2008,8 +2016,17 @@ var InputHandler = class {
2008
2016
  get shortcuts() {
2009
2017
  return this.keyboard.shortcuts;
2010
2018
  }
2019
+ /**
2020
+ * True while the camera glides under pan inertia, and while the pointer
2021
+ * gesture that just stopped such a glide is still down. Passive observers use
2022
+ * it to ignore a gesture that was only meant to halt the coast.
2023
+ */
2024
+ isCameraCoasting() {
2025
+ return this.panInertia.isCoasting() || this.coastStoppedByPointer;
2026
+ }
2011
2027
  destroy() {
2012
2028
  this.panInertia.cancel();
2029
+ this.coastStoppedByPointer = false;
2013
2030
  this.actions.dispose();
2014
2031
  this.abortController.abort();
2015
2032
  this.inputFilter.reset();
@@ -2030,6 +2047,8 @@ var InputHandler = class {
2030
2047
  this.element.addEventListener("pointerleave", this.onPointerLeave, opts);
2031
2048
  this.element.addEventListener("pointercancel", this.onPointerUp, opts);
2032
2049
  this.element.addEventListener("contextmenu", this.onContextMenu, opts);
2050
+ window.addEventListener("blur", this.onCoastInterrupt, opts);
2051
+ window.addEventListener("visibilitychange", this.onCoastInterrupt, opts);
2033
2052
  }
2034
2053
  onWheel = (e) => {
2035
2054
  e.preventDefault();
@@ -2043,6 +2062,7 @@ var InputHandler = class {
2043
2062
  });
2044
2063
  };
2045
2064
  onPointerDown = (e) => {
2065
+ if (this.panInertia.isCoasting()) this.coastStoppedByPointer = true;
2046
2066
  this.panInertia.cancel();
2047
2067
  this.focusSelf();
2048
2068
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@ -2123,6 +2143,7 @@ var InputHandler = class {
2123
2143
  this.isPanning = false;
2124
2144
  this.panInertia.release();
2125
2145
  }
2146
+ if (this.activePointers.size === 0) this.coastStoppedByPointer = false;
2126
2147
  const upResult = this.inputFilter.filterUp(e);
2127
2148
  if (this.isToolActive) {
2128
2149
  this.dispatchToolUp(e);
@@ -2202,6 +2223,12 @@ var InputHandler = class {
2202
2223
  this.lastPointerEvent = null;
2203
2224
  this.onPointerUp(e);
2204
2225
  };
2226
+ // Recovers `coastStoppedByPointer` when the gesture that set it never delivers
2227
+ // a matching pointerup/cancel/leave. Deliberately narrow: it clears only this
2228
+ // flag, not `activePointers`/`isPanning`, which is a separate pre-existing gap.
2229
+ onCoastInterrupt = () => {
2230
+ this.coastStoppedByPointer = false;
2231
+ };
2205
2232
  toPointerState(e) {
2206
2233
  const rect = this.element.getBoundingClientRect();
2207
2234
  return {
@@ -4015,6 +4042,157 @@ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
4015
4042
  }
4016
4043
  }
4017
4044
 
4045
+ // src/canvas/html-painter-registry.ts
4046
+ var HtmlPainterMissingError = class extends Error {
4047
+ elementId;
4048
+ htmlType;
4049
+ constructor(elementId, htmlType) {
4050
+ super(`[fieldnotes] no painter registered for canvas-backed htmlType "${htmlType ?? ""}"`);
4051
+ this.name = "HtmlPainterMissingError";
4052
+ this.elementId = elementId;
4053
+ this.htmlType = htmlType;
4054
+ }
4055
+ };
4056
+ var HtmlPainterRegistry = class {
4057
+ painters = /* @__PURE__ */ new Map();
4058
+ declared = /* @__PURE__ */ new Map();
4059
+ listeners = /* @__PURE__ */ new Set();
4060
+ _version = 0;
4061
+ canvasTypesCache = null;
4062
+ get version() {
4063
+ return this._version;
4064
+ }
4065
+ /**
4066
+ * Memoized: this is the hottest read in the feature — `isDomElement` consults it several
4067
+ * times per element per frame, and every html-element store update re-reconciles every
4068
+ * html element. The cache is dropped in `bump()`, which is called on exactly the
4069
+ * transitions that can change the membership of this set (first `expect` of a type, last
4070
+ * release of a type, any `register`, and any unregister that empties a type's stack).
4071
+ *
4072
+ * The returned `Set` is the LIVE memoized instance, not a copy: callers must not mutate
4073
+ * it, and a caller that holds it across a `bump()` holds a stale snapshot. Nothing in the
4074
+ * codebase does either — `withHtmlDefaults` passes it straight through to the exporters
4075
+ * unless the caller supplied `expectedCanvasTypes`, in which case it builds a fresh union
4076
+ * — and holding it across a change was already a stale snapshot before memoization.
4077
+ */
4078
+ get canvasTypes() {
4079
+ const cached = this.canvasTypesCache;
4080
+ if (cached) return cached;
4081
+ const types = new Set(this.declared.keys());
4082
+ for (const [type, stack] of this.painters) {
4083
+ if (stack.length > 0) types.add(type);
4084
+ }
4085
+ this.canvasTypesCache = types;
4086
+ return types;
4087
+ }
4088
+ expect(htmlTypes) {
4089
+ const claimed = [...htmlTypes];
4090
+ let changed = false;
4091
+ for (const type of claimed) {
4092
+ const count = this.declared.get(type) ?? 0;
4093
+ if (count === 0) changed = true;
4094
+ this.declared.set(type, count + 1);
4095
+ }
4096
+ if (changed) this.bump();
4097
+ let released = false;
4098
+ return () => {
4099
+ if (released) return;
4100
+ released = true;
4101
+ let releaseChanged = false;
4102
+ for (const type of claimed) {
4103
+ const count = this.declared.get(type) ?? 0;
4104
+ if (count <= 1) {
4105
+ this.declared.delete(type);
4106
+ releaseChanged = true;
4107
+ } else {
4108
+ this.declared.set(type, count - 1);
4109
+ }
4110
+ }
4111
+ if (releaseChanged) this.bump();
4112
+ };
4113
+ }
4114
+ register(htmlType, painter) {
4115
+ const entry = { token: /* @__PURE__ */ Symbol("html-painter"), painter };
4116
+ const stack = this.painters.get(htmlType) ?? [];
4117
+ stack.push(entry);
4118
+ this.painters.set(htmlType, stack);
4119
+ this.bump();
4120
+ let released = false;
4121
+ return () => {
4122
+ if (released) return;
4123
+ released = true;
4124
+ const current = this.painters.get(htmlType);
4125
+ if (!current) return;
4126
+ const index = current.findIndex((candidate) => candidate.token === entry.token);
4127
+ if (index === -1) return;
4128
+ const wasActive = index === current.length - 1;
4129
+ current.splice(index, 1);
4130
+ if (current.length === 0) this.painters.delete(htmlType);
4131
+ if (wasActive) this.bump();
4132
+ };
4133
+ }
4134
+ getActivePainter(htmlType) {
4135
+ const stack = this.painters.get(htmlType);
4136
+ return stack && stack.length > 0 ? stack[stack.length - 1]?.painter : void 0;
4137
+ }
4138
+ onChange(listener) {
4139
+ this.listeners.add(listener);
4140
+ return () => this.listeners.delete(listener);
4141
+ }
4142
+ bump() {
4143
+ this.canvasTypesCache = null;
4144
+ this._version += 1;
4145
+ for (const listener of [...this.listeners]) {
4146
+ try {
4147
+ listener();
4148
+ } catch {
4149
+ }
4150
+ }
4151
+ }
4152
+ };
4153
+ function resolveHtmlRouting(el, registry, expectedCanvasTypes) {
4154
+ const htmlType = el.htmlType;
4155
+ if (htmlType === void 0) return "dom";
4156
+ const expected = (registry?.canvasTypes.has(htmlType) ?? false) || (expectedCanvasTypes?.has(htmlType) ?? false);
4157
+ if (!expected) return "dom";
4158
+ return registry?.getActivePainter(htmlType) ? "canvas" : "missing";
4159
+ }
4160
+
4161
+ // src/canvas/html-paint.ts
4162
+ function paintHtmlElement(el, painter, opts) {
4163
+ const { ctx, zoom, target, onDiagnostic } = opts;
4164
+ const { w, h } = el.size;
4165
+ if (!(w > 0) || !(h > 0)) {
4166
+ onDiagnostic?.({ kind: "degenerate-size", elementId: el.id, htmlType: el.htmlType, target });
4167
+ return;
4168
+ }
4169
+ ctx.save();
4170
+ try {
4171
+ const rotation = opts.applyRotation === false ? 0 : el.rotation ?? 0;
4172
+ if (rotation !== 0) {
4173
+ ctx.translate(el.position.x + w / 2, el.position.y + h / 2);
4174
+ ctx.rotate(rotation);
4175
+ ctx.translate(-w / 2, -h / 2);
4176
+ } else {
4177
+ ctx.translate(el.position.x, el.position.y);
4178
+ }
4179
+ ctx.beginPath();
4180
+ ctx.rect(0, 0, w, h);
4181
+ ctx.clip();
4182
+ painter({ ctx, element: el, size: { w, h }, zoom });
4183
+ } catch (error) {
4184
+ onDiagnostic?.({
4185
+ kind: "painter-threw",
4186
+ elementId: el.id,
4187
+ htmlType: el.htmlType,
4188
+ target,
4189
+ error
4190
+ });
4191
+ } finally {
4192
+ ctx.restore();
4193
+ }
4194
+ }
4195
+
4018
4196
  // src/elements/element-renderer.ts
4019
4197
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
4020
4198
  var ElementRenderer = class {
@@ -4028,6 +4206,11 @@ var ElementRenderer = class {
4028
4206
  hexTileCacheKey = "";
4029
4207
  gridBoundsOverride = null;
4030
4208
  labelEditingId = null;
4209
+ htmlPainters = null;
4210
+ expectedCanvasTypes;
4211
+ renderTarget = "screen";
4212
+ diagnosticSink = null;
4213
+ surfaceZoom = null;
4031
4214
  setStore(store) {
4032
4215
  this.store = store;
4033
4216
  }
@@ -4049,8 +4232,27 @@ var ElementRenderer = class {
4049
4232
  setLabelEditingId(id) {
4050
4233
  this.labelEditingId = id;
4051
4234
  }
4235
+ /** Registry of canvas-backed html painters, plus any types declared canvas-routed
4236
+ * before a painter is registered (`expectedCanvasTypes`). Null clears routing back
4237
+ * to legacy DOM-only behavior. */
4238
+ setHtmlPainters(registry, expected) {
4239
+ this.htmlPainters = registry;
4240
+ this.expectedCanvasTypes = expected;
4241
+ }
4242
+ setRenderTarget(target) {
4243
+ this.renderTarget = target;
4244
+ }
4245
+ setDiagnosticSink(sink) {
4246
+ this.diagnosticSink = sink;
4247
+ }
4248
+ /** Explicit zoom used for surfaces with no camera (minimap, export). Ignored for
4249
+ * the 'screen' target, which always reads the live camera zoom. */
4250
+ setSurfaceZoom(zoom) {
4251
+ this.surfaceZoom = zoom;
4252
+ }
4052
4253
  isDomElement(element) {
4053
- return DOM_ELEMENT_TYPES.has(element.type);
4254
+ if (element.type !== "html") return DOM_ELEMENT_TYPES.has(element.type);
4255
+ return resolveHtmlRouting(element, this.htmlPainters, this.expectedCanvasTypes) === "dom";
4054
4256
  }
4055
4257
  renderCanvasElement(ctx, element) {
4056
4258
  switch (element.type) {
@@ -4086,7 +4288,37 @@ var ElementRenderer = class {
4086
4288
  case "template":
4087
4289
  renderTemplate(ctx, element, this.store);
4088
4290
  break;
4291
+ case "html":
4292
+ this.renderHtml(ctx, element);
4293
+ break;
4294
+ }
4295
+ }
4296
+ /** Only reached for canvas-routed html (isDomElement already sent 'dom'-routed
4297
+ * elements down the DOM sync branch instead). */
4298
+ renderHtml(ctx, el) {
4299
+ const routing = resolveHtmlRouting(el, this.htmlPainters, this.expectedCanvasTypes);
4300
+ if (routing === "dom") return;
4301
+ if (routing === "missing") {
4302
+ this.diagnosticSink?.({
4303
+ kind: "missing-painter",
4304
+ elementId: el.id,
4305
+ htmlType: el.htmlType,
4306
+ target: this.renderTarget
4307
+ });
4308
+ return;
4089
4309
  }
4310
+ const painter = this.htmlPainters?.getActivePainter(el.htmlType ?? "");
4311
+ if (!painter) return;
4312
+ paintHtmlElement(el, painter, {
4313
+ ctx,
4314
+ zoom: this.zoomForTarget(),
4315
+ target: this.renderTarget,
4316
+ onDiagnostic: this.diagnosticSink ?? void 0
4317
+ });
4318
+ }
4319
+ zoomForTarget() {
4320
+ if (this.renderTarget === "screen") return this.camera?.zoom ?? 1;
4321
+ return this.surfaceZoom ?? 1;
4090
4322
  }
4091
4323
  renderGrid(ctx, grid) {
4092
4324
  const canvasSize = this.canvasSize;
@@ -4233,6 +4465,7 @@ function createHtmlElement(input) {
4233
4465
  if (input.interactive) el.interactive = input.interactive;
4234
4466
  if (input.htmlType) el.htmlType = input.htmlType;
4235
4467
  if (input.data) el.data = input.data;
4468
+ if (input.rotation !== void 0) el.rotation = input.rotation;
4236
4469
  return el;
4237
4470
  }
4238
4471
  function createShape(input) {
@@ -4813,7 +5046,6 @@ var DEFAULT_PADDING = 8;
4813
5046
  var DEFAULT_DEBOUNCE_MS2 = 200;
4814
5047
  var DEFAULT_VIEWPORT_STROKE = "#3b82f6";
4815
5048
  var NEUTRAL = "rgba(100,116,139,0.6)";
4816
- var DOM_FALLBACK_TYPES = /* @__PURE__ */ new Set(["note", "text", "html"]);
4817
5049
  function elementColor(el) {
4818
5050
  return "color" in el && typeof el.color === "string" ? el.color : NEUTRAL;
4819
5051
  }
@@ -4837,6 +5069,9 @@ var MinimapController = class {
4837
5069
  });
4838
5070
  this.renderer.setStore(viewport.store);
4839
5071
  this.renderer.setOnImageLoad(() => this.markSceneDirty());
5072
+ this.htmlPainters = viewport.getHtmlPainters();
5073
+ this.renderer.setHtmlPainters(this.htmlPainters);
5074
+ this.renderer.setRenderTarget("minimap");
4840
5075
  this.applyCanvasSize();
4841
5076
  const onScene = () => this.markSceneDirty();
4842
5077
  this.unsubs.push(
@@ -4846,7 +5081,8 @@ var MinimapController = class {
4846
5081
  viewport.store.on("clear", onScene),
4847
5082
  viewport.layerManager.on("change", onScene),
4848
5083
  viewport.camera.onChange(() => this.onViewChanged()),
4849
- viewport.onResize(() => this.onViewChanged())
5084
+ viewport.onResize(() => this.onViewChanged()),
5085
+ this.htmlPainters.onChange(() => this.invalidateScene())
4850
5086
  );
4851
5087
  if (this.interactive) {
4852
5088
  canvas.style.touchAction = "none";
@@ -4870,6 +5106,10 @@ var MinimapController = class {
4870
5106
  requestFrame;
4871
5107
  cancelFrame;
4872
5108
  renderer = new ElementRenderer();
5109
+ // Declared, not initialized here: a field initializer runs before parameter
5110
+ // properties are assigned (ES2022 useDefineForClassFields), so it cannot
5111
+ // read the `viewport` parameter. Assigned in the constructor body instead.
5112
+ htmlPainters;
4873
5113
  scene = null;
4874
5114
  frameId = null;
4875
5115
  debounceTimer = null;
@@ -4889,6 +5129,23 @@ var MinimapController = class {
4889
5129
  if (this.disposed || this.frameId !== null) return;
4890
5130
  this.frameId = this.requestFrame(this.draw);
4891
5131
  }
5132
+ /**
5133
+ * Invalidates the cached scene bitmap in response to html-painter registry
5134
+ * changes (a painter registering/unregistering, or an `expect` declaration
5135
+ * changing) and schedules the same debounced rebuild `markSceneDirty` uses
5136
+ * for every other invalidation source. Unlike those other sources — which
5137
+ * deliberately keep compositing the OLD bitmap until the rebuild lands, so
5138
+ * camera motion and content edits never stall on a render — painter
5139
+ * availability has no "old bitmap is still valid" reading: an element that
5140
+ * was falling back to a neutral fillRect (no painter yet) or a stale
5141
+ * painter's output is not a safe thing to keep showing, so the cached
5142
+ * bitmap is dropped immediately instead of composited a further time.
5143
+ */
5144
+ invalidateScene() {
5145
+ if (this.disposed) return;
5146
+ this.scene = null;
5147
+ this.markSceneDirty();
5148
+ }
4892
5149
  dispose() {
4893
5150
  if (this.disposed) return;
4894
5151
  this.disposed = true;
@@ -4954,6 +5211,7 @@ var MinimapController = class {
4954
5211
  const dpr = this.dpr();
4955
5212
  const mapping = this.currentMapping();
4956
5213
  const transform = computeMinimapTransform(mapping, this.width, this.height, this.padding);
5214
+ this.renderer.setSurfaceZoom(transform.scale);
4957
5215
  const sceneCanvas = document.createElement("canvas");
4958
5216
  sceneCanvas.width = Math.max(1, Math.round(this.width * dpr));
4959
5217
  sceneCanvas.height = Math.max(1, Math.round(this.height * dpr));
@@ -4991,7 +5249,7 @@ var MinimapController = class {
4991
5249
  }
4992
5250
  renderLayerElements(ctx, elements, t, dpr) {
4993
5251
  for (const el of elements) {
4994
- if (DOM_FALLBACK_TYPES.has(el.type)) {
5252
+ if (this.renderer.isDomElement(el)) {
4995
5253
  const b = getElementBounds(el);
4996
5254
  if (!b) continue;
4997
5255
  const tl = worldToMini(t, { x: b.x, y: b.y });
@@ -6071,7 +6329,28 @@ async function exportImage(store, options = {}, layerManager) {
6071
6329
  assertExportSize(width, height, options);
6072
6330
  const imageCache = await loadImages(visibleElements, options);
6073
6331
  const htmlElements = visibleElements.filter((el) => el.type === "html");
6074
- const htmlSources = await renderHtmlElements(htmlElements, options);
6332
+ const canvasRoutedIds = /* @__PURE__ */ new Set();
6333
+ const domHtmlElements = [];
6334
+ for (const el of htmlElements) {
6335
+ const routing = resolveHtmlRouting(
6336
+ el,
6337
+ options.htmlPainters ?? null,
6338
+ options.expectedCanvasTypes
6339
+ );
6340
+ if (routing === "missing") {
6341
+ if (options.strictMissingCanvasHtml) {
6342
+ throw new HtmlPainterMissingError(el.id, el.htmlType);
6343
+ }
6344
+ options.onHtmlError?.({ elementId: el.id, htmlType: el.htmlType, reason: "missing-painter" });
6345
+ continue;
6346
+ }
6347
+ if (routing === "canvas") {
6348
+ canvasRoutedIds.add(el.id);
6349
+ } else {
6350
+ domHtmlElements.push(el);
6351
+ }
6352
+ }
6353
+ const htmlSources = await renderHtmlElements(domHtmlElements, options);
6075
6354
  const canvas = document.createElement("canvas");
6076
6355
  canvas.width = width;
6077
6356
  canvas.height = height;
@@ -6083,6 +6362,14 @@ async function exportImage(store, options = {}, layerManager) {
6083
6362
  ctx.fillRect(bounds.x, bounds.y, bounds.w, bounds.h);
6084
6363
  const renderer = new ElementRenderer();
6085
6364
  renderer.setStore(store);
6365
+ const onHtmlPaintDiagnostic = (d) => {
6366
+ options.onHtmlError?.({
6367
+ elementId: d.elementId,
6368
+ htmlType: d.htmlType,
6369
+ reason: d.kind,
6370
+ cause: d.kind === "painter-threw" ? d.error : void 0
6371
+ });
6372
+ };
6086
6373
  const grids = [];
6087
6374
  const renderElement = (target, el) => {
6088
6375
  if (el.type === "note") {
@@ -6096,6 +6383,24 @@ async function exportImage(store, options = {}, layerManager) {
6096
6383
  return;
6097
6384
  }
6098
6385
  if (el.type === "html") {
6386
+ if (canvasRoutedIds.has(el.id)) {
6387
+ const painter = options.htmlPainters?.getActivePainter(el.htmlType ?? "");
6388
+ if (!painter) {
6389
+ options.onHtmlError?.({
6390
+ elementId: el.id,
6391
+ htmlType: el.htmlType,
6392
+ reason: "missing-painter"
6393
+ });
6394
+ return;
6395
+ }
6396
+ paintHtmlElement(el, painter, {
6397
+ ctx: target,
6398
+ zoom: scale,
6399
+ target: "export",
6400
+ onDiagnostic: onHtmlPaintDiagnostic
6401
+ });
6402
+ return;
6403
+ }
6099
6404
  const source = htmlSources.get(el.id);
6100
6405
  if (!source) return;
6101
6406
  const b = getElementBounds(el);
@@ -6490,8 +6795,36 @@ async function exportSvg(store, options = {}, layerManager) {
6490
6795
  const imageCache = await loadImages(remoteImages, options);
6491
6796
  const imageDataUris = encodeImages(visibleElements, imageCache, rasterScale, options);
6492
6797
  const htmlElements = visibleElements.filter((el) => el.type === "html");
6493
- const htmlSources = await renderHtmlElements(htmlElements, options);
6494
- const htmlDataUris = encodeHtmlElements(htmlElements, htmlSources, rasterScale, options);
6798
+ const canvasRoutedElements = [];
6799
+ const domHtmlElements = [];
6800
+ for (const el of htmlElements) {
6801
+ const routing = resolveHtmlRouting(
6802
+ el,
6803
+ options.htmlPainters ?? null,
6804
+ options.expectedCanvasTypes
6805
+ );
6806
+ if (routing === "missing") {
6807
+ if (options.strictMissingCanvasHtml) {
6808
+ throw new HtmlPainterMissingError(el.id, el.htmlType);
6809
+ }
6810
+ options.onHtmlError?.({ elementId: el.id, htmlType: el.htmlType, reason: "missing-painter" });
6811
+ continue;
6812
+ }
6813
+ if (routing === "canvas") {
6814
+ canvasRoutedElements.push(el);
6815
+ } else {
6816
+ domHtmlElements.push(el);
6817
+ }
6818
+ }
6819
+ const htmlSources = await renderHtmlElements(domHtmlElements, options);
6820
+ const htmlDataUris = encodeHtmlElements(domHtmlElements, htmlSources, rasterScale, options);
6821
+ const canvasHtmlDataUris = rasterizeCanvasRoutedHtml(
6822
+ canvasRoutedElements,
6823
+ options.htmlPainters,
6824
+ rasterScale,
6825
+ options
6826
+ );
6827
+ for (const [id, uri] of canvasHtmlDataUris) htmlDataUris.set(id, uri);
6495
6828
  const grids = visibleElements.filter((el) => el.type === "grid");
6496
6829
  const firstGrid = grids[0];
6497
6830
  let body = "";
@@ -6546,6 +6879,81 @@ function emitElement(el, imageDataUris, htmlDataUris, rasterScale, firstGrid, st
6546
6879
  return "";
6547
6880
  }
6548
6881
  }
6882
+ function rasterizeCanvasRoutedHtml(elements, registry, rasterScale, options) {
6883
+ const encoded = /* @__PURE__ */ new Map();
6884
+ const onDiagnostic = (d) => {
6885
+ options.onHtmlError?.({
6886
+ elementId: d.elementId,
6887
+ htmlType: d.htmlType,
6888
+ reason: d.kind,
6889
+ cause: d.kind === "painter-threw" ? d.error : void 0
6890
+ });
6891
+ };
6892
+ for (const element of elements) {
6893
+ const painter = registry?.getActivePainter(element.htmlType ?? "");
6894
+ if (!painter) {
6895
+ options.onHtmlError?.({
6896
+ elementId: element.id,
6897
+ htmlType: element.htmlType,
6898
+ reason: "missing-painter"
6899
+ });
6900
+ continue;
6901
+ }
6902
+ if (typeof document === "undefined") {
6903
+ options.onHtmlError?.({
6904
+ elementId: element.id,
6905
+ htmlType: element.htmlType,
6906
+ reason: "encode"
6907
+ });
6908
+ continue;
6909
+ }
6910
+ const width = Math.max(1, Math.ceil(element.size.w * rasterScale));
6911
+ const height = Math.max(1, Math.ceil(element.size.h * rasterScale));
6912
+ assertExportSize(width, height, options);
6913
+ const off = document.createElement("canvas");
6914
+ off.width = width;
6915
+ off.height = height;
6916
+ const octx = off.getContext("2d");
6917
+ if (!octx) {
6918
+ options.onHtmlError?.({
6919
+ elementId: element.id,
6920
+ htmlType: element.htmlType,
6921
+ reason: "encode"
6922
+ });
6923
+ continue;
6924
+ }
6925
+ octx.scale(rasterScale, rasterScale);
6926
+ octx.translate(-element.position.x, -element.position.y);
6927
+ paintHtmlElement(element, painter, {
6928
+ ctx: octx,
6929
+ zoom: rasterScale,
6930
+ target: "export",
6931
+ applyRotation: false,
6932
+ // withRotationSvg wraps the emitted <image>; rotating here too would double it
6933
+ onDiagnostic
6934
+ });
6935
+ try {
6936
+ const dataUri = off.toDataURL("image/png");
6937
+ if (dataUri.startsWith("data:")) {
6938
+ encoded.set(element.id, dataUri);
6939
+ } else {
6940
+ options.onHtmlError?.({
6941
+ elementId: element.id,
6942
+ htmlType: element.htmlType,
6943
+ reason: "encode"
6944
+ });
6945
+ }
6946
+ } catch (cause) {
6947
+ options.onHtmlError?.({
6948
+ elementId: element.id,
6949
+ htmlType: element.htmlType,
6950
+ reason: "encode",
6951
+ cause
6952
+ });
6953
+ }
6954
+ }
6955
+ return encoded;
6956
+ }
6549
6957
  function encodeHtmlElements(elements, sources, rasterScale, options) {
6550
6958
  const encoded = /* @__PURE__ */ new Map();
6551
6959
  for (const element of elements) {
@@ -6891,6 +7299,10 @@ var DomNodeManager = class {
6891
7299
  domNodes = /* @__PURE__ */ new Map();
6892
7300
  strata = /* @__PURE__ */ new Map();
6893
7301
  htmlContent = /* @__PURE__ */ new Map();
7302
+ /** Elements whose node carries host-supplied content (see `markHostOwnedContent`). */
7303
+ hostOwnedContent = /* @__PURE__ */ new Set();
7304
+ /** Host-owned nodes removed from the DOM but kept alive for a later remount. */
7305
+ preservedNodes = /* @__PURE__ */ new Map();
6894
7306
  domLayer;
6895
7307
  onEditRequest;
6896
7308
  isEditingElement;
@@ -6915,12 +7327,34 @@ var DomNodeManager = class {
6915
7327
  }
6916
7328
  storeHtmlContent(elementId, dom) {
6917
7329
  this.htmlContent.set(elementId, dom);
7330
+ this.lastSyncedVersion.delete(elementId);
7331
+ }
7332
+ /**
7333
+ * Marks an element's node as carrying content the HOST mounted into it directly
7334
+ * (`ViewportOptions.onHtmlElementMount`). That content is never recorded in
7335
+ * `htmlContent` — the host appends straight into the node — so a detach would
7336
+ * destroy it and no remount could ever bring it back. Preserving the node itself
7337
+ * (rather than a guessed-at child) keeps arbitrary subtrees, host-attached
7338
+ * listeners, and the host's own reference to the node all valid.
7339
+ *
7340
+ * Callers mark UNCONDITIONALLY, without inspecting the node — a host that only attaches
7341
+ * listeners or styles owns its node just as much as one that appended children, and
7342
+ * there is no way to tell those apart from the outside. Two consequences follow, both
7343
+ * accepted: a node the host never populated can still round-trip back into the DOM (the
7344
+ * documented exception to "never had content -> never remount", reachable only via
7345
+ * `onHtmlElementMount`), and one detached `<div>` is retained per such element for its
7346
+ * lifetime.
7347
+ */
7348
+ markHostOwnedContent(elementId) {
7349
+ this.hostOwnedContent.add(elementId);
6918
7350
  }
6919
7351
  hasContent(elementId) {
6920
7352
  return this.htmlContent.has(elementId);
6921
7353
  }
6922
7354
  resetHtmlContent(elementId) {
6923
7355
  this.htmlContent.delete(elementId);
7356
+ this.hostOwnedContent.delete(elementId);
7357
+ this.preservedNodes.delete(elementId);
6924
7358
  this.lastSyncedVersion.delete(elementId);
6925
7359
  this.lastSyncedZIndex.delete(elementId);
6926
7360
  this.lastSyncedOpacity.delete(elementId);
@@ -6934,12 +7368,18 @@ var DomNodeManager = class {
6934
7368
  syncDomNode(element, zIndex = 0, opacity = 1) {
6935
7369
  let node = this.domNodes.get(element.id);
6936
7370
  if (!node) {
6937
- node = document.createElement("div");
6938
- node.dataset["elementId"] = element.id;
6939
- Object.assign(node.style, {
6940
- position: "absolute",
6941
- pointerEvents: "auto"
6942
- });
7371
+ const preserved = this.preservedNodes.get(element.id);
7372
+ if (preserved) {
7373
+ this.preservedNodes.delete(element.id);
7374
+ node = preserved;
7375
+ } else {
7376
+ node = document.createElement("div");
7377
+ node.dataset["elementId"] = element.id;
7378
+ Object.assign(node.style, {
7379
+ position: "absolute",
7380
+ pointerEvents: "auto"
7381
+ });
7382
+ }
6943
7383
  this.getStratum(zIndex).appendChild(node);
6944
7384
  this.domNodes.set(element.id, node);
6945
7385
  } else if (this.getVersion) {
@@ -6988,18 +7428,51 @@ var DomNodeManager = class {
6988
7428
  }
6989
7429
  removeDomNode(id) {
6990
7430
  this.htmlContent.delete(id);
7431
+ this.hostOwnedContent.delete(id);
7432
+ this.preservedNodes.delete(id);
7433
+ this.detachNodeElement(id);
7434
+ }
7435
+ /** Removes the node but KEEPS htmlContent, so a later re-mount restores the original embed.
7436
+ * The registry factory that produces embed content only runs in loadState (G1), so dropping
7437
+ * content here would be unrecoverable. For a host-owned node there is no recorded content at
7438
+ * all, so the node ITSELF is kept alive off-DOM and reattached by `syncDomNode`.
7439
+ * Use `removeDomNode` when the element itself is gone. */
7440
+ detachDomNode(id) {
7441
+ if (this.hostOwnedContent.has(id)) {
7442
+ const node = this.domNodes.get(id);
7443
+ if (node) this.preservedNodes.set(id, node);
7444
+ }
7445
+ this.detachNodeElement(id);
7446
+ }
7447
+ /** Shared by `removeDomNode` and `detachDomNode`: clears dirty-tracking caches, removes the
7448
+ * node from the DOM, and cleans up its stratum if now empty. Does NOT touch `htmlContent` —
7449
+ * that distinction is each caller's own responsibility. */
7450
+ detachNodeElement(id) {
6991
7451
  this.lastSyncedVersion.delete(id);
6992
7452
  this.lastSyncedZIndex.delete(id);
6993
7453
  this.lastSyncedOpacity.delete(id);
6994
7454
  const node = this.domNodes.get(id);
6995
- if (node) {
6996
- const stratum = node.parentElement;
6997
- node.remove();
6998
- this.domNodes.delete(id);
6999
- if (stratum?.childElementCount === 0) {
7000
- const order = Number(stratum.dataset["paintOrder"]);
7001
- stratum.remove();
7002
- this.strata.delete(order);
7455
+ if (!node) return;
7456
+ const stratum = node.parentElement;
7457
+ node.remove();
7458
+ this.domNodes.delete(id);
7459
+ if (stratum?.childElementCount === 0) {
7460
+ const order = Number(stratum.dataset["paintOrder"]);
7461
+ stratum.remove();
7462
+ this.strata.delete(order);
7463
+ }
7464
+ }
7465
+ /** Reconciles BOTH directions synchronously. Canvas/missing routing detaches the node
7466
+ * (content preserved); dom routing remounts preserved content immediately, so a painter
7467
+ * unregistration does not wait for an unrelated render pass. */
7468
+ reconcileHtmlRouting(store, resolve) {
7469
+ for (const el of store.getElementsByType("html")) {
7470
+ if (resolve(el) !== "dom") {
7471
+ this.detachDomNode(el.id);
7472
+ continue;
7473
+ }
7474
+ if ((this.htmlContent.has(el.id) || this.preservedNodes.has(el.id)) && !this.domNodes.has(el.id)) {
7475
+ this.syncDomNode(el);
7003
7476
  }
7004
7477
  }
7005
7478
  }
@@ -7007,6 +7480,8 @@ var DomNodeManager = class {
7007
7480
  this.domNodes.forEach((node) => node.remove());
7008
7481
  this.domNodes.clear();
7009
7482
  this.htmlContent.clear();
7483
+ this.hostOwnedContent.clear();
7484
+ this.preservedNodes.clear();
7010
7485
  this.lastSyncedVersion.clear();
7011
7486
  this.lastSyncedZIndex.clear();
7012
7487
  this.lastSyncedOpacity.clear();
@@ -7079,8 +7554,8 @@ var DomNodeManager = class {
7079
7554
  }
7080
7555
  }
7081
7556
  if (element.type === "html") {
7557
+ const content = this.htmlContent.get(element.id);
7082
7558
  if (!node.dataset["initialized"]) {
7083
- const content = this.htmlContent.get(element.id);
7084
7559
  if (content) {
7085
7560
  node.dataset["initialized"] = "true";
7086
7561
  Object.assign(node.style, {
@@ -7088,6 +7563,8 @@ var DomNodeManager = class {
7088
7563
  pointerEvents: element.interactive ? "auto" : "none"
7089
7564
  });
7090
7565
  node.appendChild(content);
7566
+ } else {
7567
+ node.style.pointerEvents = "none";
7091
7568
  }
7092
7569
  } else {
7093
7570
  node.style.pointerEvents = element.interactive ? "auto" : "none";
@@ -7222,6 +7699,8 @@ var RenderLoop = class {
7222
7699
  gridCacheCanvas = null;
7223
7700
  gridCacheCtx = null;
7224
7701
  lastGridRefs = [];
7702
+ htmlScratchCanvas = null;
7703
+ htmlScratchCtx = null;
7225
7704
  constructor(deps) {
7226
7705
  this.canvasEl = deps.canvasEl;
7227
7706
  this.camera = deps.camera;
@@ -7332,6 +7811,117 @@ var RenderLoop = class {
7332
7811
  }
7333
7812
  this.gridCacheCtx = this.gridCacheCanvas.getContext("2d");
7334
7813
  }
7814
+ /**
7815
+ * Lazily allocated, and only ever touched by the translucent-layer html branch below.
7816
+ * Grow-only: the branch runs once per element per frame, and reassigning `width` or
7817
+ * `height` reallocates the backing store, so shrinking to each element in turn would
7818
+ * thrash. The requested size is always clipped to the visible canvas by
7819
+ * `htmlScratchRect`, so this stays bounded by the canvas itself.
7820
+ */
7821
+ ensureHtmlScratch(w, h) {
7822
+ if (this.htmlScratchCanvas === null || this.htmlScratchCtx === null) {
7823
+ if (typeof OffscreenCanvas !== "undefined") {
7824
+ this.htmlScratchCanvas = new OffscreenCanvas(w, h);
7825
+ } else if (typeof document !== "undefined") {
7826
+ this.htmlScratchCanvas = document.createElement("canvas");
7827
+ } else {
7828
+ return null;
7829
+ }
7830
+ this.htmlScratchCtx = this.htmlScratchCanvas.getContext(
7831
+ "2d"
7832
+ );
7833
+ if (this.htmlScratchCtx === null) {
7834
+ this.htmlScratchCanvas = null;
7835
+ return null;
7836
+ }
7837
+ }
7838
+ if (this.htmlScratchCanvas.width < w) this.htmlScratchCanvas.width = w;
7839
+ if (this.htmlScratchCanvas.height < h) this.htmlScratchCanvas.height = h;
7840
+ return this.htmlScratchCtx;
7841
+ }
7842
+ /**
7843
+ * The device-pixel footprint an element occupies on the hybrid surface: its world
7844
+ * bounds mapped through the same `scale(dpr) -> translate(cam) -> scale(zoom)` chain
7845
+ * the hybrid context uses, snapped OUT to whole pixels (so the clip edge's antialiased
7846
+ * pixel is included) and intersected with the surface.
7847
+ *
7848
+ * Null when nothing of the element lands on the surface. That includes the case where
7849
+ * the surface has no pixels at all: a host hiding the viewport (`display: none`) drives
7850
+ * `canvasEl.width` to 0 via `syncCanvasSize`, culling does not go degenerate with it,
7851
+ * and a zero-dimension canvas throws `InvalidStateError` when used as a `drawImage`
7852
+ * SOURCE — which would escape `render()` and kill the frame loop permanently.
7853
+ * Non-finite bounds fail the same `>= 1` test and are rejected here too.
7854
+ */
7855
+ htmlScratchRect(bounds, dpr) {
7856
+ let left = 0;
7857
+ let top = 0;
7858
+ let right = this.canvasEl.width;
7859
+ let bottom = this.canvasEl.height;
7860
+ if (bounds) {
7861
+ const zoom = this.camera.zoom;
7862
+ const camX = this.camera.position.x;
7863
+ const camY = this.camera.position.y;
7864
+ left = Math.max(left, Math.floor((bounds.x * zoom + camX) * dpr));
7865
+ top = Math.max(top, Math.floor((bounds.y * zoom + camY) * dpr));
7866
+ right = Math.min(right, Math.ceil(((bounds.x + bounds.w) * zoom + camX) * dpr));
7867
+ bottom = Math.min(bottom, Math.ceil(((bounds.y + bounds.h) * zoom + camY) * dpr));
7868
+ }
7869
+ const w = right - left;
7870
+ const h = bottom - top;
7871
+ if (!(w >= 1) || !(h >= 1)) return null;
7872
+ return { x: left, y: top, w, h };
7873
+ }
7874
+ /**
7875
+ * Draws one canvas-routed html element on the hybrid stratum with the layer-opacity
7876
+ * boundary the painter contract requires: paint at `globalAlpha === 1` into a scratch
7877
+ * surface, then composite that raster at the layer's opacity. Mirrors `exportImage`'s
7878
+ * per-layer temp canvas and the minimap's layer composite.
7879
+ *
7880
+ * The scratch covers only the element's own device-pixel rect, the way
7881
+ * `rasterizeCanvasRoutedHtml` sizes its offscreen to the element rather than the export
7882
+ * bounds. That is exact rather than approximate because `paintHtmlElement` clips every
7883
+ * painter to the element's (rotated) rect, so nothing can land outside those bounds.
7884
+ * The offset is whole device pixels and the blit is 1:1, so the element occupies
7885
+ * exactly the pixels a full-canvas scratch would have given it.
7886
+ */
7887
+ paintHybridHtmlAtLayerOpacity(hybridCtx, element, elementBounds, layerOpacity, dpr) {
7888
+ const rect = this.htmlScratchRect(elementBounds, dpr);
7889
+ if (!rect) return;
7890
+ const scratchCtx = this.ensureHtmlScratch(rect.w, rect.h);
7891
+ const scratchCanvas = this.htmlScratchCanvas;
7892
+ if (!scratchCtx || !scratchCanvas) {
7893
+ hybridCtx.save();
7894
+ hybridCtx.globalAlpha = layerOpacity;
7895
+ this.renderer.renderCanvasElement(hybridCtx, element);
7896
+ hybridCtx.restore();
7897
+ return;
7898
+ }
7899
+ scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
7900
+ scratchCtx.clearRect(0, 0, rect.w, rect.h);
7901
+ scratchCtx.save();
7902
+ scratchCtx.globalAlpha = 1;
7903
+ scratchCtx.translate(-rect.x, -rect.y);
7904
+ scratchCtx.scale(dpr, dpr);
7905
+ scratchCtx.translate(this.camera.position.x, this.camera.position.y);
7906
+ scratchCtx.scale(this.camera.zoom, this.camera.zoom);
7907
+ this.renderer.renderCanvasElement(scratchCtx, element);
7908
+ scratchCtx.restore();
7909
+ hybridCtx.save();
7910
+ hybridCtx.setTransform(1, 0, 0, 1, 0, 0);
7911
+ hybridCtx.globalAlpha = layerOpacity;
7912
+ hybridCtx.drawImage(
7913
+ scratchCanvas,
7914
+ 0,
7915
+ 0,
7916
+ rect.w,
7917
+ rect.h,
7918
+ rect.x,
7919
+ rect.y,
7920
+ rect.w,
7921
+ rect.h
7922
+ );
7923
+ hybridCtx.restore();
7924
+ }
7335
7925
  render() {
7336
7926
  const t0 = performance.now();
7337
7927
  const ctx = this.canvasEl.getContext("2d");
@@ -7536,8 +8126,13 @@ var RenderLoop = class {
7536
8126
  for (const element of elements) {
7537
8127
  const elBounds = getElementVisualBounds(element);
7538
8128
  if (elBounds && !boundsIntersect(elBounds, cullingRect)) continue;
8129
+ const layerOpacity = this.layerManager.getLayer?.(element.layerId)?.opacity ?? 1;
8130
+ if (element.type === "html" && layerOpacity < 1) {
8131
+ this.paintHybridHtmlAtLayerOpacity(hybridCtx, element, elBounds, layerOpacity, dpr);
8132
+ continue;
8133
+ }
7539
8134
  hybridCtx.save();
7540
- hybridCtx.globalAlpha = this.layerManager.getLayer?.(element.layerId)?.opacity ?? 1;
8135
+ hybridCtx.globalAlpha = layerOpacity;
7541
8136
  this.renderer.renderCanvasElement(hybridCtx, element);
7542
8137
  hybridCtx.restore();
7543
8138
  }
@@ -7573,6 +8168,28 @@ var RenderLoop = class {
7573
8168
  }
7574
8169
  };
7575
8170
 
8171
+ // src/canvas/html-paint-diagnostics.ts
8172
+ var HtmlPaintDiagnosticDeduper = class {
8173
+ constructor(sink) {
8174
+ this.sink = sink;
8175
+ }
8176
+ seen = /* @__PURE__ */ new Map();
8177
+ emit(diagnostic, keyParts) {
8178
+ const key = `${diagnostic.target}|${diagnostic.kind}|${keyParts.registryVersion}|${keyParts.elementVersion}`;
8179
+ const forElement = this.seen.get(diagnostic.elementId) ?? /* @__PURE__ */ new Set();
8180
+ if (forElement.has(key)) return;
8181
+ forElement.add(key);
8182
+ this.seen.set(diagnostic.elementId, forElement);
8183
+ this.sink(diagnostic);
8184
+ }
8185
+ forget(elementId) {
8186
+ this.seen.delete(elementId);
8187
+ }
8188
+ reset() {
8189
+ this.seen.clear();
8190
+ }
8191
+ };
8192
+
7576
8193
  // src/canvas/layer-cache.ts
7577
8194
  function createOffscreenCanvas(width, height) {
7578
8195
  if (typeof OffscreenCanvas !== "undefined") {
@@ -8297,59 +8914,915 @@ var ViewportInteractions = class {
8297
8914
  };
8298
8915
  };
8299
8916
 
8300
- // src/canvas/viewport.ts
8301
- var EMPTY_IDS = [];
8302
- function noop() {
8917
+ // src/elements/stroke-hit.ts
8918
+ function hitTestStroke(stroke, point, radius) {
8919
+ const bounds = getElementBounds(stroke);
8920
+ if (!bounds) return false;
8921
+ 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) {
8922
+ return false;
8923
+ }
8924
+ const radiusSq = radius * radius;
8925
+ const local = { x: point.x - stroke.position.x, y: point.y - stroke.position.y };
8926
+ const { segments } = getStrokeRenderData(stroke);
8927
+ if (segments.length === 0) {
8928
+ const p = stroke.points[0];
8929
+ if (!p) return false;
8930
+ const dx = p.x - local.x;
8931
+ const dy = p.y - local.y;
8932
+ return dx * dx + dy * dy <= radiusSq;
8933
+ }
8934
+ for (const seg of segments) {
8935
+ if (distSqToSegment(local, seg.start, seg.end) <= radiusSq) return true;
8936
+ }
8937
+ return false;
8303
8938
  }
8304
- var Viewport = class _Viewport {
8305
- constructor(container, options = {}) {
8306
- this.container = container;
8307
- this.camera = new Camera(options.camera);
8308
- this.background = new Background(options.background);
8309
- this._gridSize = options.background?.spacing ?? 24;
8310
- this.store = new ElementStore();
8311
- this.layerManager = new LayerManager(this.store);
8312
- this.toolManager = new ToolManager();
8313
- this.unsubToolRegister = this.toolManager.onRegister((tool) => {
8314
- if (_Viewport.isSelectionSource(tool)) this.attachSelectionSource(tool);
8315
- });
8316
- const existingSelect = this.getSelectTool();
8317
- if (existingSelect && _Viewport.isSelectionSource(existingSelect)) {
8318
- this.attachSelectionSource(existingSelect);
8319
- }
8320
- this.renderer = new ElementRenderer();
8321
- this.renderer.setStore(this.store);
8322
- this.renderer.setCamera(this.camera);
8323
- this.renderer.setOnImageLoad(() => {
8324
- this.renderLoop.markAllLayersDirty();
8325
- this.requestRender();
8326
- });
8327
- this.renderer.setOnImageError((src, cause) => {
8328
- const elementIds = [];
8329
- for (const el of this.store.getAll()) {
8330
- if (el.type === "image" && el.src === src) elementIds.push(el.id);
8331
- }
8332
- if (options.onImageError) {
8333
- options.onImageError({ src, elementIds, cause });
8334
- } else {
8335
- console.warn(`[fieldnotes] image failed to load: ${src}`);
8336
- }
8337
- });
8338
- this.noteEditor = new NoteEditor({
8339
- fontSizePresets: options.fontSizePresets,
8340
- toolbar: options.toolbar,
8341
- placeholder: options.placeholder
8342
- });
8343
- this.noteEditor.setOnStop((id) => this.interactions.onTextEditStop(id));
8344
- this.noteEditor.setOnInput((id) => this.interactions.liveFitHeight(id));
8345
- this.arrowLabelEditor = new ArrowLabelEditor();
8346
- this.noteEditor.setHistoryHooks(
8347
- () => this.historyRecorder.begin(),
8348
- () => this.historyRecorder.commit()
8349
- );
8350
- this.onHtmlElementMount = options.onHtmlElementMount;
8351
- this.dropHandler = options.onDrop;
8352
- this.history = new HistoryStack();
8939
+
8940
+ // src/tools/arrow-handles.ts
8941
+ var BIND_THRESHOLD = 20;
8942
+ var HANDLE_RADIUS = 5;
8943
+ var HANDLE_HIT_PADDING = 4;
8944
+ var ARROW_HANDLE_CURSORS = {
8945
+ start: "crosshair",
8946
+ end: "crosshair",
8947
+ mid: "grab"
8948
+ };
8949
+ function getArrowHandleCursor(handle, active) {
8950
+ if (handle === "mid" && active) return "grabbing";
8951
+ return ARROW_HANDLE_CURSORS[handle];
8952
+ }
8953
+ function getArrowHandlePositions(arrow) {
8954
+ const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
8955
+ return [
8956
+ ["start", arrow.from],
8957
+ ["mid", mid],
8958
+ ["end", arrow.to]
8959
+ ];
8960
+ }
8961
+ function hitTestArrowHandles(world, selectedIds, ctx) {
8962
+ if (selectedIds.length === 0) return null;
8963
+ const zoom = ctx.camera.zoom;
8964
+ const hitRadius = (HANDLE_RADIUS + HANDLE_HIT_PADDING) / zoom;
8965
+ for (const id of selectedIds) {
8966
+ const el = ctx.store.getById(id);
8967
+ if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
8968
+ continue;
8969
+ const handles = getArrowHandlePositions(el);
8970
+ for (const [handle, pos] of handles) {
8971
+ const dx = world.x - pos.x;
8972
+ const dy = world.y - pos.y;
8973
+ if (dx * dx + dy * dy <= hitRadius * hitRadius) {
8974
+ return { elementId: id, handle };
8975
+ }
8976
+ }
8977
+ }
8978
+ return null;
8979
+ }
8980
+ function applyArrowHandleDrag(handle, elementId, world, ctx) {
8981
+ const el = ctx.store.getById(elementId);
8982
+ if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) return;
8983
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
8984
+ const layerFilter = (candidate) => candidate.layerId === el.layerId;
8985
+ switch (handle) {
8986
+ case "start": {
8987
+ const excludeId = el.toBinding?.elementId;
8988
+ const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
8989
+ if (target) {
8990
+ const center2 = getElementCenter(target);
8991
+ ctx.store.update(elementId, {
8992
+ from: center2,
8993
+ position: center2,
8994
+ fromBinding: { elementId: target.id }
8995
+ });
8996
+ } else {
8997
+ ctx.store.update(elementId, {
8998
+ from: { x: world.x, y: world.y },
8999
+ position: { x: world.x, y: world.y },
9000
+ fromBinding: void 0
9001
+ });
9002
+ }
9003
+ break;
9004
+ }
9005
+ case "end": {
9006
+ const excludeId = el.fromBinding?.elementId;
9007
+ const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
9008
+ if (target) {
9009
+ const center2 = getElementCenter(target);
9010
+ ctx.store.update(elementId, {
9011
+ to: center2,
9012
+ toBinding: { elementId: target.id }
9013
+ });
9014
+ } else {
9015
+ ctx.store.update(elementId, {
9016
+ to: { x: world.x, y: world.y },
9017
+ toBinding: void 0
9018
+ });
9019
+ }
9020
+ break;
9021
+ }
9022
+ case "mid": {
9023
+ const bend = getBendFromPoint(el.from, el.to, world);
9024
+ ctx.store.update(elementId, { bend });
9025
+ break;
9026
+ }
9027
+ }
9028
+ ctx.requestRender();
9029
+ }
9030
+ function getArrowHandleDragTarget(handle, elementId, world, ctx) {
9031
+ if (handle === "mid") return null;
9032
+ const el = ctx.store.getById(elementId);
9033
+ if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) {
9034
+ return null;
9035
+ }
9036
+ const threshold = BIND_THRESHOLD / ctx.camera.zoom;
9037
+ const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
9038
+ const layerFilter = (candidate) => candidate.layerId === el.layerId;
9039
+ const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
9040
+ if (!target) return null;
9041
+ return getElementBounds(target);
9042
+ }
9043
+ function renderArrowHandles(canvasCtx, arrow, zoom) {
9044
+ const radius = HANDLE_RADIUS / zoom;
9045
+ const handles = getArrowHandlePositions(arrow);
9046
+ canvasCtx.setLineDash([]);
9047
+ canvasCtx.lineWidth = 1.5 / zoom;
9048
+ for (const [handle, pos] of handles) {
9049
+ canvasCtx.fillStyle = handle === "mid" ? "#2196F3" : "#ffffff";
9050
+ canvasCtx.strokeStyle = "#2196F3";
9051
+ canvasCtx.beginPath();
9052
+ canvasCtx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
9053
+ canvasCtx.fill();
9054
+ canvasCtx.stroke();
9055
+ }
9056
+ }
9057
+ function renderArrowHoverHandle(canvasCtx, arrow, zoom) {
9058
+ const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
9059
+ const radius = HANDLE_RADIUS / zoom;
9060
+ canvasCtx.fillStyle = "#2196F3";
9061
+ canvasCtx.strokeStyle = "#2196F3";
9062
+ canvasCtx.lineWidth = 1.5 / zoom;
9063
+ canvasCtx.beginPath();
9064
+ canvasCtx.arc(mid.x, mid.y, radius, 0, Math.PI * 2);
9065
+ canvasCtx.fill();
9066
+ canvasCtx.stroke();
9067
+ }
9068
+
9069
+ // src/tools/select-overlay.ts
9070
+ var HANDLE_SIZE = 8;
9071
+ var HANDLE_HIT_PADDING2 = 4;
9072
+ var SELECTION_PAD = 4;
9073
+ var ROTATE_HANDLE_OFFSET = 24;
9074
+ var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
9075
+ var HANDLE_CURSORS = {
9076
+ nw: "nwse-resize",
9077
+ se: "nwse-resize",
9078
+ ne: "nesw-resize",
9079
+ sw: "nesw-resize"
9080
+ };
9081
+ function getOverlayLayout(el, zoom) {
9082
+ const bounds = getElementBounds(el);
9083
+ if (!bounds) return null;
9084
+ const angle = el.rotation ?? 0;
9085
+ const pad = SELECTION_PAD / zoom;
9086
+ const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
9087
+ const raw = [
9088
+ ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
9089
+ ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
9090
+ ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
9091
+ ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
9092
+ ];
9093
+ const corners = raw.map(
9094
+ ([h, p]) => [h, rotatePoint(p, center2, angle)]
9095
+ );
9096
+ const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
9097
+ const rotateHandle = rotatePoint(topMid, center2, angle);
9098
+ return { center: center2, corners, rotateHandle, angle };
9099
+ }
9100
+ function templateAimKnob(el, zoom) {
9101
+ if (el.type !== "template") return null;
9102
+ if (el.templateShape !== "cone" && el.templateShape !== "line" && el.templateShape !== "rectangle")
9103
+ return null;
9104
+ const gap = ROTATE_HANDLE_OFFSET / zoom;
9105
+ const dist = el.radius + gap;
9106
+ const origin = el.position;
9107
+ return {
9108
+ origin,
9109
+ knob: {
9110
+ x: origin.x + dist * Math.cos(el.angle),
9111
+ y: origin.y + dist * Math.sin(el.angle)
9112
+ }
9113
+ };
9114
+ }
9115
+ function getHandlePositions(bounds) {
9116
+ return [
9117
+ ["nw", { x: bounds.x, y: bounds.y }],
9118
+ ["ne", { x: bounds.x + bounds.w, y: bounds.y }],
9119
+ ["sw", { x: bounds.x, y: bounds.y + bounds.h }],
9120
+ ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
9121
+ ];
9122
+ }
9123
+ function topMidpoint(layout) {
9124
+ const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
9125
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
9126
+ return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
9127
+ }
9128
+ function drawLockBadge(ctx, at, zoom) {
9129
+ const r = 9 / zoom;
9130
+ ctx.save();
9131
+ ctx.setLineDash([]);
9132
+ ctx.beginPath();
9133
+ ctx.arc(at.x, at.y, r, 0, Math.PI * 2);
9134
+ ctx.fillStyle = "#ffffff";
9135
+ ctx.fill();
9136
+ ctx.strokeStyle = "#2196F3";
9137
+ ctx.lineWidth = 1.5 / zoom;
9138
+ ctx.stroke();
9139
+ const bw = 8 / zoom;
9140
+ const bh = 6 / zoom;
9141
+ ctx.fillStyle = "#2196F3";
9142
+ ctx.fillRect(at.x - bw / 2, at.y - bh / 2 + 1 / zoom, bw, bh);
9143
+ ctx.beginPath();
9144
+ ctx.arc(at.x, at.y - bh / 2 + 1 / zoom, 2.5 / zoom, Math.PI, 0);
9145
+ ctx.lineWidth = 1.4 / zoom;
9146
+ ctx.stroke();
9147
+ ctx.restore();
9148
+ }
9149
+ function renderMarquee(ctx, rect) {
9150
+ ctx.save();
9151
+ ctx.strokeStyle = "#2196F3";
9152
+ ctx.fillStyle = "rgba(33, 150, 243, 0.08)";
9153
+ ctx.lineWidth = 1;
9154
+ ctx.setLineDash([4, 4]);
9155
+ ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
9156
+ ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
9157
+ ctx.restore();
9158
+ }
9159
+ function renderBindingHighlights(ctx, arrow, zoom, store) {
9160
+ if (!arrow.fromBinding && !arrow.toBinding) return;
9161
+ const pad = SELECTION_PAD / zoom;
9162
+ ctx.save();
9163
+ ctx.strokeStyle = "#2196F3";
9164
+ ctx.lineWidth = 2 / zoom;
9165
+ ctx.setLineDash([]);
9166
+ const drawn = /* @__PURE__ */ new Set();
9167
+ for (const binding of [arrow.fromBinding, arrow.toBinding]) {
9168
+ if (!binding || drawn.has(binding.elementId)) continue;
9169
+ drawn.add(binding.elementId);
9170
+ const target = store.getById(binding.elementId);
9171
+ if (!target) continue;
9172
+ const bounds = getElementBounds(target);
9173
+ if (!bounds) continue;
9174
+ ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
9175
+ }
9176
+ ctx.restore();
9177
+ }
9178
+ function renderSelectionBoxes(ctx, p) {
9179
+ if (p.selectedIds.length === 0) return;
9180
+ const zoom = p.zoom;
9181
+ const handleWorldSize = HANDLE_SIZE / zoom;
9182
+ ctx.save();
9183
+ ctx.strokeStyle = "#2196F3";
9184
+ ctx.lineWidth = 1.5 / zoom;
9185
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9186
+ for (const id of p.selectedIds) {
9187
+ const el = p.store.getById(id);
9188
+ if (!el) continue;
9189
+ const locked = el.locked || (p.isLayerLocked?.(el.layerId) ?? false);
9190
+ if (el.type === "arrow") {
9191
+ if (!locked) renderArrowHandles(ctx, el, zoom);
9192
+ renderBindingHighlights(ctx, el, zoom, p.store);
9193
+ continue;
9194
+ }
9195
+ if (el.type === "shape" && el.shape === "line") {
9196
+ if (!locked) {
9197
+ ctx.setLineDash([]);
9198
+ ctx.fillStyle = "#ffffff";
9199
+ const r = handleWorldSize / 2;
9200
+ for (const pt of lineEndpoints(el)) {
9201
+ ctx.beginPath();
9202
+ ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
9203
+ ctx.fill();
9204
+ ctx.stroke();
9205
+ }
9206
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9207
+ }
9208
+ continue;
9209
+ }
9210
+ const bounds = getElementBounds(el);
9211
+ if (!bounds) continue;
9212
+ const layout = getOverlayLayout(el, zoom);
9213
+ if (!layout) continue;
9214
+ const pad = SELECTION_PAD / zoom;
9215
+ if (layout.angle === 0) {
9216
+ ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
9217
+ } else {
9218
+ const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((pp) => !!pp);
9219
+ const [p0, ...others] = ordered;
9220
+ if (p0) {
9221
+ ctx.beginPath();
9222
+ ctx.moveTo(p0.x, p0.y);
9223
+ for (const pp of others) ctx.lineTo(pp.x, pp.y);
9224
+ ctx.closePath();
9225
+ ctx.stroke();
9226
+ }
9227
+ }
9228
+ if (!el.locked) {
9229
+ if ("size" in el) {
9230
+ ctx.setLineDash([]);
9231
+ ctx.fillStyle = "#ffffff";
9232
+ const corners = layout.angle === 0 ? getHandlePositions(bounds) : layout.corners;
9233
+ for (const [, pos] of corners) {
9234
+ ctx.fillRect(
9235
+ pos.x - handleWorldSize / 2,
9236
+ pos.y - handleWorldSize / 2,
9237
+ handleWorldSize,
9238
+ handleWorldSize
9239
+ );
9240
+ ctx.strokeRect(
9241
+ pos.x - handleWorldSize / 2,
9242
+ pos.y - handleWorldSize / 2,
9243
+ handleWorldSize,
9244
+ handleWorldSize
9245
+ );
9246
+ }
9247
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9248
+ } else if (el.type === "template") {
9249
+ ctx.setLineDash([]);
9250
+ ctx.fillStyle = "#ffffff";
9251
+ if (el.templateShape === "rectangle") {
9252
+ if (p.selectedIds.length === 1) {
9253
+ const cos = Math.cos(el.angle);
9254
+ const sin = Math.sin(el.angle);
9255
+ const halfW = (el.width ?? 0) / 2;
9256
+ const pts = [
9257
+ [el.position.x + el.radius * cos, el.position.y + el.radius * sin],
9258
+ [
9259
+ el.position.x + el.radius / 2 * cos + halfW * -sin,
9260
+ el.position.y + el.radius / 2 * sin + halfW * cos
9261
+ ]
9262
+ ];
9263
+ for (const [hx, hy] of pts) {
9264
+ ctx.fillRect(
9265
+ hx - handleWorldSize / 2,
9266
+ hy - handleWorldSize / 2,
9267
+ handleWorldSize,
9268
+ handleWorldSize
9269
+ );
9270
+ ctx.strokeRect(
9271
+ hx - handleWorldSize / 2,
9272
+ hy - handleWorldSize / 2,
9273
+ handleWorldSize,
9274
+ handleWorldSize
9275
+ );
9276
+ }
9277
+ }
9278
+ } else {
9279
+ const hx = bounds.x + bounds.w;
9280
+ const hy = bounds.y + bounds.h;
9281
+ ctx.fillRect(
9282
+ hx - handleWorldSize / 2,
9283
+ hy - handleWorldSize / 2,
9284
+ handleWorldSize,
9285
+ handleWorldSize
9286
+ );
9287
+ ctx.strokeRect(
9288
+ hx - handleWorldSize / 2,
9289
+ hy - handleWorldSize / 2,
9290
+ handleWorldSize,
9291
+ handleWorldSize
9292
+ );
9293
+ }
9294
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9295
+ if (p.selectedIds.length === 1 && (el.templateShape === "cone" || el.templateShape === "line" || el.templateShape === "rectangle")) {
9296
+ const aim = templateAimKnob(el, zoom);
9297
+ if (aim) {
9298
+ ctx.beginPath();
9299
+ ctx.moveTo(aim.origin.x, aim.origin.y);
9300
+ ctx.lineTo(aim.knob.x, aim.knob.y);
9301
+ ctx.stroke();
9302
+ ctx.setLineDash([]);
9303
+ ctx.fillStyle = "#ffffff";
9304
+ ctx.beginPath();
9305
+ ctx.arc(aim.knob.x, aim.knob.y, handleWorldSize / 2, 0, Math.PI * 2);
9306
+ ctx.fill();
9307
+ ctx.stroke();
9308
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9309
+ }
9310
+ }
9311
+ }
9312
+ if (p.selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
9313
+ const stemStart = topMidpoint(layout);
9314
+ const stemEnd = layout.rotateHandle;
9315
+ ctx.beginPath();
9316
+ ctx.moveTo(stemStart.x, stemStart.y);
9317
+ ctx.lineTo(stemEnd.x, stemEnd.y);
9318
+ ctx.stroke();
9319
+ ctx.setLineDash([]);
9320
+ ctx.fillStyle = "#ffffff";
9321
+ ctx.beginPath();
9322
+ ctx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
9323
+ ctx.fill();
9324
+ ctx.stroke();
9325
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
9326
+ }
9327
+ }
9328
+ if (el.locked) {
9329
+ const ne = layout.corners.find(([h]) => h === "ne")?.[1];
9330
+ if (ne) drawLockBadge(ctx, ne, zoom);
9331
+ }
9332
+ }
9333
+ ctx.restore();
9334
+ }
9335
+ function renderGuideLines(ctx, p) {
9336
+ const zoom = p.zoom;
9337
+ const rect = p.rect;
9338
+ ctx.save();
9339
+ ctx.strokeStyle = "#FF4081";
9340
+ ctx.lineWidth = 1 / zoom;
9341
+ ctx.setLineDash([]);
9342
+ for (const g of p.guides) {
9343
+ ctx.beginPath();
9344
+ if (g.axis === "x") {
9345
+ const y0 = rect ? rect.y : p.currentWorld.y - 1e5;
9346
+ const y1 = rect ? rect.y + rect.h : p.currentWorld.y + 1e5;
9347
+ ctx.moveTo(g.position, y0);
9348
+ ctx.lineTo(g.position, y1);
9349
+ } else {
9350
+ const x0 = rect ? rect.x : p.currentWorld.x - 1e5;
9351
+ const x1 = rect ? rect.x + rect.w : p.currentWorld.x + 1e5;
9352
+ ctx.moveTo(x0, g.position);
9353
+ ctx.lineTo(x1, g.position);
9354
+ }
9355
+ ctx.stroke();
9356
+ }
9357
+ ctx.restore();
9358
+ }
9359
+
9360
+ // src/tools/select-hit.ts
9361
+ function hitTest(world, ctx) {
9362
+ const r = 10;
9363
+ const candidates = ctx.store.queryRect({ x: world.x - r, y: world.y - r, w: r * 2, h: r * 2 }).reverse();
9364
+ for (const el of candidates) {
9365
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
9366
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
9367
+ if (el.type === "grid") continue;
9368
+ if (isInsideBounds(world, el)) return el;
9369
+ }
9370
+ return null;
9371
+ }
9372
+ function isInsideBounds(point, el) {
9373
+ if (el.type === "grid") return false;
9374
+ const angle = el.rotation ?? 0;
9375
+ if (angle !== 0) {
9376
+ const b = getElementBounds(el);
9377
+ if (b) {
9378
+ point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
9379
+ }
9380
+ }
9381
+ if (el.type === "shape" && el.shape === "line") {
9382
+ const [a, b] = lineEndpoints(el);
9383
+ const threshold = Math.max(el.strokeWidth / 2, 6);
9384
+ return distSqToSegment(point, a, b) <= threshold * threshold;
9385
+ }
9386
+ if ("size" in el) {
9387
+ const s = el.size;
9388
+ 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;
9389
+ }
9390
+ if (el.type === "stroke") {
9391
+ return hitTestStroke(el, point, 10);
9392
+ }
9393
+ if (el.type === "arrow") {
9394
+ return isNearBezier(point, el.from, el.to, el.bend, 10);
9395
+ }
9396
+ if (el.type === "template") {
9397
+ const bounds = getElementBounds(el);
9398
+ if (!bounds) return false;
9399
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
9400
+ }
9401
+ return false;
9402
+ }
9403
+ function hitTestResizeHandle(world, ctx, selectedIds) {
9404
+ if (selectedIds.length === 0) return null;
9405
+ const zoom = ctx.camera.zoom;
9406
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9407
+ for (const id of selectedIds) {
9408
+ const el = ctx.store.getById(id);
9409
+ if (!el || !("size" in el)) continue;
9410
+ if (el.locked) continue;
9411
+ if (el.type === "shape" && el.shape === "line") continue;
9412
+ const layout = getOverlayLayout(el, zoom);
9413
+ if (!layout) continue;
9414
+ for (const [handle, pos] of layout.corners) {
9415
+ if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
9416
+ return { elementId: id, handle };
9417
+ }
9418
+ }
9419
+ }
9420
+ return null;
9421
+ }
9422
+ function hitTestRotateHandle(world, ctx, selectedIds) {
9423
+ if (selectedIds.length !== 1) return null;
9424
+ const id = selectedIds[0];
9425
+ if (!id) return null;
9426
+ const el = ctx.store.getById(id);
9427
+ if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
9428
+ const layout = getOverlayLayout(el, ctx.camera.zoom);
9429
+ if (!layout) return null;
9430
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
9431
+ const dx = world.x - layout.rotateHandle.x;
9432
+ const dy = world.y - layout.rotateHandle.y;
9433
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9434
+ }
9435
+ function hitTestLineHandles(world, ctx, selectedIds) {
9436
+ if (selectedIds.length === 0) return null;
9437
+ const zoom = ctx.camera.zoom;
9438
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9439
+ const r2 = r * r;
9440
+ for (const id of selectedIds) {
9441
+ const el = ctx.store.getById(id);
9442
+ if (!el || el.type !== "shape" || el.shape !== "line" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
9443
+ continue;
9444
+ const [a, b] = lineEndpoints(el);
9445
+ if ((world.x - a.x) ** 2 + (world.y - a.y) ** 2 <= r2) return { elementId: id, fixed: b };
9446
+ if ((world.x - b.x) ** 2 + (world.y - b.y) ** 2 <= r2) return { elementId: id, fixed: a };
9447
+ }
9448
+ return null;
9449
+ }
9450
+ function hitTestTemplateResizeHandle(world, ctx, selectedIds) {
9451
+ if (selectedIds.length === 0) return null;
9452
+ const zoom = ctx.camera.zoom;
9453
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9454
+ for (const id of selectedIds) {
9455
+ const el = ctx.store.getById(id);
9456
+ if (!el || el.type !== "template") continue;
9457
+ if (el.templateShape === "rectangle") continue;
9458
+ const bounds = getElementBounds(el);
9459
+ if (!bounds) continue;
9460
+ const hx = bounds.x + bounds.w;
9461
+ const hy = bounds.y + bounds.h;
9462
+ if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
9463
+ return id;
9464
+ }
9465
+ }
9466
+ return null;
9467
+ }
9468
+ function hitTestTemplateAimHandle(world, ctx, selectedIds) {
9469
+ if (selectedIds.length !== 1) return null;
9470
+ const id = selectedIds[0];
9471
+ if (!id) return null;
9472
+ const el = ctx.store.getById(id);
9473
+ if (!el || el.locked) return null;
9474
+ const knob = templateAimKnob(el, ctx.camera.zoom);
9475
+ if (!knob) return null;
9476
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
9477
+ const dx = world.x - knob.knob.x;
9478
+ const dy = world.y - knob.knob.y;
9479
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9480
+ }
9481
+ function hitTestRectangleLengthHandle(world, ctx, selectedIds) {
9482
+ if (selectedIds.length !== 1) return null;
9483
+ const id = selectedIds[0];
9484
+ if (!id) return null;
9485
+ const el = ctx.store.getById(id);
9486
+ if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
9487
+ const zoom = ctx.camera.zoom;
9488
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9489
+ const hx = el.position.x + el.radius * Math.cos(el.angle);
9490
+ const hy = el.position.y + el.radius * Math.sin(el.angle);
9491
+ const dx = world.x - hx;
9492
+ const dy = world.y - hy;
9493
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9494
+ }
9495
+ function hitTestRectangleWidthHandle(world, ctx, selectedIds) {
9496
+ if (selectedIds.length !== 1) return null;
9497
+ const id = selectedIds[0];
9498
+ if (!id) return null;
9499
+ const el = ctx.store.getById(id);
9500
+ if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
9501
+ const zoom = ctx.camera.zoom;
9502
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
9503
+ const cos = Math.cos(el.angle);
9504
+ const sin = Math.sin(el.angle);
9505
+ const halfW = (el.width ?? 0) / 2;
9506
+ const hx = el.position.x + el.radius / 2 * cos + halfW * -sin;
9507
+ const hy = el.position.y + el.radius / 2 * sin + halfW * cos;
9508
+ const dx = world.x - hx;
9509
+ const dy = world.y - hy;
9510
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
9511
+ }
9512
+ function findElementsInRect(marquee, ctx) {
9513
+ const candidates = ctx.store.queryRect(marquee);
9514
+ const ids = [];
9515
+ for (const el of candidates) {
9516
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
9517
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
9518
+ if (el.type === "grid") continue;
9519
+ const bounds = getElementBounds(el);
9520
+ if (bounds && rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
9521
+ ids.push(el.id);
9522
+ }
9523
+ }
9524
+ return ids;
9525
+ }
9526
+ function rectsOverlap(a, b) {
9527
+ 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;
9528
+ }
9529
+
9530
+ // src/canvas/element-activation.ts
9531
+ var DEFAULT_ACTIVATION_SLOP_PX = 8;
9532
+ var DEFAULT_ACTIVATION_DOUBLE_DELAY_MS = 300;
9533
+ var PASSIVE = { passive: true };
9534
+ function noop() {
9535
+ }
9536
+ var ElementActivation = class {
9537
+ deps;
9538
+ options;
9539
+ slopPx;
9540
+ doubleDelayMs;
9541
+ hitContext;
9542
+ active = null;
9543
+ pending = null;
9544
+ pendingTimer = null;
9545
+ downPointers = /* @__PURE__ */ new Set();
9546
+ cameraRevision = 0;
9547
+ disposed = false;
9548
+ listeners = /* @__PURE__ */ new Set();
9549
+ unsubCamera;
9550
+ unsubStore;
9551
+ handlePointerDown = (e) => this.onPointerDown(e);
9552
+ handlePointerMove = (e) => this.onPointerMove(e);
9553
+ handlePointerUp = (e) => this.onPointerUp(e);
9554
+ handlePointerCancel = (e) => this.onPointerCancel(e);
9555
+ handleWindowPointerEnd = (e) => this.onWindowPointerEnd(e);
9556
+ handleInterrupt = () => this.reset();
9557
+ constructor(deps, options) {
9558
+ this.slopPx = options.slopPx ?? DEFAULT_ACTIVATION_SLOP_PX;
9559
+ this.doubleDelayMs = options.doubleDelayMs ?? DEFAULT_ACTIVATION_DOUBLE_DELAY_MS;
9560
+ if (!Number.isFinite(this.slopPx) || this.slopPx < 0) {
9561
+ throw new RangeError(`[fieldnotes] activation slopPx must be finite and >= 0`);
9562
+ }
9563
+ if (!Number.isFinite(this.doubleDelayMs) || this.doubleDelayMs <= 0) {
9564
+ throw new RangeError(`[fieldnotes] activation doubleDelayMs must be finite and > 0`);
9565
+ }
9566
+ this.deps = deps;
9567
+ this.options = options;
9568
+ this.hitContext = {
9569
+ camera: deps.camera,
9570
+ store: deps.store,
9571
+ requestRender: noop,
9572
+ isLayerVisible: deps.isLayerVisible
9573
+ };
9574
+ this.unsubCamera = deps.camera.onChange(() => {
9575
+ this.cameraRevision += 1;
9576
+ });
9577
+ this.unsubStore = [
9578
+ deps.store.on("remove", (el) => this.forgetElement(el.id)),
9579
+ deps.store.on("clear", () => this.reset())
9580
+ ];
9581
+ deps.element.addEventListener("pointerdown", this.handlePointerDown, PASSIVE);
9582
+ deps.element.addEventListener("pointermove", this.handlePointerMove, PASSIVE);
9583
+ deps.element.addEventListener("pointerup", this.handlePointerUp, PASSIVE);
9584
+ deps.element.addEventListener("pointercancel", this.handlePointerCancel, PASSIVE);
9585
+ window.addEventListener("pointerup", this.handleWindowPointerEnd, PASSIVE);
9586
+ window.addEventListener("pointercancel", this.handleWindowPointerEnd, PASSIVE);
9587
+ window.addEventListener("blur", this.handleInterrupt, PASSIVE);
9588
+ window.addEventListener("visibilitychange", this.handleInterrupt, PASSIVE);
9589
+ }
9590
+ /**
9591
+ * Subscribes to activations. Emission iterates a snapshot with a per-listener
9592
+ * try/catch, so one throwing listener cannot break its siblings and a
9593
+ * concurrent unsubscribe cannot skip one. Returns an idempotent unsubscribe.
9594
+ */
9595
+ onActivate(listener) {
9596
+ this.listeners.add(listener);
9597
+ return () => {
9598
+ this.listeners.delete(listener);
9599
+ };
9600
+ }
9601
+ /** Removes every listener, clears timers and gesture state. Idempotent. */
9602
+ dispose() {
9603
+ if (this.disposed) return;
9604
+ this.disposed = true;
9605
+ this.reset();
9606
+ this.unsubCamera();
9607
+ for (const unsub of this.unsubStore) unsub();
9608
+ this.deps.element.removeEventListener("pointerdown", this.handlePointerDown);
9609
+ this.deps.element.removeEventListener("pointermove", this.handlePointerMove);
9610
+ this.deps.element.removeEventListener("pointerup", this.handlePointerUp);
9611
+ this.deps.element.removeEventListener("pointercancel", this.handlePointerCancel);
9612
+ window.removeEventListener("pointerup", this.handleWindowPointerEnd);
9613
+ window.removeEventListener("pointercancel", this.handleWindowPointerEnd);
9614
+ window.removeEventListener("blur", this.handleInterrupt);
9615
+ window.removeEventListener("visibilitychange", this.handleInterrupt);
9616
+ this.listeners.clear();
9617
+ }
9618
+ now() {
9619
+ return performance.now();
9620
+ }
9621
+ isCameraBusy() {
9622
+ return (this.deps.isCameraBusy?.() ?? false) || (this.options.isCameraBusy?.() ?? false);
9623
+ }
9624
+ toWorld(e) {
9625
+ const rect = this.deps.element.getBoundingClientRect();
9626
+ return this.deps.camera.screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top });
9627
+ }
9628
+ /**
9629
+ * Topmost visible element under `world` that activation accepts, or `null`.
9630
+ * The topmost hit wins outright: a rejected hit does not fall through to
9631
+ * whatever sits beneath it.
9632
+ */
9633
+ resolveTarget(world) {
9634
+ const hit = hitTest(world, this.hitContext);
9635
+ if (!hit) return null;
9636
+ if (hit.type === "note" || hit.type === "text") return null;
9637
+ if (hit.type === "html" && this.deps.resolveHtmlRouting(hit) !== "canvas") return null;
9638
+ if (this.options.isActivatable && !this.options.isActivatable(hit)) return null;
9639
+ return hit;
9640
+ }
9641
+ beyondSlop(from, to) {
9642
+ return Math.hypot(to.x - from.x, to.y - from.y) > this.slopPx;
9643
+ }
9644
+ onPointerDown(e) {
9645
+ const hadPointers = this.downPointers.size > 0;
9646
+ this.downPointers.add(e.pointerId);
9647
+ if (hadPointers) {
9648
+ this.clearGesture();
9649
+ return;
9650
+ }
9651
+ if (e.button !== 0) return;
9652
+ this.active = null;
9653
+ if (this.isCameraBusy()) {
9654
+ this.clearPending();
9655
+ return;
9656
+ }
9657
+ const target = this.resolveTarget(this.toWorld(e));
9658
+ if (!target) {
9659
+ this.clearPending();
9660
+ return;
9661
+ }
9662
+ this.active = {
9663
+ pointerId: e.pointerId,
9664
+ pointerType: e.pointerType,
9665
+ clientPoint: { x: e.clientX, y: e.clientY },
9666
+ elementId: target.id,
9667
+ cameraRevision: this.cameraRevision
9668
+ };
9669
+ }
9670
+ onPointerMove(e) {
9671
+ const active = this.active;
9672
+ if (!active || active.pointerId !== e.pointerId) return;
9673
+ if (this.beyondSlop(active.clientPoint, { x: e.clientX, y: e.clientY })) this.clearGesture();
9674
+ }
9675
+ onPointerUp(e) {
9676
+ this.downPointers.delete(e.pointerId);
9677
+ const active = this.active;
9678
+ if (!active || active.pointerId !== e.pointerId) return;
9679
+ this.active = null;
9680
+ if (this.beyondSlop(active.clientPoint, { x: e.clientX, y: e.clientY })) {
9681
+ this.clearPending();
9682
+ return;
9683
+ }
9684
+ if (this.cameraRevision !== active.cameraRevision || this.isCameraBusy()) {
9685
+ this.clearPending();
9686
+ return;
9687
+ }
9688
+ const world = this.toWorld(e);
9689
+ const target = this.resolveTarget(world);
9690
+ if (!target || target.id !== active.elementId) {
9691
+ this.clearPending();
9692
+ return;
9693
+ }
9694
+ if (this.options.gesture === "single") {
9695
+ this.clearPending();
9696
+ this.emit({ element: target, world, pointerType: e.pointerType, gesture: "single" });
9697
+ return;
9698
+ }
9699
+ const pending = this.pending;
9700
+ const time = this.now();
9701
+ if (pending && pending.elementId === target.id && pending.pointerType === e.pointerType && time - pending.time <= this.doubleDelayMs) {
9702
+ this.clearPending();
9703
+ this.emit({ element: target, world, pointerType: e.pointerType, gesture: "double" });
9704
+ return;
9705
+ }
9706
+ this.setPending({
9707
+ elementId: target.id,
9708
+ pointerType: e.pointerType,
9709
+ clientPoint: { x: e.clientX, y: e.clientY },
9710
+ time
9711
+ });
9712
+ }
9713
+ onPointerCancel(e) {
9714
+ this.downPointers.delete(e.pointerId);
9715
+ if (this.active && this.active.pointerId !== e.pointerId) return;
9716
+ this.clearGesture();
9717
+ }
9718
+ /**
9719
+ * A pointer pressed on the wrapper and released outside it. State-clearing
9720
+ * only: this path can never activate. Events that bubbled up from inside the
9721
+ * wrapper were already handled by the wrapper listener.
9722
+ */
9723
+ onWindowPointerEnd(e) {
9724
+ const target = e.target;
9725
+ if (target instanceof Node && this.deps.element.contains(target)) return;
9726
+ this.downPointers.delete(e.pointerId);
9727
+ if (this.active && this.active.pointerId !== e.pointerId) return;
9728
+ this.clearGesture();
9729
+ }
9730
+ forgetElement(id) {
9731
+ if (this.active?.elementId === id) this.active = null;
9732
+ if (this.pending?.elementId === id) this.clearPending();
9733
+ }
9734
+ setPending(pending) {
9735
+ this.clearPending();
9736
+ this.pending = pending;
9737
+ this.pendingTimer = setTimeout(() => this.clearPending(), this.doubleDelayMs);
9738
+ }
9739
+ clearPending() {
9740
+ this.pending = null;
9741
+ if (this.pendingTimer !== null) {
9742
+ clearTimeout(this.pendingTimer);
9743
+ this.pendingTimer = null;
9744
+ }
9745
+ }
9746
+ /** Drops the in-flight gesture and its pending half; leaves pointer bookkeeping. */
9747
+ clearGesture() {
9748
+ this.active = null;
9749
+ this.clearPending();
9750
+ }
9751
+ /** Drops everything, including which pointers are believed to be down. */
9752
+ reset() {
9753
+ this.clearGesture();
9754
+ this.downPointers.clear();
9755
+ }
9756
+ emit(event) {
9757
+ for (const listener of [...this.listeners]) {
9758
+ try {
9759
+ listener(event);
9760
+ } catch {
9761
+ }
9762
+ }
9763
+ }
9764
+ };
9765
+
9766
+ // src/canvas/viewport.ts
9767
+ var EMPTY_IDS = [];
9768
+ function noop2() {
9769
+ }
9770
+ var Viewport = class _Viewport {
9771
+ constructor(container, options = {}) {
9772
+ this.container = container;
9773
+ this.camera = new Camera(options.camera);
9774
+ this.background = new Background(options.background);
9775
+ this._gridSize = options.background?.spacing ?? 24;
9776
+ this.store = new ElementStore();
9777
+ this.layerManager = new LayerManager(this.store);
9778
+ this.toolManager = new ToolManager();
9779
+ this.unsubToolRegister = this.toolManager.onRegister((tool) => {
9780
+ if (_Viewport.isSelectionSource(tool)) this.attachSelectionSource(tool);
9781
+ });
9782
+ const existingSelect = this.getSelectTool();
9783
+ if (existingSelect && _Viewport.isSelectionSource(existingSelect)) {
9784
+ this.attachSelectionSource(existingSelect);
9785
+ }
9786
+ this.renderer = new ElementRenderer();
9787
+ this.renderer.setStore(this.store);
9788
+ this.renderer.setCamera(this.camera);
9789
+ this.renderer.setOnImageLoad(() => {
9790
+ this.renderLoop.markAllLayersDirty();
9791
+ this.requestRender();
9792
+ });
9793
+ this.renderer.setOnImageError((src, cause) => {
9794
+ const elementIds = [];
9795
+ for (const el of this.store.getAll()) {
9796
+ if (el.type === "image" && el.src === src) elementIds.push(el.id);
9797
+ }
9798
+ if (options.onImageError) {
9799
+ options.onImageError({ src, elementIds, cause });
9800
+ } else {
9801
+ console.warn(`[fieldnotes] image failed to load: ${src}`);
9802
+ }
9803
+ });
9804
+ this.renderer.setHtmlPainters(this.htmlPainters);
9805
+ this.renderer.setDiagnosticSink((d) => {
9806
+ this.htmlDiagnostics.emit(d, {
9807
+ registryVersion: this.htmlPainters.version,
9808
+ elementVersion: this.store.getVersion(d.elementId)
9809
+ });
9810
+ });
9811
+ this.noteEditor = new NoteEditor({
9812
+ fontSizePresets: options.fontSizePresets,
9813
+ toolbar: options.toolbar,
9814
+ placeholder: options.placeholder
9815
+ });
9816
+ this.noteEditor.setOnStop((id) => this.interactions.onTextEditStop(id));
9817
+ this.noteEditor.setOnInput((id) => this.interactions.liveFitHeight(id));
9818
+ this.arrowLabelEditor = new ArrowLabelEditor();
9819
+ this.noteEditor.setHistoryHooks(
9820
+ () => this.historyRecorder.begin(),
9821
+ () => this.historyRecorder.commit()
9822
+ );
9823
+ this.onHtmlElementMount = options.onHtmlElementMount;
9824
+ this.dropHandler = options.onDrop;
9825
+ this.history = new HistoryStack();
8353
9826
  this.historyRecorder = new HistoryRecorder(this.store, this.history, this.layerManager);
8354
9827
  this.unsubRecorderEnd = this.historyRecorder.onTransactionEnd(() => {
8355
9828
  if (!this.pendingSelectionPrune) return;
@@ -8412,7 +9885,7 @@ var Viewport = class _Viewport {
8412
9885
  if (options.contextMenu !== false) {
8413
9886
  this.contextMenu = new ContextMenu({
8414
9887
  onCommand: (action) => this.runAction(action),
8415
- onClose: noop
9888
+ onClose: noop2
8416
9889
  });
8417
9890
  }
8418
9891
  this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
@@ -8449,6 +9922,7 @@ var Viewport = class _Viewport {
8449
9922
  marginViewport: this.marginViewport,
8450
9923
  hybridSurface: new HybridRenderSurface(this.paintStack)
8451
9924
  });
9925
+ this.unsubHtmlPainters = this.htmlPainters.onChange(() => this.onHtmlRegistryChanged());
8452
9926
  this.unsubCamera = this.camera.onChange(() => {
8453
9927
  this.applyCameraTransform();
8454
9928
  this.noteEditor.updateToolbarPosition();
@@ -8466,6 +9940,9 @@ var Viewport = class _Viewport {
8466
9940
  this.unsubStore = [
8467
9941
  this.store.on("add", (el) => {
8468
9942
  if (el.type === "grid") this.gridController.syncContext();
9943
+ if (el.type === "html") {
9944
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
9945
+ }
8469
9946
  this.renderLoop.markLayerDirty(el.layerId);
8470
9947
  this.requestRender();
8471
9948
  }),
@@ -8473,12 +9950,16 @@ var Viewport = class _Viewport {
8473
9950
  if (el.type === "grid") this.gridController.syncContext();
8474
9951
  this.unbindArrowsFrom(el);
8475
9952
  this.domNodeManager.removeDomNode(el.id);
9953
+ this.htmlDiagnostics.forget(el.id);
8476
9954
  this.renderLoop.markLayerDirty(el.layerId);
8477
9955
  this.requestRender();
8478
9956
  this.handleRemovedElement(el.id);
8479
9957
  }),
8480
9958
  this.store.on("update", ({ previous, current }) => {
8481
9959
  if (current.type === "grid") this.gridController.syncContext();
9960
+ if (current.type === "html") {
9961
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
9962
+ }
8482
9963
  this.renderLoop.markLayerDirty(current.layerId);
8483
9964
  if (previous.layerId !== current.layerId) {
8484
9965
  this.renderLoop.markLayerDirty(previous.layerId);
@@ -8487,6 +9968,7 @@ var Viewport = class _Viewport {
8487
9968
  }),
8488
9969
  this.store.on("clear", () => {
8489
9970
  this.domNodeManager.clearDomNodes();
9971
+ this.htmlDiagnostics.reset();
8490
9972
  this.renderLoop.markAllLayersDirty();
8491
9973
  this.gridController.syncContext();
8492
9974
  this.requestRender();
@@ -8559,6 +10041,21 @@ var Viewport = class _Viewport {
8559
10041
  contextMenu = null;
8560
10042
  minimap = null;
8561
10043
  htmlRenderers = /* @__PURE__ */ new Map();
10044
+ htmlPainters = new HtmlPainterRegistry();
10045
+ htmlDiagnosticListeners = /* @__PURE__ */ new Set();
10046
+ htmlDiagnostics = new HtmlPaintDiagnosticDeduper((d) => {
10047
+ for (const listener of [...this.htmlDiagnosticListeners]) {
10048
+ try {
10049
+ listener(d);
10050
+ } catch {
10051
+ }
10052
+ }
10053
+ });
10054
+ resolveRouting = (el) => resolveHtmlRouting(el, this.htmlPainters);
10055
+ unsubHtmlPainters;
10056
+ activation = null;
10057
+ activationGeneration = 0;
10058
+ activationListeners = /* @__PURE__ */ new Set();
8562
10059
  resizeListeners = /* @__PURE__ */ new Set();
8563
10060
  selectionListeners = /* @__PURE__ */ new Set();
8564
10061
  detachSelectionSource = null;
@@ -8648,11 +10145,26 @@ var Viewport = class _Viewport {
8648
10145
  exportJSON() {
8649
10146
  return JSON.stringify(this.exportState());
8650
10147
  }
10148
+ /**
10149
+ * Injects this viewport's own html painter registry into export options so a host
10150
+ * that registered painters via `registerHtmlPainter`/`expectCanvasHtmlTypes` gets
10151
+ * markers in exports without passing anything. An explicitly passed `htmlPainters`
10152
+ * REPLACES the viewport's registry rather than merging with it. `expectedCanvasTypes`
10153
+ * is always UNIONED with the resolved registry's own declarations — a caller's set
10154
+ * can only add expectations, never shrink the registry's own.
10155
+ */
10156
+ withHtmlDefaults(options) {
10157
+ const base = options ?? {};
10158
+ const registry = base.htmlPainters ?? this.htmlPainters;
10159
+ const declared = registry.canvasTypes;
10160
+ const expected = base.expectedCanvasTypes ? /* @__PURE__ */ new Set([...declared, ...base.expectedCanvasTypes]) : declared;
10161
+ return { ...base, htmlPainters: registry, expectedCanvasTypes: expected };
10162
+ }
8651
10163
  async exportImage(options) {
8652
- return exportImage(this.store, options, this.layerManager);
10164
+ return exportImage(this.store, this.withHtmlDefaults(options), this.layerManager);
8653
10165
  }
8654
10166
  async exportSVG(options) {
8655
- return exportSvg(this.store, options, this.layerManager);
10167
+ return exportSvg(this.store, this.withHtmlDefaults(options), this.layerManager);
8656
10168
  }
8657
10169
  loadState(state) {
8658
10170
  this.inputHandler.flushPendingHistory();
@@ -8680,6 +10192,7 @@ var Viewport = class _Viewport {
8680
10192
  const node = this.domNodeManager.getNode(el.id);
8681
10193
  if (node) {
8682
10194
  this.onHtmlElementMount(el.id, el.domId, node);
10195
+ this.domNodeManager.markHostOwnedContent(el.id);
8683
10196
  node.dataset["initialized"] = "true";
8684
10197
  Object.assign(node.style, {
8685
10198
  overflow: "hidden",
@@ -8692,6 +10205,7 @@ var Viewport = class _Viewport {
8692
10205
  this.historyRecorder.resume();
8693
10206
  this.camera.moveTo(state.camera.position.x, state.camera.position.y);
8694
10207
  this.camera.setZoom(state.camera.zoom);
10208
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
8695
10209
  }
8696
10210
  loadJSON(json) {
8697
10211
  this.loadState(parseState(json));
@@ -8816,6 +10330,125 @@ var Viewport = class _Viewport {
8816
10330
  if (el.type !== "html") throw new Error(`Element ${id} is not an HTML element`);
8817
10331
  this.domNodeManager.resetHtmlContent(id);
8818
10332
  this.domNodeManager.storeHtmlContent(id, newContent);
10333
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
10334
+ this.requestRender();
10335
+ }
10336
+ /**
10337
+ * Declares htmlTypes that route to canvas painters even before a painter for
10338
+ * them registers, so the element renderer never treats them as DOM-backed
10339
+ * (avoiding a DOM-mount flash while a host is still loading its painter).
10340
+ * Returns an idempotent release; each `expect` call is independently reference
10341
+ * counted by the registry.
10342
+ */
10343
+ expectCanvasHtmlTypes(htmlTypes) {
10344
+ return this.htmlPainters.expect(htmlTypes);
10345
+ }
10346
+ /**
10347
+ * Direct access to the viewport's live html-painter registry — the same
10348
+ * instance the viewport itself uses to route canvas-backed html elements.
10349
+ * Beyond `register`/`expectCanvasHtmlTypes` (already exposed above), this
10350
+ * hands out `getActivePainter`, `canvasTypes`, `onChange`, and `version`,
10351
+ * so a surface such as the minimap that needs to read routing state or
10352
+ * react to registry changes can do so without the viewport re-deriving or
10353
+ * proxying each capability individually.
10354
+ */
10355
+ getHtmlPainters() {
10356
+ return this.htmlPainters;
10357
+ }
10358
+ /**
10359
+ * Registers the canvas painter for `htmlType`. Later registrations for the
10360
+ * same type shadow earlier ones (LIFO); unregistering restores the previous
10361
+ * entry. Existing elements of this type reconcile synchronously — DOM nodes
10362
+ * detach and the render loop repaints on the next frame.
10363
+ */
10364
+ registerHtmlPainter(htmlType, painter) {
10365
+ return this.htmlPainters.register(htmlType, painter);
10366
+ }
10367
+ /**
10368
+ * Subscribes to diagnostics emitted while painting canvas-routed html
10369
+ * elements (missing painter, painter threw, degenerate size). Deduped per
10370
+ * element/target/kind against the current registry and element versions, so
10371
+ * a fail -> repair -> fail-again sequence reports twice rather than being
10372
+ * suppressed forever. Returns an idempotent unsubscribe.
10373
+ */
10374
+ onHtmlPaintDiagnostic(listener) {
10375
+ this.htmlDiagnosticListeners.add(listener);
10376
+ return () => this.htmlDiagnosticListeners.delete(listener);
10377
+ }
10378
+ /**
10379
+ * Enables (or replaces, or with `null` disables) pointer activation of
10380
+ * canvas-painted elements — the bridge for elements that are drawn rather than
10381
+ * mounted and so cannot receive DOM events. **Default off**, so every existing
10382
+ * consumer behaves identically.
10383
+ *
10384
+ * The controller is a passive observer: listeners are `{ passive: true }` and
10385
+ * it never calls `preventDefault`, `stopPropagation`, or takes pointer capture.
10386
+ * Changing or disabling activation resets all active and pending gestures.
10387
+ * Throws `RangeError` for a non-finite/negative `slopPx` or a non-positive
10388
+ * `doubleDelayMs`, leaving any existing activation untouched.
10389
+ *
10390
+ * The returned disposer clears **only its own generation**, so a stale
10391
+ * Strict-Mode cleanup cannot tear down a newer registration.
10392
+ */
10393
+ setActivation(options) {
10394
+ const next = options ? new ElementActivation(
10395
+ {
10396
+ element: this.wrapper,
10397
+ camera: this.camera,
10398
+ store: this.store,
10399
+ resolveHtmlRouting: this.resolveRouting,
10400
+ isLayerVisible: (layerId) => this.layerManager.isLayerVisible(layerId),
10401
+ // Owner-side busy signal: the camera is gliding under pan inertia, or
10402
+ // this very gesture is the one that stopped the glide. Suppressing
10403
+ // both is what keeps "tap to stop a flick" from activating whatever
10404
+ // sits under the finger.
10405
+ isCameraBusy: () => this.inputHandler.isCameraCoasting()
10406
+ },
10407
+ options
10408
+ ) : null;
10409
+ this.activation?.dispose();
10410
+ this.activation = next;
10411
+ next?.onActivate((e) => this.emitActivation(e));
10412
+ const generation = ++this.activationGeneration;
10413
+ return () => {
10414
+ if (this.activationGeneration !== generation) return;
10415
+ this.activation?.dispose();
10416
+ this.activation = null;
10417
+ };
10418
+ }
10419
+ /**
10420
+ * Subscribes to element activations. Persistent and independent of
10421
+ * `setActivation`: subscribing before activation is enabled, or across a
10422
+ * replacement, keeps working. Emission iterates a snapshot with per-listener
10423
+ * try/catch. Returns an idempotent unsubscribe.
10424
+ */
10425
+ onElementActivate(listener) {
10426
+ this.activationListeners.add(listener);
10427
+ return () => {
10428
+ this.activationListeners.delete(listener);
10429
+ };
10430
+ }
10431
+ emitActivation(event) {
10432
+ for (const listener of [...this.activationListeners]) {
10433
+ try {
10434
+ listener(event);
10435
+ } catch {
10436
+ }
10437
+ }
10438
+ }
10439
+ /**
10440
+ * Fires whenever the html painter registry's active-painter set changes
10441
+ * (declare, register, or their release). Reconciliation is synchronous —
10442
+ * routing flips (and any DOM detach/remount) happen before this returns —
10443
+ * while the actual repaint of newly canvas-routed elements is deferred to
10444
+ * the next render frame via markAllLayersDirty + requestRender. Does NOT
10445
+ * touch a minimap: Viewport does not own a MinimapController, and the
10446
+ * built-in wrapper / React <Minimap /> each subscribe to the registry
10447
+ * directly.
10448
+ */
10449
+ onHtmlRegistryChanged() {
10450
+ this.domNodeManager.reconcileHtmlRouting(this.store, this.resolveRouting);
10451
+ this.renderLoop.markAllLayersDirty();
8819
10452
  this.requestRender();
8820
10453
  }
8821
10454
  addGrid(input) {
@@ -8978,6 +10611,11 @@ var Viewport = class _Viewport {
8978
10611
  this.unsubToolChange();
8979
10612
  this.unsubToolRegister();
8980
10613
  this.unsubRecorderEnd();
10614
+ this.unsubHtmlPainters();
10615
+ this.activation?.dispose();
10616
+ this.activation = null;
10617
+ this.activationListeners.clear();
10618
+ this.htmlDiagnosticListeners.clear();
8981
10619
  this.detachSelectionSource?.();
8982
10620
  this.detachSelectionSource = null;
8983
10621
  this.selectionListeners.clear();
@@ -9673,1440 +11311,827 @@ var PingInput = class {
9673
11311
  if (this.disposed) return false;
9674
11312
  return this.emit(world);
9675
11313
  }
9676
- /** Removes all DOM listeners and cancels any pending press. Idempotent. */
9677
- dispose() {
9678
- if (this.disposed) return;
9679
- this.disposed = true;
9680
- this.cancelPress();
9681
- this.downPointers.clear();
9682
- this.element.removeEventListener("pointerdown", this.handlePointerDown);
9683
- this.element.removeEventListener("pointermove", this.handlePointerMove);
9684
- this.element.removeEventListener("pointerup", this.handlePointerUp);
9685
- this.element.removeEventListener("pointercancel", this.handlePointerCancel);
9686
- this.element.removeEventListener("pointerleave", this.handlePointerLeave);
9687
- this.optionListeners.clear();
9688
- this.pingListeners.clear();
9689
- }
9690
- toLocal(e) {
9691
- const rect = this.element.getBoundingClientRect();
9692
- return { x: e.clientX - rect.left, y: e.clientY - rect.top };
9693
- }
9694
- onPointerDown(e) {
9695
- const local = this.toLocal(e);
9696
- this.lastPointerScreen = local;
9697
- const hadPointers = this.downPointers.size > 0;
9698
- this.downPointers.add(e.pointerId);
9699
- if (hadPointers) {
9700
- this.cancelPress();
9701
- return;
9702
- }
9703
- if (!this.longPressEnabled) return;
9704
- if (e.pointerType === "mouse" && e.button !== 0) return;
9705
- this.press = {
9706
- pointerId: e.pointerId,
9707
- x: local.x,
9708
- y: local.y,
9709
- timer: setTimeout(() => this.firePress(), this.longPressMs)
9710
- };
9711
- }
9712
- onPointerMove(e) {
9713
- const local = this.toLocal(e);
9714
- this.lastPointerScreen = local;
9715
- if (this.press === null || e.pointerId !== this.press.pointerId) return;
9716
- const dx = local.x - this.press.x;
9717
- const dy = local.y - this.press.y;
9718
- if (Math.hypot(dx, dy) > this.slopPx) this.cancelPress();
9719
- }
9720
- onPointerEnd(e) {
9721
- this.downPointers.delete(e.pointerId);
9722
- if (this.press !== null && e.pointerId === this.press.pointerId) this.cancelPress();
9723
- }
9724
- cancelPress() {
9725
- if (this.press === null) return;
9726
- clearTimeout(this.press.timer);
9727
- this.press = null;
9728
- }
9729
- firePress() {
9730
- if (this.press === null) return;
9731
- const screen = { x: this.press.x, y: this.press.y };
9732
- this.press = null;
9733
- this.emit(this.host.screenToWorld(screen));
9734
- }
9735
- emit(world) {
9736
- if (this.shouldPing && !this.shouldPing()) return false;
9737
- const t = this.now();
9738
- if (t - this.lastEmitAt < this.minIntervalMs) return false;
9739
- this.lastEmitAt = t;
9740
- const emission = {
9741
- x: world.x,
9742
- y: world.y,
9743
- color: this.color,
9744
- durationMs: this.durationMs,
9745
- radius: this.radius
9746
- };
9747
- for (const listener of this.pingListeners) {
9748
- try {
9749
- listener(emission);
9750
- } catch {
9751
- }
9752
- }
9753
- return true;
9754
- }
9755
- };
9756
-
9757
- // src/canvas/camera-view.ts
9758
- function assertValidView(view) {
9759
- const { x, y, w, h } = view;
9760
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
9761
- throw new Error("[fieldnotes] CameraView requires finite x, y, w, and h");
9762
- }
9763
- if (w <= 0 || h <= 0) {
9764
- throw new Error("[fieldnotes] CameraView requires positive w and h");
9765
- }
9766
- }
9767
- function assertCanvasDims(canvasW, canvasH) {
9768
- if (!Number.isFinite(canvasW) || !Number.isFinite(canvasH)) {
9769
- throw new Error("[fieldnotes] canvas dimensions must be finite");
9770
- }
9771
- if (canvasW < 0 || canvasH < 0) {
9772
- throw new Error("[fieldnotes] canvas dimensions must not be negative");
9773
- }
9774
- }
9775
- function canvasDimsUsable(canvasW, canvasH) {
9776
- return Number.isFinite(canvasW) && Number.isFinite(canvasH) && canvasW >= 0 && canvasH >= 0;
9777
- }
9778
- function captureCameraView(viewport) {
9779
- const rect = viewport.getVisibleRect();
9780
- return { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
9781
- }
9782
- function fitZoomForView(view, canvasW, canvasH) {
9783
- assertValidView(view);
9784
- assertCanvasDims(canvasW, canvasH);
9785
- if (canvasW === 0 || canvasH === 0) {
9786
- throw new Error("[fieldnotes] fitZoomForView requires a non-zero canvas size");
9787
- }
9788
- return Math.min(canvasW / view.w, canvasH / view.h);
9789
- }
9790
- function cameraOriginForView(view, zoom, canvasW, canvasH) {
9791
- return {
9792
- x: canvasW / 2 - (view.x + view.w / 2) * zoom,
9793
- y: canvasH / 2 - (view.y + view.h / 2) * zoom
9794
- };
9795
- }
9796
- function applyCameraView(camera, view, canvasW, canvasH) {
9797
- assertValidView(view);
9798
- assertCanvasDims(canvasW, canvasH);
9799
- if (canvasW === 0 || canvasH === 0) return;
9800
- camera.setZoom(fitZoomForView(view, canvasW, canvasH));
9801
- const origin = cameraOriginForView(view, camera.zoom, canvasW, canvasH);
9802
- camera.moveTo(origin.x, origin.y);
9803
- }
9804
-
9805
- // src/canvas/camera-animator.ts
9806
- var DEFAULT_DURATION_MS3 = 400;
9807
- var FRAMED_EPSILON = 1e-6;
9808
- var easeOutCubic2 = (t) => 1 - Math.pow(1 - t, 3);
9809
- function lerp(a, b, k) {
9810
- return a + (b - a) * k;
9811
- }
9812
- function lerpView(from, to, k) {
9813
- return {
9814
- x: lerp(from.x, to.x, k),
9815
- y: lerp(from.y, to.y, k),
9816
- w: lerp(from.w, to.w, k),
9817
- h: lerp(from.h, to.h, k)
9818
- };
9819
- }
9820
- function viewsClose(a, b) {
9821
- 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;
9822
- }
9823
- var CameraAnimator = class {
9824
- camera;
9825
- getCanvasSize;
9826
- frames;
9827
- now;
9828
- durationMs;
9829
- easing;
9830
- rafId = null;
9831
- from = null;
9832
- to = null;
9833
- startedAt = 0;
9834
- endListeners = /* @__PURE__ */ new Set();
9835
- /**
9836
- * Monotonic operation counter. `animateTo`/`jumpTo` claim a generation
9837
- * before emitting 'superseded'; if an onEnd listener starts a newer
9838
- * operation during that emit, the outer call sees a bumped counter and
9839
- * bails instead of overwriting the nested animation's state. Without this,
9840
- * the nested animation would run to completion having never reported an end
9841
- * reason, breaking the exactly-one guarantee the spec makes.
9842
- */
9843
- generation = 0;
9844
- lastWrite = null;
9845
- disposed = false;
9846
- detachListeners = null;
9847
- constructor(element, camera, options) {
9848
- if (options.frames !== void 0) {
9849
- const { requestFrame, cancelFrame } = options.frames;
9850
- if (typeof requestFrame !== "function" || typeof cancelFrame !== "function") {
9851
- throw new Error(
9852
- "[fieldnotes] CameraAnimator: `frames` must supply both requestFrame and cancelFrame"
9853
- );
9854
- }
9855
- }
9856
- this.camera = camera;
9857
- this.getCanvasSize = options.getCanvasSize;
9858
- this.frames = options.frames ?? {
9859
- requestFrame: (cb) => requestAnimationFrame(cb),
9860
- cancelFrame: (id) => cancelAnimationFrame(id)
9861
- };
9862
- this.now = options.now ?? (() => performance.now());
9863
- this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
9864
- this.easing = options.easing ?? easeOutCubic2;
9865
- if (options.interactive ?? true) {
9866
- const onUserInput = () => {
9867
- this.end("cancelled");
9868
- };
9869
- const types = ["pointerdown", "wheel", "keydown"];
9870
- for (const type of types) {
9871
- element.addEventListener(type, onUserInput, { passive: true });
9872
- }
9873
- this.detachListeners = () => {
9874
- for (const type of types) {
9875
- element.removeEventListener(type, onUserInput);
9876
- }
9877
- };
9878
- }
9879
- }
9880
- get animating() {
9881
- return this.to !== null;
9882
- }
9883
- onEnd(listener) {
9884
- this.endListeners.add(listener);
9885
- return () => this.endListeners.delete(listener);
9886
- }
9887
- animateTo(view) {
9888
- const size = this.validateAndMeasure(view);
9889
- if (size === null) return;
9890
- const current = this.camera.getVisibleRect(size.w, size.h);
9891
- const generation = ++this.generation;
9892
- this.end("superseded");
9893
- if (this.generation !== generation) return;
9894
- if (viewsClose(current, view)) {
9895
- applyCameraView(this.camera, view, size.w, size.h);
9896
- this.emit("complete");
9897
- return;
9898
- }
9899
- this.from = current;
9900
- this.to = view;
9901
- this.startedAt = this.now();
9902
- this.lastWrite = null;
9903
- this.rafId = this.frames.requestFrame(this.step);
9904
- }
9905
- jumpTo(view) {
9906
- const size = this.validateAndMeasure(view);
9907
- if (size === null) return;
9908
- const generation = ++this.generation;
9909
- this.end("superseded");
9910
- if (this.generation !== generation) return;
9911
- applyCameraView(this.camera, view, size.w, size.h);
9912
- this.lastWrite = null;
9913
- }
9914
- cancel() {
9915
- if (this.disposed) return;
9916
- this.end("cancelled");
9917
- }
9918
- /**
9919
- * Terminal. Order is load-bearing: the flag is set BEFORE any listener runs,
9920
- * because an onEnd listener can call animateTo during the disposal callback.
9921
- * With the flag set last, that call would start a real animation which the
9922
- * listener clear then silently discards — a second animation with no end
9923
- * reason, breaking the exactly-one guarantee.
9924
- */
11314
+ /** Removes all DOM listeners and cancels any pending press. Idempotent. */
9925
11315
  dispose() {
9926
11316
  if (this.disposed) return;
9927
11317
  this.disposed = true;
9928
- this.generation++;
9929
- const wasAnimating = this.to !== null;
9930
- this.clearFrame();
9931
- this.from = null;
9932
- this.to = null;
9933
- this.lastWrite = null;
9934
- if (wasAnimating) this.emit("cancelled");
9935
- this.endListeners.clear();
9936
- this.detachListeners?.();
9937
- this.detachListeners = null;
11318
+ this.cancelPress();
11319
+ this.downPointers.clear();
11320
+ this.element.removeEventListener("pointerdown", this.handlePointerDown);
11321
+ this.element.removeEventListener("pointermove", this.handlePointerMove);
11322
+ this.element.removeEventListener("pointerup", this.handlePointerUp);
11323
+ this.element.removeEventListener("pointercancel", this.handlePointerCancel);
11324
+ this.element.removeEventListener("pointerleave", this.handlePointerLeave);
11325
+ this.optionListeners.clear();
11326
+ this.pingListeners.clear();
9938
11327
  }
9939
- /**
9940
- * Steps 1-3 of the public-call contract. Returns null when the caller must
9941
- * stop, having already handled termination.
9942
- *
9943
- * The disposed check precedes validation deliberately: ordering it after
9944
- * would make `disposed.animateTo(invalidView)` both required to throw and
9945
- * required to stay silent. Disposal wins — a terminal animator is inert for
9946
- * every input, and post-disposal calls are exactly the racy teardown paths
9947
- * where a throw is least useful.
9948
- */
9949
- validateAndMeasure(view) {
9950
- if (this.disposed) return null;
9951
- assertValidView(view);
9952
- const size = this.getCanvasSize();
9953
- assertCanvasDims(size.w, size.h);
9954
- if (size.w === 0 || size.h === 0) {
9955
- this.end("cancelled");
9956
- return null;
9957
- }
9958
- return size;
11328
+ toLocal(e) {
11329
+ const rect = this.element.getBoundingClientRect();
11330
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
9959
11331
  }
9960
- step = () => {
9961
- if (this.disposed || this.to === null || this.from === null) return;
9962
- if (this.foreignWrite()) {
9963
- this.end("cancelled");
9964
- return;
9965
- }
9966
- const size = this.getCanvasSize();
9967
- if (!canvasDimsUsable(size.w, size.h)) {
9968
- this.end("cancelled");
9969
- return;
9970
- }
9971
- if (size.w === 0 || size.h === 0) {
9972
- this.end("cancelled");
9973
- return;
9974
- }
9975
- const elapsed = this.now() - this.startedAt;
9976
- const t = this.durationMs <= 0 ? 1 : Math.min(1, elapsed / this.durationMs);
9977
- const view = lerpView(this.from, this.to, this.easing(t));
9978
- applyCameraView(this.camera, view, size.w, size.h);
9979
- this.recordWrite();
9980
- if (t >= 1) {
9981
- this.end("complete");
11332
+ onPointerDown(e) {
11333
+ const local = this.toLocal(e);
11334
+ this.lastPointerScreen = local;
11335
+ const hadPointers = this.downPointers.size > 0;
11336
+ this.downPointers.add(e.pointerId);
11337
+ if (hadPointers) {
11338
+ this.cancelPress();
9982
11339
  return;
9983
11340
  }
9984
- this.rafId = this.frames.requestFrame(this.step);
9985
- };
9986
- recordWrite() {
9987
- this.lastWrite = {
9988
- x: this.camera.position.x,
9989
- y: this.camera.position.y,
9990
- zoom: this.camera.zoom
11341
+ if (!this.longPressEnabled) return;
11342
+ if (e.pointerType === "mouse" && e.button !== 0) return;
11343
+ this.press = {
11344
+ pointerId: e.pointerId,
11345
+ x: local.x,
11346
+ y: local.y,
11347
+ timer: setTimeout(() => this.firePress(), this.longPressMs)
9991
11348
  };
9992
11349
  }
9993
- foreignWrite() {
9994
- if (this.lastWrite === null) return false;
9995
- const eps = 1e-6;
9996
- 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;
11350
+ onPointerMove(e) {
11351
+ const local = this.toLocal(e);
11352
+ this.lastPointerScreen = local;
11353
+ if (this.press === null || e.pointerId !== this.press.pointerId) return;
11354
+ const dx = local.x - this.press.x;
11355
+ const dy = local.y - this.press.y;
11356
+ if (Math.hypot(dx, dy) > this.slopPx) this.cancelPress();
9997
11357
  }
9998
- /** Terminates an in-flight animation with `reason`. No-op when idle. */
9999
- end(reason) {
10000
- if (this.disposed || this.to === null) return;
10001
- this.clearFrame();
10002
- this.from = null;
10003
- this.to = null;
10004
- this.lastWrite = null;
10005
- this.emit(reason);
11358
+ onPointerEnd(e) {
11359
+ this.downPointers.delete(e.pointerId);
11360
+ if (this.press !== null && e.pointerId === this.press.pointerId) this.cancelPress();
10006
11361
  }
10007
- clearFrame() {
10008
- if (this.rafId !== null) {
10009
- this.frames.cancelFrame(this.rafId);
10010
- this.rafId = null;
10011
- }
11362
+ cancelPress() {
11363
+ if (this.press === null) return;
11364
+ clearTimeout(this.press.timer);
11365
+ this.press = null;
10012
11366
  }
10013
- emit(reason) {
10014
- for (const listener of [...this.endListeners]) {
11367
+ firePress() {
11368
+ if (this.press === null) return;
11369
+ const screen = { x: this.press.x, y: this.press.y };
11370
+ this.press = null;
11371
+ this.emit(this.host.screenToWorld(screen));
11372
+ }
11373
+ emit(world) {
11374
+ if (this.shouldPing && !this.shouldPing()) return false;
11375
+ const t = this.now();
11376
+ if (t - this.lastEmitAt < this.minIntervalMs) return false;
11377
+ this.lastEmitAt = t;
11378
+ const emission = {
11379
+ x: world.x,
11380
+ y: world.y,
11381
+ color: this.color,
11382
+ durationMs: this.durationMs,
11383
+ radius: this.radius
11384
+ };
11385
+ for (const listener of this.pingListeners) {
10015
11386
  try {
10016
- listener(reason);
11387
+ listener(emission);
10017
11388
  } catch {
10018
11389
  }
10019
11390
  }
10020
- }
10021
- };
10022
-
10023
- // src/canvas/focus-presence.ts
10024
- var FOCUS_PRESENCE_KIND = "focus";
10025
- var AUDIENCES = ["all", "players", "display"];
10026
- function isPositiveFinite(value) {
10027
- return typeof value === "number" && Number.isFinite(value) && value > 0;
10028
- }
10029
- function isFiniteNumber2(value) {
10030
- return typeof value === "number" && Number.isFinite(value);
10031
- }
10032
- function isFocusPresence(data) {
10033
- if (typeof data !== "object" || data === null) return false;
10034
- const payload = data;
10035
- if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
10036
- if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
10037
- if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
10038
- if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
10039
- return false;
10040
- }
10041
- if (payload.color !== void 0 && typeof payload.color !== "string") return false;
10042
- return true;
10043
- }
10044
- function toFocusPresence(view, audience, color) {
10045
- return {
10046
- kind: FOCUS_PRESENCE_KIND,
10047
- x: view.x,
10048
- y: view.y,
10049
- w: view.w,
10050
- h: view.h,
10051
- audience,
10052
- ...color === void 0 ? {} : { color }
10053
- };
10054
- }
10055
-
10056
- // src/canvas/remote-focus-receiver.ts
10057
- function audienceIncludes(audience, role) {
10058
- if (role === "dm") return false;
10059
- if (audience === "all") return true;
10060
- if (audience === "players") return role === "player";
10061
- return role === "display";
10062
- }
10063
- var RemoteFocusReceiver = class {
10064
- role;
10065
- animator;
10066
- animate;
10067
- pulseColor;
10068
- overlay;
10069
- disposed = false;
10070
- constructor(host, options) {
10071
- this.role = options.role;
10072
- this.animator = options.animator;
10073
- this.animate = options.animate ?? true;
10074
- this.pulseColor = options.pulseColor;
10075
- this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
10076
- ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
10077
- ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
10078
- ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
10079
- maxPingsPerSender: 1
10080
- }) : null;
10081
- }
10082
- /**
10083
- * Applies a presence payload from `sender`. Returns `false` for payloads
10084
- * that are not focus frames, or are addressed to a different role, so hosts
10085
- * can feed every presence frame through without disturbing other handlers.
10086
- */
10087
- apply(from, data) {
10088
- if (this.disposed || !isFocusPresence(data)) return false;
10089
- if (!audienceIncludes(data.audience, this.role)) return false;
10090
- const view = { x: data.x, y: data.y, w: data.w, h: data.h };
10091
- if (this.animate) {
10092
- this.animator.animateTo(view);
10093
- } else {
10094
- this.animator.jumpTo(view);
10095
- }
10096
- this.overlay?.apply(from, {
10097
- kind: "ping",
10098
- x: view.x + view.w / 2,
10099
- y: view.y + view.h / 2,
10100
- color: data.color ?? this.pulseColor
10101
- });
10102
11391
  return true;
10103
11392
  }
10104
- /** Idempotent. Does NOT dispose the animator — the host owns that. */
10105
- dispose() {
10106
- if (this.disposed) return;
10107
- this.disposed = true;
10108
- this.overlay?.dispose();
10109
- }
10110
11393
  };
10111
11394
 
10112
- // src/tools/hand-tool.ts
10113
- var HandTool = class {
10114
- name = "hand";
10115
- panning = false;
10116
- lastScreen = { x: 0, y: 0 };
10117
- onActivate(ctx) {
10118
- ctx.setCursor?.("grab");
10119
- }
10120
- onDeactivate(ctx) {
10121
- ctx.setCursor?.("default");
11395
+ // src/canvas/camera-view.ts
11396
+ function assertValidView(view) {
11397
+ const { x, y, w, h } = view;
11398
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
11399
+ throw new Error("[fieldnotes] CameraView requires finite x, y, w, and h");
10122
11400
  }
10123
- onPointerDown(state, ctx) {
10124
- this.panning = true;
10125
- this.lastScreen = { x: state.x, y: state.y };
10126
- ctx.setCursor?.("grabbing");
11401
+ if (w <= 0 || h <= 0) {
11402
+ throw new Error("[fieldnotes] CameraView requires positive w and h");
10127
11403
  }
10128
- onPointerMove(state, ctx) {
10129
- if (!this.panning) return;
10130
- const dx = state.x - this.lastScreen.x;
10131
- const dy = state.y - this.lastScreen.y;
10132
- this.lastScreen = { x: state.x, y: state.y };
10133
- ctx.camera.pan(dx, dy);
11404
+ }
11405
+ function assertCanvasDims(canvasW, canvasH) {
11406
+ if (!Number.isFinite(canvasW) || !Number.isFinite(canvasH)) {
11407
+ throw new Error("[fieldnotes] canvas dimensions must be finite");
10134
11408
  }
10135
- onPointerUp(_state, ctx) {
10136
- this.panning = false;
10137
- ctx.setCursor?.("grab");
11409
+ if (canvasW < 0 || canvasH < 0) {
11410
+ throw new Error("[fieldnotes] canvas dimensions must not be negative");
10138
11411
  }
10139
- };
11412
+ }
11413
+ function canvasDimsUsable(canvasW, canvasH) {
11414
+ return Number.isFinite(canvasW) && Number.isFinite(canvasH) && canvasW >= 0 && canvasH >= 0;
11415
+ }
11416
+ function captureCameraView(viewport) {
11417
+ const rect = viewport.getVisibleRect();
11418
+ return { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
11419
+ }
11420
+ function fitZoomForView(view, canvasW, canvasH) {
11421
+ assertValidView(view);
11422
+ assertCanvasDims(canvasW, canvasH);
11423
+ if (canvasW === 0 || canvasH === 0) {
11424
+ throw new Error("[fieldnotes] fitZoomForView requires a non-zero canvas size");
11425
+ }
11426
+ return Math.min(canvasW / view.w, canvasH / view.h);
11427
+ }
11428
+ function cameraOriginForView(view, zoom, canvasW, canvasH) {
11429
+ return {
11430
+ x: canvasW / 2 - (view.x + view.w / 2) * zoom,
11431
+ y: canvasH / 2 - (view.y + view.h / 2) * zoom
11432
+ };
11433
+ }
11434
+ function applyCameraView(camera, view, canvasW, canvasH) {
11435
+ assertValidView(view);
11436
+ assertCanvasDims(canvasW, canvasH);
11437
+ if (canvasW === 0 || canvasH === 0) return;
11438
+ camera.setZoom(fitZoomForView(view, canvasW, canvasH));
11439
+ const origin = cameraOriginForView(view, camera.zoom, canvasW, canvasH);
11440
+ camera.moveTo(origin.x, origin.y);
11441
+ }
10140
11442
 
10141
- // src/tools/pencil-tool.ts
10142
- var MIN_POINTS_FOR_STROKE = 2;
10143
- var DEFAULT_SMOOTHING = 1.5;
10144
- var DEFAULT_PRESSURE = 0.5;
10145
- var DEFAULT_MIN_POINT_DISTANCE = 3;
10146
- var DEFAULT_PROGRESSIVE_THRESHOLD = 200;
10147
- var PROGRESSIVE_HOT_ZONE = 30;
10148
- var PencilTool = class {
10149
- name;
10150
- drawing = false;
10151
- points = [];
10152
- color;
10153
- width;
10154
- smoothing;
10155
- minPointDistance;
10156
- progressiveThreshold;
10157
- nextSimplifyAt;
10158
- opacity;
10159
- blendMode;
10160
- optionListeners = /* @__PURE__ */ new Set();
10161
- constructor(options = {}) {
10162
- this.name = options.name ?? "pencil";
10163
- this.color = options.color ?? "#000000";
10164
- this.width = options.width ?? 2;
10165
- this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
10166
- this.minPointDistance = options.minPointDistance ?? DEFAULT_MIN_POINT_DISTANCE;
10167
- this.progressiveThreshold = options.progressiveSimplifyThreshold ?? DEFAULT_PROGRESSIVE_THRESHOLD;
10168
- this.nextSimplifyAt = this.progressiveThreshold;
10169
- this.opacity = options.opacity ?? 1;
10170
- this.blendMode = options.blendMode;
11443
+ // src/canvas/camera-animator.ts
11444
+ var DEFAULT_DURATION_MS3 = 400;
11445
+ var FRAMED_EPSILON = 1e-6;
11446
+ var easeOutCubic2 = (t) => 1 - Math.pow(1 - t, 3);
11447
+ function lerp(a, b, k) {
11448
+ return a + (b - a) * k;
11449
+ }
11450
+ function lerpView(from, to, k) {
11451
+ return {
11452
+ x: lerp(from.x, to.x, k),
11453
+ y: lerp(from.y, to.y, k),
11454
+ w: lerp(from.w, to.w, k),
11455
+ h: lerp(from.h, to.h, k)
11456
+ };
11457
+ }
11458
+ function viewsClose(a, b) {
11459
+ 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;
11460
+ }
11461
+ var CameraAnimator = class {
11462
+ camera;
11463
+ getCanvasSize;
11464
+ frames;
11465
+ now;
11466
+ durationMs;
11467
+ easing;
11468
+ rafId = null;
11469
+ from = null;
11470
+ to = null;
11471
+ startedAt = 0;
11472
+ endListeners = /* @__PURE__ */ new Set();
11473
+ /**
11474
+ * Monotonic operation counter. `animateTo`/`jumpTo` claim a generation
11475
+ * before emitting 'superseded'; if an onEnd listener starts a newer
11476
+ * operation during that emit, the outer call sees a bumped counter and
11477
+ * bails instead of overwriting the nested animation's state. Without this,
11478
+ * the nested animation would run to completion having never reported an end
11479
+ * reason, breaking the exactly-one guarantee the spec makes.
11480
+ */
11481
+ generation = 0;
11482
+ lastWrite = null;
11483
+ disposed = false;
11484
+ detachListeners = null;
11485
+ constructor(element, camera, options) {
11486
+ if (options.frames !== void 0) {
11487
+ const { requestFrame, cancelFrame } = options.frames;
11488
+ if (typeof requestFrame !== "function" || typeof cancelFrame !== "function") {
11489
+ throw new Error(
11490
+ "[fieldnotes] CameraAnimator: `frames` must supply both requestFrame and cancelFrame"
11491
+ );
11492
+ }
11493
+ }
11494
+ this.camera = camera;
11495
+ this.getCanvasSize = options.getCanvasSize;
11496
+ this.frames = options.frames ?? {
11497
+ requestFrame: (cb) => requestAnimationFrame(cb),
11498
+ cancelFrame: (id) => cancelAnimationFrame(id)
11499
+ };
11500
+ this.now = options.now ?? (() => performance.now());
11501
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
11502
+ this.easing = options.easing ?? easeOutCubic2;
11503
+ if (options.interactive ?? true) {
11504
+ const onUserInput = () => {
11505
+ this.end("cancelled");
11506
+ };
11507
+ const types = ["pointerdown", "wheel", "keydown"];
11508
+ for (const type of types) {
11509
+ element.addEventListener(type, onUserInput, { passive: true });
11510
+ }
11511
+ this.detachListeners = () => {
11512
+ for (const type of types) {
11513
+ element.removeEventListener(type, onUserInput);
11514
+ }
11515
+ };
11516
+ }
10171
11517
  }
10172
- onActivate(ctx) {
10173
- ctx.setCursor?.("crosshair");
11518
+ get animating() {
11519
+ return this.to !== null;
10174
11520
  }
10175
- onDeactivate(ctx) {
10176
- ctx.setCursor?.("default");
11521
+ onEnd(listener) {
11522
+ this.endListeners.add(listener);
11523
+ return () => this.endListeners.delete(listener);
10177
11524
  }
10178
- getOptions() {
10179
- return {
10180
- color: this.color,
10181
- width: this.width,
10182
- smoothing: this.smoothing,
10183
- minPointDistance: this.minPointDistance,
10184
- progressiveSimplifyThreshold: this.progressiveThreshold,
10185
- opacity: this.opacity,
10186
- blendMode: this.blendMode
10187
- };
11525
+ animateTo(view) {
11526
+ const size = this.validateAndMeasure(view);
11527
+ if (size === null) return;
11528
+ const current = this.camera.getVisibleRect(size.w, size.h);
11529
+ const generation = ++this.generation;
11530
+ this.end("superseded");
11531
+ if (this.generation !== generation) return;
11532
+ if (viewsClose(current, view)) {
11533
+ applyCameraView(this.camera, view, size.w, size.h);
11534
+ this.emit("complete");
11535
+ return;
11536
+ }
11537
+ this.from = current;
11538
+ this.to = view;
11539
+ this.startedAt = this.now();
11540
+ this.lastWrite = null;
11541
+ this.rafId = this.frames.requestFrame(this.step);
10188
11542
  }
10189
- onOptionsChange(listener) {
10190
- this.optionListeners.add(listener);
10191
- return () => this.optionListeners.delete(listener);
11543
+ jumpTo(view) {
11544
+ const size = this.validateAndMeasure(view);
11545
+ if (size === null) return;
11546
+ const generation = ++this.generation;
11547
+ this.end("superseded");
11548
+ if (this.generation !== generation) return;
11549
+ applyCameraView(this.camera, view, size.w, size.h);
11550
+ this.lastWrite = null;
10192
11551
  }
10193
- setOptions(options) {
10194
- if (options.color !== void 0) this.color = options.color;
10195
- if (options.width !== void 0) this.width = options.width;
10196
- if (options.smoothing !== void 0) this.smoothing = options.smoothing;
10197
- if (options.minPointDistance !== void 0) this.minPointDistance = options.minPointDistance;
10198
- if (options.progressiveSimplifyThreshold !== void 0)
10199
- this.progressiveThreshold = options.progressiveSimplifyThreshold;
10200
- if (options.opacity !== void 0) this.opacity = options.opacity;
10201
- if (options.blendMode !== void 0) this.blendMode = options.blendMode;
10202
- this.notifyOptionsChange();
11552
+ cancel() {
11553
+ if (this.disposed) return;
11554
+ this.end("cancelled");
10203
11555
  }
10204
- onPointerDown(state, ctx) {
10205
- this.drawing = true;
10206
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
10207
- const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
10208
- this.points = [{ x: world.x, y: world.y, pressure }];
10209
- this.nextSimplifyAt = this.progressiveThreshold;
11556
+ /**
11557
+ * Terminal. Order is load-bearing: the flag is set BEFORE any listener runs,
11558
+ * because an onEnd listener can call animateTo during the disposal callback.
11559
+ * With the flag set last, that call would start a real animation which the
11560
+ * listener clear then silently discards a second animation with no end
11561
+ * reason, breaking the exactly-one guarantee.
11562
+ */
11563
+ dispose() {
11564
+ if (this.disposed) return;
11565
+ this.disposed = true;
11566
+ this.generation++;
11567
+ const wasAnimating = this.to !== null;
11568
+ this.clearFrame();
11569
+ this.from = null;
11570
+ this.to = null;
11571
+ this.lastWrite = null;
11572
+ if (wasAnimating) this.emit("cancelled");
11573
+ this.endListeners.clear();
11574
+ this.detachListeners?.();
11575
+ this.detachListeners = null;
10210
11576
  }
10211
- onPointerMove(state, ctx) {
10212
- if (!this.drawing) return;
10213
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
10214
- const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
10215
- const last = this.points[this.points.length - 1];
10216
- if (last) {
10217
- const dx = world.x - last.x;
10218
- const dy = world.y - last.y;
10219
- if (dx * dx + dy * dy < this.minPointDistance * this.minPointDistance) return;
10220
- }
10221
- this.points.push({ x: world.x, y: world.y, pressure });
10222
- if (this.points.length > this.nextSimplifyAt) {
10223
- const hotZone = this.points.slice(-PROGRESSIVE_HOT_ZONE);
10224
- const coldZone = this.points.slice(0, -PROGRESSIVE_HOT_ZONE);
10225
- const simplified = simplifyPoints(coldZone, this.smoothing * 2);
10226
- this.points = [...simplified, ...hotZone];
10227
- this.nextSimplifyAt = this.points.length + this.progressiveThreshold;
11577
+ /**
11578
+ * Steps 1-3 of the public-call contract. Returns null when the caller must
11579
+ * stop, having already handled termination.
11580
+ *
11581
+ * The disposed check precedes validation deliberately: ordering it after
11582
+ * would make `disposed.animateTo(invalidView)` both required to throw and
11583
+ * required to stay silent. Disposal wins — a terminal animator is inert for
11584
+ * every input, and post-disposal calls are exactly the racy teardown paths
11585
+ * where a throw is least useful.
11586
+ */
11587
+ validateAndMeasure(view) {
11588
+ if (this.disposed) return null;
11589
+ assertValidView(view);
11590
+ const size = this.getCanvasSize();
11591
+ assertCanvasDims(size.w, size.h);
11592
+ if (size.w === 0 || size.h === 0) {
11593
+ this.end("cancelled");
11594
+ return null;
10228
11595
  }
10229
- ctx.requestRender();
11596
+ return size;
10230
11597
  }
10231
- onPointerUp(_state, ctx) {
10232
- if (!this.drawing) return;
10233
- this.drawing = false;
10234
- if (this.points.length < MIN_POINTS_FOR_STROKE) {
10235
- this.points = [];
11598
+ step = () => {
11599
+ if (this.disposed || this.to === null || this.from === null) return;
11600
+ if (this.foreignWrite()) {
11601
+ this.end("cancelled");
10236
11602
  return;
10237
11603
  }
10238
- const simplified = simplifyPoints(this.points, this.smoothing);
10239
- const stroke = createStroke({
10240
- points: simplified,
10241
- color: this.color,
10242
- width: this.width,
10243
- layerId: ctx.activeLayerId ?? "",
10244
- opacity: this.opacity,
10245
- blendMode: this.blendMode
10246
- });
10247
- ctx.store.add(stroke);
10248
- computeStrokeSegments(stroke);
10249
- this.points = [];
10250
- ctx.requestRender();
11604
+ const size = this.getCanvasSize();
11605
+ if (!canvasDimsUsable(size.w, size.h)) {
11606
+ this.end("cancelled");
11607
+ return;
11608
+ }
11609
+ if (size.w === 0 || size.h === 0) {
11610
+ this.end("cancelled");
11611
+ return;
11612
+ }
11613
+ const elapsed = this.now() - this.startedAt;
11614
+ const t = this.durationMs <= 0 ? 1 : Math.min(1, elapsed / this.durationMs);
11615
+ const view = lerpView(this.from, this.to, this.easing(t));
11616
+ applyCameraView(this.camera, view, size.w, size.h);
11617
+ this.recordWrite();
11618
+ if (t >= 1) {
11619
+ this.end("complete");
11620
+ return;
11621
+ }
11622
+ this.rafId = this.frames.requestFrame(this.step);
11623
+ };
11624
+ recordWrite() {
11625
+ this.lastWrite = {
11626
+ x: this.camera.position.x,
11627
+ y: this.camera.position.y,
11628
+ zoom: this.camera.zoom
11629
+ };
10251
11630
  }
10252
- notifyOptionsChange() {
10253
- for (const listener of this.optionListeners) listener();
11631
+ foreignWrite() {
11632
+ if (this.lastWrite === null) return false;
11633
+ const eps = 1e-6;
11634
+ 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;
10254
11635
  }
10255
- renderOverlay(ctx) {
10256
- if (!this.drawing || this.points.length < 2) return;
10257
- ctx.save();
10258
- ctx.strokeStyle = this.color;
10259
- ctx.lineCap = "round";
10260
- ctx.lineJoin = "round";
10261
- ctx.globalAlpha = this.blendMode ? this.opacity : 0.8;
10262
- if (this.blendMode) ctx.globalCompositeOperation = this.blendMode;
10263
- const segments = smoothToSegments(this.points);
10264
- for (const seg of segments) {
10265
- const w = (pressureToWidth(seg.start.pressure, this.width) + pressureToWidth(seg.end.pressure, this.width)) / 2;
10266
- ctx.lineWidth = w;
10267
- ctx.beginPath();
10268
- ctx.moveTo(seg.start.x, seg.start.y);
10269
- ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
10270
- ctx.stroke();
11636
+ /** Terminates an in-flight animation with `reason`. No-op when idle. */
11637
+ end(reason) {
11638
+ if (this.disposed || this.to === null) return;
11639
+ this.clearFrame();
11640
+ this.from = null;
11641
+ this.to = null;
11642
+ this.lastWrite = null;
11643
+ this.emit(reason);
11644
+ }
11645
+ clearFrame() {
11646
+ if (this.rafId !== null) {
11647
+ this.frames.cancelFrame(this.rafId);
11648
+ this.rafId = null;
11649
+ }
11650
+ }
11651
+ emit(reason) {
11652
+ for (const listener of [...this.endListeners]) {
11653
+ try {
11654
+ listener(reason);
11655
+ } catch {
11656
+ }
10271
11657
  }
10272
- ctx.restore();
10273
11658
  }
10274
11659
  };
10275
11660
 
10276
- // src/elements/stroke-hit.ts
10277
- function hitTestStroke(stroke, point, radius) {
10278
- const bounds = getElementBounds(stroke);
10279
- if (!bounds) return false;
10280
- 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) {
11661
+ // src/canvas/focus-presence.ts
11662
+ var FOCUS_PRESENCE_KIND = "focus";
11663
+ var AUDIENCES = ["all", "players", "display"];
11664
+ function isPositiveFinite(value) {
11665
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
11666
+ }
11667
+ function isFiniteNumber2(value) {
11668
+ return typeof value === "number" && Number.isFinite(value);
11669
+ }
11670
+ function isFocusPresence(data) {
11671
+ if (typeof data !== "object" || data === null) return false;
11672
+ const payload = data;
11673
+ if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
11674
+ if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
11675
+ if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
11676
+ if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
10281
11677
  return false;
10282
11678
  }
10283
- const radiusSq = radius * radius;
10284
- const local = { x: point.x - stroke.position.x, y: point.y - stroke.position.y };
10285
- const { segments } = getStrokeRenderData(stroke);
10286
- if (segments.length === 0) {
10287
- const p = stroke.points[0];
10288
- if (!p) return false;
10289
- const dx = p.x - local.x;
10290
- const dy = p.y - local.y;
10291
- return dx * dx + dy * dy <= radiusSq;
10292
- }
10293
- for (const seg of segments) {
10294
- if (distSqToSegment(local, seg.start, seg.end) <= radiusSq) return true;
10295
- }
10296
- return false;
11679
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11680
+ return true;
10297
11681
  }
10298
-
10299
- // src/elements/stroke-erase.ts
10300
- function lerp2(a, b, t) {
11682
+ function toFocusPresence(view, audience, color) {
10301
11683
  return {
10302
- x: a.x + (b.x - a.x) * t,
10303
- y: a.y + (b.y - a.y) * t,
10304
- pressure: a.pressure + (b.pressure - a.pressure) * t
10305
- };
10306
- }
10307
- function erasePoints(points, eraser, radius) {
10308
- const r2 = radius * radius;
10309
- if (points.length < 2) {
10310
- const p = points[0];
10311
- if (p && (p.x - eraser.x) ** 2 + (p.y - eraser.y) ** 2 <= r2) return [];
10312
- return null;
10313
- }
10314
- const runs = [];
10315
- let current = [];
10316
- let erased = false;
10317
- const flush = () => {
10318
- if (current.length >= 2) runs.push(current);
10319
- current = [];
11684
+ kind: FOCUS_PRESENCE_KIND,
11685
+ x: view.x,
11686
+ y: view.y,
11687
+ w: view.w,
11688
+ h: view.h,
11689
+ audience,
11690
+ ...color === void 0 ? {} : { color }
10320
11691
  };
10321
- for (let i = 0; i < points.length - 1; i++) {
10322
- const a = points[i];
10323
- const b = points[i + 1];
10324
- if (!a || !b) continue;
10325
- const dx = b.x - a.x;
10326
- const dy = b.y - a.y;
10327
- const fx = a.x - eraser.x;
10328
- const fy = a.y - eraser.y;
10329
- const A = dx * dx + dy * dy;
10330
- const B = 2 * (fx * dx + fy * dy);
10331
- const C = fx * fx + fy * fy - r2;
10332
- let tLo = 1;
10333
- let tHi = 0;
10334
- if (A === 0) {
10335
- if (C <= 0) {
10336
- tLo = 0;
10337
- tHi = 1;
10338
- }
10339
- } else {
10340
- const disc = B * B - 4 * A * C;
10341
- if (disc >= 0) {
10342
- const sq = Math.sqrt(disc);
10343
- const lo = Math.max(0, (-B - sq) / (2 * A));
10344
- const hi = Math.min(1, (-B + sq) / (2 * A));
10345
- if (lo < hi) {
10346
- tLo = lo;
10347
- tHi = hi;
10348
- }
10349
- }
10350
- }
10351
- if (tLo > tHi) {
10352
- if (current.length === 0) current.push(a);
10353
- current.push(b);
10354
- continue;
10355
- }
10356
- erased = true;
10357
- if (tLo > 0) {
10358
- if (current.length === 0) current.push(a);
10359
- current.push(lerp2(a, b, tLo));
10360
- flush();
10361
- } else {
10362
- flush();
10363
- }
10364
- if (tHi < 1) {
10365
- current = [lerp2(a, b, tHi), b];
10366
- }
10367
- }
10368
- flush();
10369
- return erased ? runs : null;
10370
11692
  }
10371
11693
 
10372
- // src/tools/eraser-tool.ts
10373
- var DEFAULT_RADIUS3 = 20;
10374
- function makeEraserCursor(radius) {
10375
- const size = radius * 2;
10376
- 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>`;
10377
- return `url("data:image/svg+xml,${svg}") ${radius} ${radius}, crosshair`;
11694
+ // src/canvas/remote-focus-receiver.ts
11695
+ function audienceIncludes(audience, role) {
11696
+ if (role === "dm") return false;
11697
+ if (audience === "all") return true;
11698
+ if (audience === "players") return role === "player";
11699
+ return role === "display";
10378
11700
  }
10379
- var EraserTool = class {
10380
- name = "eraser";
10381
- erasing = false;
10382
- radius;
10383
- cursor;
10384
- mode;
10385
- constructor(options = {}) {
10386
- this.radius = options.radius ?? DEFAULT_RADIUS3;
10387
- this.cursor = makeEraserCursor(this.radius);
10388
- this.mode = options.mode ?? "partial";
10389
- }
10390
- getOptions() {
10391
- return { radius: this.radius, mode: this.mode };
11701
+ var RemoteFocusReceiver = class {
11702
+ role;
11703
+ animator;
11704
+ animate;
11705
+ pulseColor;
11706
+ overlay;
11707
+ disposed = false;
11708
+ constructor(host, options) {
11709
+ this.role = options.role;
11710
+ this.animator = options.animator;
11711
+ this.animate = options.animate ?? true;
11712
+ this.pulseColor = options.pulseColor;
11713
+ this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
11714
+ ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
11715
+ ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
11716
+ ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
11717
+ maxPingsPerSender: 1
11718
+ }) : null;
10392
11719
  }
10393
- setOptions(options) {
10394
- if (options.mode !== void 0) this.mode = options.mode;
10395
- if (options.radius !== void 0) {
10396
- this.radius = options.radius;
10397
- this.cursor = makeEraserCursor(this.radius);
11720
+ /**
11721
+ * Applies a presence payload from `sender`. Returns `false` for payloads
11722
+ * that are not focus frames, or are addressed to a different role, so hosts
11723
+ * can feed every presence frame through without disturbing other handlers.
11724
+ */
11725
+ apply(from, data) {
11726
+ if (this.disposed || !isFocusPresence(data)) return false;
11727
+ if (!audienceIncludes(data.audience, this.role)) return false;
11728
+ const view = { x: data.x, y: data.y, w: data.w, h: data.h };
11729
+ if (this.animate) {
11730
+ this.animator.animateTo(view);
11731
+ } else {
11732
+ this.animator.jumpTo(view);
10398
11733
  }
11734
+ this.overlay?.apply(from, {
11735
+ kind: "ping",
11736
+ x: view.x + view.w / 2,
11737
+ y: view.y + view.h / 2,
11738
+ color: data.color ?? this.pulseColor
11739
+ });
11740
+ return true;
11741
+ }
11742
+ /** Idempotent. Does NOT dispose the animator — the host owns that. */
11743
+ dispose() {
11744
+ if (this.disposed) return;
11745
+ this.disposed = true;
11746
+ this.overlay?.dispose();
10399
11747
  }
11748
+ };
11749
+
11750
+ // src/tools/hand-tool.ts
11751
+ var HandTool = class {
11752
+ name = "hand";
11753
+ panning = false;
11754
+ lastScreen = { x: 0, y: 0 };
10400
11755
  onActivate(ctx) {
10401
- ctx.setCursor?.(this.cursor);
11756
+ ctx.setCursor?.("grab");
10402
11757
  }
10403
11758
  onDeactivate(ctx) {
10404
11759
  ctx.setCursor?.("default");
10405
11760
  }
10406
11761
  onPointerDown(state, ctx) {
10407
- this.erasing = true;
10408
- this.eraseAt(state, ctx);
11762
+ this.panning = true;
11763
+ this.lastScreen = { x: state.x, y: state.y };
11764
+ ctx.setCursor?.("grabbing");
10409
11765
  }
10410
11766
  onPointerMove(state, ctx) {
10411
- if (!this.erasing) return;
10412
- this.eraseAt(state, ctx);
10413
- }
10414
- onPointerUp(_state, _ctx) {
10415
- this.erasing = false;
10416
- }
10417
- eraseAt(state, ctx) {
10418
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
10419
- const worldRadius = this.radius / ctx.camera.zoom;
10420
- const queryBounds = {
10421
- x: world.x - worldRadius,
10422
- y: world.y - worldRadius,
10423
- w: worldRadius * 2,
10424
- h: worldRadius * 2
10425
- };
10426
- const candidates = ctx.store.queryRect(queryBounds);
10427
- let erased = false;
10428
- for (const el of candidates) {
10429
- if (el.type !== "stroke") continue;
10430
- if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
10431
- if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
10432
- if (!this.strokeIntersects(el, world, worldRadius)) continue;
10433
- if (this.mode === "stroke") {
10434
- ctx.store.remove(el.id);
10435
- erased = true;
10436
- continue;
10437
- }
10438
- const localEraser = { x: world.x - el.position.x, y: world.y - el.position.y };
10439
- const runs = erasePoints(el.points, localEraser, worldRadius);
10440
- if (runs === null) continue;
10441
- ctx.store.remove(el.id);
10442
- for (const run of runs) {
10443
- ctx.store.add(
10444
- createStroke({
10445
- points: run,
10446
- color: el.color,
10447
- width: el.width,
10448
- opacity: el.opacity,
10449
- layerId: el.layerId,
10450
- zIndex: el.zIndex,
10451
- position: el.position
10452
- })
10453
- );
10454
- }
10455
- erased = true;
10456
- }
10457
- if (erased) ctx.requestRender();
11767
+ if (!this.panning) return;
11768
+ const dx = state.x - this.lastScreen.x;
11769
+ const dy = state.y - this.lastScreen.y;
11770
+ this.lastScreen = { x: state.x, y: state.y };
11771
+ ctx.camera.pan(dx, dy);
10458
11772
  }
10459
- strokeIntersects(stroke, point, worldRadius) {
10460
- return hitTestStroke(stroke, point, worldRadius);
11773
+ onPointerUp(_state, ctx) {
11774
+ this.panning = false;
11775
+ ctx.setCursor?.("grab");
10461
11776
  }
10462
11777
  };
10463
11778
 
10464
- // src/elements/group.ts
10465
- function expandToGroups(ids, elements) {
10466
- const byId = new Map(elements.map((e) => [e.id, e]));
10467
- const groupIds = /* @__PURE__ */ new Set();
10468
- for (const id of ids) {
10469
- const g = byId.get(id)?.groupId;
10470
- if (g) groupIds.add(g);
11779
+ // src/tools/pencil-tool.ts
11780
+ var MIN_POINTS_FOR_STROKE = 2;
11781
+ var DEFAULT_SMOOTHING = 1.5;
11782
+ var DEFAULT_PRESSURE = 0.5;
11783
+ var DEFAULT_MIN_POINT_DISTANCE = 3;
11784
+ var DEFAULT_PROGRESSIVE_THRESHOLD = 200;
11785
+ var PROGRESSIVE_HOT_ZONE = 30;
11786
+ var PencilTool = class {
11787
+ name;
11788
+ drawing = false;
11789
+ points = [];
11790
+ color;
11791
+ width;
11792
+ smoothing;
11793
+ minPointDistance;
11794
+ progressiveThreshold;
11795
+ nextSimplifyAt;
11796
+ opacity;
11797
+ blendMode;
11798
+ optionListeners = /* @__PURE__ */ new Set();
11799
+ constructor(options = {}) {
11800
+ this.name = options.name ?? "pencil";
11801
+ this.color = options.color ?? "#000000";
11802
+ this.width = options.width ?? 2;
11803
+ this.smoothing = options.smoothing ?? DEFAULT_SMOOTHING;
11804
+ this.minPointDistance = options.minPointDistance ?? DEFAULT_MIN_POINT_DISTANCE;
11805
+ this.progressiveThreshold = options.progressiveSimplifyThreshold ?? DEFAULT_PROGRESSIVE_THRESHOLD;
11806
+ this.nextSimplifyAt = this.progressiveThreshold;
11807
+ this.opacity = options.opacity ?? 1;
11808
+ this.blendMode = options.blendMode;
10471
11809
  }
10472
- if (groupIds.size === 0) return ids;
10473
- const idSet = new Set(ids);
10474
- const result = [...ids];
10475
- for (const el of elements) {
10476
- if (el.groupId && groupIds.has(el.groupId) && !idSet.has(el.id)) {
10477
- result.push(el.id);
10478
- idSet.add(el.id);
10479
- }
11810
+ onActivate(ctx) {
11811
+ ctx.setCursor?.("crosshair");
10480
11812
  }
10481
- return result;
10482
- }
10483
-
10484
- // src/tools/arrow-handles.ts
10485
- var BIND_THRESHOLD = 20;
10486
- var HANDLE_RADIUS = 5;
10487
- var HANDLE_HIT_PADDING = 4;
10488
- var ARROW_HANDLE_CURSORS = {
10489
- start: "crosshair",
10490
- end: "crosshair",
10491
- mid: "grab"
10492
- };
10493
- function getArrowHandleCursor(handle, active) {
10494
- if (handle === "mid" && active) return "grabbing";
10495
- return ARROW_HANDLE_CURSORS[handle];
10496
- }
10497
- function getArrowHandlePositions(arrow) {
10498
- const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
10499
- return [
10500
- ["start", arrow.from],
10501
- ["mid", mid],
10502
- ["end", arrow.to]
10503
- ];
10504
- }
10505
- function hitTestArrowHandles(world, selectedIds, ctx) {
10506
- if (selectedIds.length === 0) return null;
10507
- const zoom = ctx.camera.zoom;
10508
- const hitRadius = (HANDLE_RADIUS + HANDLE_HIT_PADDING) / zoom;
10509
- for (const id of selectedIds) {
10510
- const el = ctx.store.getById(id);
10511
- if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
10512
- continue;
10513
- const handles = getArrowHandlePositions(el);
10514
- for (const [handle, pos] of handles) {
10515
- const dx = world.x - pos.x;
10516
- const dy = world.y - pos.y;
10517
- if (dx * dx + dy * dy <= hitRadius * hitRadius) {
10518
- return { elementId: id, handle };
10519
- }
10520
- }
11813
+ onDeactivate(ctx) {
11814
+ ctx.setCursor?.("default");
10521
11815
  }
10522
- return null;
10523
- }
10524
- function applyArrowHandleDrag(handle, elementId, world, ctx) {
10525
- const el = ctx.store.getById(elementId);
10526
- if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) return;
10527
- const threshold = BIND_THRESHOLD / ctx.camera.zoom;
10528
- const layerFilter = (candidate) => candidate.layerId === el.layerId;
10529
- switch (handle) {
10530
- case "start": {
10531
- const excludeId = el.toBinding?.elementId;
10532
- const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
10533
- if (target) {
10534
- const center2 = getElementCenter(target);
10535
- ctx.store.update(elementId, {
10536
- from: center2,
10537
- position: center2,
10538
- fromBinding: { elementId: target.id }
10539
- });
10540
- } else {
10541
- ctx.store.update(elementId, {
10542
- from: { x: world.x, y: world.y },
10543
- position: { x: world.x, y: world.y },
10544
- fromBinding: void 0
10545
- });
10546
- }
10547
- break;
10548
- }
10549
- case "end": {
10550
- const excludeId = el.fromBinding?.elementId;
10551
- const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
10552
- if (target) {
10553
- const center2 = getElementCenter(target);
10554
- ctx.store.update(elementId, {
10555
- to: center2,
10556
- toBinding: { elementId: target.id }
10557
- });
10558
- } else {
10559
- ctx.store.update(elementId, {
10560
- to: { x: world.x, y: world.y },
10561
- toBinding: void 0
10562
- });
10563
- }
10564
- break;
10565
- }
10566
- case "mid": {
10567
- const bend = getBendFromPoint(el.from, el.to, world);
10568
- ctx.store.update(elementId, { bend });
10569
- break;
10570
- }
11816
+ getOptions() {
11817
+ return {
11818
+ color: this.color,
11819
+ width: this.width,
11820
+ smoothing: this.smoothing,
11821
+ minPointDistance: this.minPointDistance,
11822
+ progressiveSimplifyThreshold: this.progressiveThreshold,
11823
+ opacity: this.opacity,
11824
+ blendMode: this.blendMode
11825
+ };
11826
+ }
11827
+ onOptionsChange(listener) {
11828
+ this.optionListeners.add(listener);
11829
+ return () => this.optionListeners.delete(listener);
10571
11830
  }
10572
- ctx.requestRender();
10573
- }
10574
- function getArrowHandleDragTarget(handle, elementId, world, ctx) {
10575
- if (handle === "mid") return null;
10576
- const el = ctx.store.getById(elementId);
10577
- if (!el || el.type !== "arrow" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false)) {
10578
- return null;
11831
+ setOptions(options) {
11832
+ if (options.color !== void 0) this.color = options.color;
11833
+ if (options.width !== void 0) this.width = options.width;
11834
+ if (options.smoothing !== void 0) this.smoothing = options.smoothing;
11835
+ if (options.minPointDistance !== void 0) this.minPointDistance = options.minPointDistance;
11836
+ if (options.progressiveSimplifyThreshold !== void 0)
11837
+ this.progressiveThreshold = options.progressiveSimplifyThreshold;
11838
+ if (options.opacity !== void 0) this.opacity = options.opacity;
11839
+ if (options.blendMode !== void 0) this.blendMode = options.blendMode;
11840
+ this.notifyOptionsChange();
10579
11841
  }
10580
- const threshold = BIND_THRESHOLD / ctx.camera.zoom;
10581
- const excludeId = handle === "start" ? el.toBinding?.elementId : el.fromBinding?.elementId;
10582
- const layerFilter = (candidate) => candidate.layerId === el.layerId;
10583
- const target = findBindTarget(world, ctx.store, threshold, excludeId, layerFilter);
10584
- if (!target) return null;
10585
- return getElementBounds(target);
10586
- }
10587
- function renderArrowHandles(canvasCtx, arrow, zoom) {
10588
- const radius = HANDLE_RADIUS / zoom;
10589
- const handles = getArrowHandlePositions(arrow);
10590
- canvasCtx.setLineDash([]);
10591
- canvasCtx.lineWidth = 1.5 / zoom;
10592
- for (const [handle, pos] of handles) {
10593
- canvasCtx.fillStyle = handle === "mid" ? "#2196F3" : "#ffffff";
10594
- canvasCtx.strokeStyle = "#2196F3";
10595
- canvasCtx.beginPath();
10596
- canvasCtx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
10597
- canvasCtx.fill();
10598
- canvasCtx.stroke();
11842
+ onPointerDown(state, ctx) {
11843
+ this.drawing = true;
11844
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11845
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
11846
+ this.points = [{ x: world.x, y: world.y, pressure }];
11847
+ this.nextSimplifyAt = this.progressiveThreshold;
10599
11848
  }
10600
- }
10601
- function renderArrowHoverHandle(canvasCtx, arrow, zoom) {
10602
- const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
10603
- const radius = HANDLE_RADIUS / zoom;
10604
- canvasCtx.fillStyle = "#2196F3";
10605
- canvasCtx.strokeStyle = "#2196F3";
10606
- canvasCtx.lineWidth = 1.5 / zoom;
10607
- canvasCtx.beginPath();
10608
- canvasCtx.arc(mid.x, mid.y, radius, 0, Math.PI * 2);
10609
- canvasCtx.fill();
10610
- canvasCtx.stroke();
10611
- }
10612
-
10613
- // src/elements/snap-guides.ts
10614
- function xAnchors(b) {
10615
- return { lo: b.x, mid: b.x + b.w / 2, hi: b.x + b.w };
10616
- }
10617
- function yAnchors(b) {
10618
- return { lo: b.y, mid: b.y + b.h / 2, hi: b.y + b.h };
10619
- }
10620
- function bestAxisSnap(moving, targets, anchorsFn, threshold) {
10621
- let best = null;
10622
- for (const t of targets) {
10623
- const ta = anchorsFn(t);
10624
- const pairs = [
10625
- // colinear alignment: same-type edges/centers line up
10626
- [ta.lo - moving.lo, ta.lo],
10627
- [ta.mid - moving.mid, ta.mid],
10628
- [ta.hi - moving.hi, ta.hi],
10629
- // abutment: the moving box sits flush against the target's opposite edge
10630
- [ta.lo - moving.hi, ta.lo],
10631
- [ta.hi - moving.lo, ta.hi]
10632
- ];
10633
- for (const [delta, position] of pairs) {
10634
- const abs = Math.abs(delta);
10635
- if (abs <= threshold && (best === null || abs < Math.abs(best.delta))) {
10636
- best = { delta, position };
10637
- }
11849
+ onPointerMove(state, ctx) {
11850
+ if (!this.drawing) return;
11851
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11852
+ const pressure = state.pressure === 0 ? DEFAULT_PRESSURE : state.pressure;
11853
+ const last = this.points[this.points.length - 1];
11854
+ if (last) {
11855
+ const dx = world.x - last.x;
11856
+ const dy = world.y - last.y;
11857
+ if (dx * dx + dy * dy < this.minPointDistance * this.minPointDistance) return;
11858
+ }
11859
+ this.points.push({ x: world.x, y: world.y, pressure });
11860
+ if (this.points.length > this.nextSimplifyAt) {
11861
+ const hotZone = this.points.slice(-PROGRESSIVE_HOT_ZONE);
11862
+ const coldZone = this.points.slice(0, -PROGRESSIVE_HOT_ZONE);
11863
+ const simplified = simplifyPoints(coldZone, this.smoothing * 2);
11864
+ this.points = [...simplified, ...hotZone];
11865
+ this.nextSimplifyAt = this.points.length + this.progressiveThreshold;
10638
11866
  }
11867
+ ctx.requestRender();
10639
11868
  }
10640
- return best;
10641
- }
10642
- function computeSnapGuides(moving, targets, threshold) {
10643
- const xSnap = bestAxisSnap(xAnchors(moving), targets, xAnchors, threshold);
10644
- const ySnap = bestAxisSnap(yAnchors(moving), targets, yAnchors, threshold);
10645
- const guides = [];
10646
- if (xSnap) guides.push({ axis: "x", position: xSnap.position });
10647
- if (ySnap) guides.push({ axis: "y", position: ySnap.position });
10648
- return { dx: xSnap?.delta ?? 0, dy: ySnap?.delta ?? 0, guides };
10649
- }
10650
-
10651
- // src/tools/select-overlay.ts
10652
- var HANDLE_SIZE = 8;
10653
- var HANDLE_HIT_PADDING2 = 4;
10654
- var SELECTION_PAD = 4;
10655
- var ROTATE_HANDLE_OFFSET = 24;
10656
- var ROTATABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape", "stroke"]);
10657
- var HANDLE_CURSORS = {
10658
- nw: "nwse-resize",
10659
- se: "nwse-resize",
10660
- ne: "nesw-resize",
10661
- sw: "nesw-resize"
10662
- };
10663
- function getOverlayLayout(el, zoom) {
10664
- const bounds = getElementBounds(el);
10665
- if (!bounds) return null;
10666
- const angle = el.rotation ?? 0;
10667
- const pad = SELECTION_PAD / zoom;
10668
- const center2 = { x: bounds.x + bounds.w / 2, y: bounds.y + bounds.h / 2 };
10669
- const raw = [
10670
- ["nw", { x: bounds.x - pad, y: bounds.y - pad }],
10671
- ["ne", { x: bounds.x + bounds.w + pad, y: bounds.y - pad }],
10672
- ["sw", { x: bounds.x - pad, y: bounds.y + bounds.h + pad }],
10673
- ["se", { x: bounds.x + bounds.w + pad, y: bounds.y + bounds.h + pad }]
10674
- ];
10675
- const corners = raw.map(
10676
- ([h, p]) => [h, rotatePoint(p, center2, angle)]
10677
- );
10678
- const topMid = { x: center2.x, y: bounds.y - pad - ROTATE_HANDLE_OFFSET / zoom };
10679
- const rotateHandle = rotatePoint(topMid, center2, angle);
10680
- return { center: center2, corners, rotateHandle, angle };
10681
- }
10682
- function templateAimKnob(el, zoom) {
10683
- if (el.type !== "template") return null;
10684
- if (el.templateShape !== "cone" && el.templateShape !== "line" && el.templateShape !== "rectangle")
10685
- return null;
10686
- const gap = ROTATE_HANDLE_OFFSET / zoom;
10687
- const dist = el.radius + gap;
10688
- const origin = el.position;
10689
- return {
10690
- origin,
10691
- knob: {
10692
- x: origin.x + dist * Math.cos(el.angle),
10693
- y: origin.y + dist * Math.sin(el.angle)
11869
+ onPointerUp(_state, ctx) {
11870
+ if (!this.drawing) return;
11871
+ this.drawing = false;
11872
+ if (this.points.length < MIN_POINTS_FOR_STROKE) {
11873
+ this.points = [];
11874
+ return;
10694
11875
  }
10695
- };
10696
- }
10697
- function getHandlePositions(bounds) {
10698
- return [
10699
- ["nw", { x: bounds.x, y: bounds.y }],
10700
- ["ne", { x: bounds.x + bounds.w, y: bounds.y }],
10701
- ["sw", { x: bounds.x, y: bounds.y + bounds.h }],
10702
- ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
10703
- ];
10704
- }
10705
- function topMidpoint(layout) {
10706
- const nw = layout.corners.find(([h]) => h === "nw")?.[1] ?? { x: 0, y: 0 };
10707
- const ne = layout.corners.find(([h]) => h === "ne")?.[1] ?? { x: 0, y: 0 };
10708
- return { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
10709
- }
10710
- function drawLockBadge(ctx, at, zoom) {
10711
- const r = 9 / zoom;
10712
- ctx.save();
10713
- ctx.setLineDash([]);
10714
- ctx.beginPath();
10715
- ctx.arc(at.x, at.y, r, 0, Math.PI * 2);
10716
- ctx.fillStyle = "#ffffff";
10717
- ctx.fill();
10718
- ctx.strokeStyle = "#2196F3";
10719
- ctx.lineWidth = 1.5 / zoom;
10720
- ctx.stroke();
10721
- const bw = 8 / zoom;
10722
- const bh = 6 / zoom;
10723
- ctx.fillStyle = "#2196F3";
10724
- ctx.fillRect(at.x - bw / 2, at.y - bh / 2 + 1 / zoom, bw, bh);
10725
- ctx.beginPath();
10726
- ctx.arc(at.x, at.y - bh / 2 + 1 / zoom, 2.5 / zoom, Math.PI, 0);
10727
- ctx.lineWidth = 1.4 / zoom;
10728
- ctx.stroke();
10729
- ctx.restore();
10730
- }
10731
- function renderMarquee(ctx, rect) {
10732
- ctx.save();
10733
- ctx.strokeStyle = "#2196F3";
10734
- ctx.fillStyle = "rgba(33, 150, 243, 0.08)";
10735
- ctx.lineWidth = 1;
10736
- ctx.setLineDash([4, 4]);
10737
- ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
10738
- ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
10739
- ctx.restore();
10740
- }
10741
- function renderBindingHighlights(ctx, arrow, zoom, store) {
10742
- if (!arrow.fromBinding && !arrow.toBinding) return;
10743
- const pad = SELECTION_PAD / zoom;
10744
- ctx.save();
10745
- ctx.strokeStyle = "#2196F3";
10746
- ctx.lineWidth = 2 / zoom;
10747
- ctx.setLineDash([]);
10748
- const drawn = /* @__PURE__ */ new Set();
10749
- for (const binding of [arrow.fromBinding, arrow.toBinding]) {
10750
- if (!binding || drawn.has(binding.elementId)) continue;
10751
- drawn.add(binding.elementId);
10752
- const target = store.getById(binding.elementId);
10753
- if (!target) continue;
10754
- const bounds = getElementBounds(target);
10755
- if (!bounds) continue;
10756
- ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
11876
+ const simplified = simplifyPoints(this.points, this.smoothing);
11877
+ const stroke = createStroke({
11878
+ points: simplified,
11879
+ color: this.color,
11880
+ width: this.width,
11881
+ layerId: ctx.activeLayerId ?? "",
11882
+ opacity: this.opacity,
11883
+ blendMode: this.blendMode
11884
+ });
11885
+ ctx.store.add(stroke);
11886
+ computeStrokeSegments(stroke);
11887
+ this.points = [];
11888
+ ctx.requestRender();
11889
+ }
11890
+ notifyOptionsChange() {
11891
+ for (const listener of this.optionListeners) listener();
11892
+ }
11893
+ renderOverlay(ctx) {
11894
+ if (!this.drawing || this.points.length < 2) return;
11895
+ ctx.save();
11896
+ ctx.strokeStyle = this.color;
11897
+ ctx.lineCap = "round";
11898
+ ctx.lineJoin = "round";
11899
+ ctx.globalAlpha = this.blendMode ? this.opacity : 0.8;
11900
+ if (this.blendMode) ctx.globalCompositeOperation = this.blendMode;
11901
+ const segments = smoothToSegments(this.points);
11902
+ for (const seg of segments) {
11903
+ const w = (pressureToWidth(seg.start.pressure, this.width) + pressureToWidth(seg.end.pressure, this.width)) / 2;
11904
+ ctx.lineWidth = w;
11905
+ ctx.beginPath();
11906
+ ctx.moveTo(seg.start.x, seg.start.y);
11907
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
11908
+ ctx.stroke();
11909
+ }
11910
+ ctx.restore();
10757
11911
  }
10758
- ctx.restore();
11912
+ };
11913
+
11914
+ // src/elements/stroke-erase.ts
11915
+ function lerp2(a, b, t) {
11916
+ return {
11917
+ x: a.x + (b.x - a.x) * t,
11918
+ y: a.y + (b.y - a.y) * t,
11919
+ pressure: a.pressure + (b.pressure - a.pressure) * t
11920
+ };
10759
11921
  }
10760
- function renderSelectionBoxes(ctx, p) {
10761
- if (p.selectedIds.length === 0) return;
10762
- const zoom = p.zoom;
10763
- const handleWorldSize = HANDLE_SIZE / zoom;
10764
- ctx.save();
10765
- ctx.strokeStyle = "#2196F3";
10766
- ctx.lineWidth = 1.5 / zoom;
10767
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10768
- for (const id of p.selectedIds) {
10769
- const el = p.store.getById(id);
10770
- if (!el) continue;
10771
- const locked = el.locked || (p.isLayerLocked?.(el.layerId) ?? false);
10772
- if (el.type === "arrow") {
10773
- if (!locked) renderArrowHandles(ctx, el, zoom);
10774
- renderBindingHighlights(ctx, el, zoom, p.store);
10775
- continue;
10776
- }
10777
- if (el.type === "shape" && el.shape === "line") {
10778
- if (!locked) {
10779
- ctx.setLineDash([]);
10780
- ctx.fillStyle = "#ffffff";
10781
- const r = handleWorldSize / 2;
10782
- for (const pt of lineEndpoints(el)) {
10783
- ctx.beginPath();
10784
- ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
10785
- ctx.fill();
10786
- ctx.stroke();
10787
- }
10788
- ctx.setLineDash([4 / zoom, 4 / zoom]);
11922
+ function erasePoints(points, eraser, radius) {
11923
+ const r2 = radius * radius;
11924
+ if (points.length < 2) {
11925
+ const p = points[0];
11926
+ if (p && (p.x - eraser.x) ** 2 + (p.y - eraser.y) ** 2 <= r2) return [];
11927
+ return null;
11928
+ }
11929
+ const runs = [];
11930
+ let current = [];
11931
+ let erased = false;
11932
+ const flush = () => {
11933
+ if (current.length >= 2) runs.push(current);
11934
+ current = [];
11935
+ };
11936
+ for (let i = 0; i < points.length - 1; i++) {
11937
+ const a = points[i];
11938
+ const b = points[i + 1];
11939
+ if (!a || !b) continue;
11940
+ const dx = b.x - a.x;
11941
+ const dy = b.y - a.y;
11942
+ const fx = a.x - eraser.x;
11943
+ const fy = a.y - eraser.y;
11944
+ const A = dx * dx + dy * dy;
11945
+ const B = 2 * (fx * dx + fy * dy);
11946
+ const C = fx * fx + fy * fy - r2;
11947
+ let tLo = 1;
11948
+ let tHi = 0;
11949
+ if (A === 0) {
11950
+ if (C <= 0) {
11951
+ tLo = 0;
11952
+ tHi = 1;
10789
11953
  }
10790
- continue;
10791
- }
10792
- const bounds = getElementBounds(el);
10793
- if (!bounds) continue;
10794
- const layout = getOverlayLayout(el, zoom);
10795
- if (!layout) continue;
10796
- const pad = SELECTION_PAD / zoom;
10797
- if (layout.angle === 0) {
10798
- ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
10799
11954
  } else {
10800
- const ordered = ["nw", "ne", "se", "sw"].map((h) => layout.corners.find(([c]) => c === h)?.[1]).filter((pp) => !!pp);
10801
- const [p0, ...others] = ordered;
10802
- if (p0) {
10803
- ctx.beginPath();
10804
- ctx.moveTo(p0.x, p0.y);
10805
- for (const pp of others) ctx.lineTo(pp.x, pp.y);
10806
- ctx.closePath();
10807
- ctx.stroke();
10808
- }
10809
- }
10810
- if (!el.locked) {
10811
- if ("size" in el) {
10812
- ctx.setLineDash([]);
10813
- ctx.fillStyle = "#ffffff";
10814
- const corners = layout.angle === 0 ? getHandlePositions(bounds) : layout.corners;
10815
- for (const [, pos] of corners) {
10816
- ctx.fillRect(
10817
- pos.x - handleWorldSize / 2,
10818
- pos.y - handleWorldSize / 2,
10819
- handleWorldSize,
10820
- handleWorldSize
10821
- );
10822
- ctx.strokeRect(
10823
- pos.x - handleWorldSize / 2,
10824
- pos.y - handleWorldSize / 2,
10825
- handleWorldSize,
10826
- handleWorldSize
10827
- );
10828
- }
10829
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10830
- } else if (el.type === "template") {
10831
- ctx.setLineDash([]);
10832
- ctx.fillStyle = "#ffffff";
10833
- if (el.templateShape === "rectangle") {
10834
- if (p.selectedIds.length === 1) {
10835
- const cos = Math.cos(el.angle);
10836
- const sin = Math.sin(el.angle);
10837
- const halfW = (el.width ?? 0) / 2;
10838
- const pts = [
10839
- [el.position.x + el.radius * cos, el.position.y + el.radius * sin],
10840
- [
10841
- el.position.x + el.radius / 2 * cos + halfW * -sin,
10842
- el.position.y + el.radius / 2 * sin + halfW * cos
10843
- ]
10844
- ];
10845
- for (const [hx, hy] of pts) {
10846
- ctx.fillRect(
10847
- hx - handleWorldSize / 2,
10848
- hy - handleWorldSize / 2,
10849
- handleWorldSize,
10850
- handleWorldSize
10851
- );
10852
- ctx.strokeRect(
10853
- hx - handleWorldSize / 2,
10854
- hy - handleWorldSize / 2,
10855
- handleWorldSize,
10856
- handleWorldSize
10857
- );
10858
- }
10859
- }
10860
- } else {
10861
- const hx = bounds.x + bounds.w;
10862
- const hy = bounds.y + bounds.h;
10863
- ctx.fillRect(
10864
- hx - handleWorldSize / 2,
10865
- hy - handleWorldSize / 2,
10866
- handleWorldSize,
10867
- handleWorldSize
10868
- );
10869
- ctx.strokeRect(
10870
- hx - handleWorldSize / 2,
10871
- hy - handleWorldSize / 2,
10872
- handleWorldSize,
10873
- handleWorldSize
10874
- );
10875
- }
10876
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10877
- if (p.selectedIds.length === 1 && (el.templateShape === "cone" || el.templateShape === "line" || el.templateShape === "rectangle")) {
10878
- const aim = templateAimKnob(el, zoom);
10879
- if (aim) {
10880
- ctx.beginPath();
10881
- ctx.moveTo(aim.origin.x, aim.origin.y);
10882
- ctx.lineTo(aim.knob.x, aim.knob.y);
10883
- ctx.stroke();
10884
- ctx.setLineDash([]);
10885
- ctx.fillStyle = "#ffffff";
10886
- ctx.beginPath();
10887
- ctx.arc(aim.knob.x, aim.knob.y, handleWorldSize / 2, 0, Math.PI * 2);
10888
- ctx.fill();
10889
- ctx.stroke();
10890
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10891
- }
11955
+ const disc = B * B - 4 * A * C;
11956
+ if (disc >= 0) {
11957
+ const sq = Math.sqrt(disc);
11958
+ const lo = Math.max(0, (-B - sq) / (2 * A));
11959
+ const hi = Math.min(1, (-B + sq) / (2 * A));
11960
+ if (lo < hi) {
11961
+ tLo = lo;
11962
+ tHi = hi;
10892
11963
  }
10893
11964
  }
10894
- if (p.selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
10895
- const stemStart = topMidpoint(layout);
10896
- const stemEnd = layout.rotateHandle;
10897
- ctx.beginPath();
10898
- ctx.moveTo(stemStart.x, stemStart.y);
10899
- ctx.lineTo(stemEnd.x, stemEnd.y);
10900
- ctx.stroke();
10901
- ctx.setLineDash([]);
10902
- ctx.fillStyle = "#ffffff";
10903
- ctx.beginPath();
10904
- ctx.arc(stemEnd.x, stemEnd.y, handleWorldSize / 2, 0, Math.PI * 2);
10905
- ctx.fill();
10906
- ctx.stroke();
10907
- ctx.setLineDash([4 / zoom, 4 / zoom]);
10908
- }
10909
11965
  }
10910
- if (el.locked) {
10911
- const ne = layout.corners.find(([h]) => h === "ne")?.[1];
10912
- if (ne) drawLockBadge(ctx, ne, zoom);
11966
+ if (tLo > tHi) {
11967
+ if (current.length === 0) current.push(a);
11968
+ current.push(b);
11969
+ continue;
10913
11970
  }
10914
- }
10915
- ctx.restore();
10916
- }
10917
- function renderGuideLines(ctx, p) {
10918
- const zoom = p.zoom;
10919
- const rect = p.rect;
10920
- ctx.save();
10921
- ctx.strokeStyle = "#FF4081";
10922
- ctx.lineWidth = 1 / zoom;
10923
- ctx.setLineDash([]);
10924
- for (const g of p.guides) {
10925
- ctx.beginPath();
10926
- if (g.axis === "x") {
10927
- const y0 = rect ? rect.y : p.currentWorld.y - 1e5;
10928
- const y1 = rect ? rect.y + rect.h : p.currentWorld.y + 1e5;
10929
- ctx.moveTo(g.position, y0);
10930
- ctx.lineTo(g.position, y1);
11971
+ erased = true;
11972
+ if (tLo > 0) {
11973
+ if (current.length === 0) current.push(a);
11974
+ current.push(lerp2(a, b, tLo));
11975
+ flush();
10931
11976
  } else {
10932
- const x0 = rect ? rect.x : p.currentWorld.x - 1e5;
10933
- const x1 = rect ? rect.x + rect.w : p.currentWorld.x + 1e5;
10934
- ctx.moveTo(x0, g.position);
10935
- ctx.lineTo(x1, g.position);
11977
+ flush();
11978
+ }
11979
+ if (tHi < 1) {
11980
+ current = [lerp2(a, b, tHi), b];
10936
11981
  }
10937
- ctx.stroke();
10938
11982
  }
10939
- ctx.restore();
11983
+ flush();
11984
+ return erased ? runs : null;
10940
11985
  }
10941
11986
 
10942
- // src/tools/select-hit.ts
10943
- function hitTest(world, ctx) {
10944
- const r = 10;
10945
- const candidates = ctx.store.queryRect({ x: world.x - r, y: world.y - r, w: r * 2, h: r * 2 }).reverse();
10946
- for (const el of candidates) {
10947
- if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
10948
- if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
10949
- if (el.type === "grid") continue;
10950
- if (isInsideBounds(world, el)) return el;
11987
+ // src/tools/eraser-tool.ts
11988
+ var DEFAULT_RADIUS3 = 20;
11989
+ function makeEraserCursor(radius) {
11990
+ const size = radius * 2;
11991
+ 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>`;
11992
+ return `url("data:image/svg+xml,${svg}") ${radius} ${radius}, crosshair`;
11993
+ }
11994
+ var EraserTool = class {
11995
+ name = "eraser";
11996
+ erasing = false;
11997
+ radius;
11998
+ cursor;
11999
+ mode;
12000
+ constructor(options = {}) {
12001
+ this.radius = options.radius ?? DEFAULT_RADIUS3;
12002
+ this.cursor = makeEraserCursor(this.radius);
12003
+ this.mode = options.mode ?? "partial";
10951
12004
  }
10952
- return null;
10953
- }
10954
- function isInsideBounds(point, el) {
10955
- if (el.type === "grid") return false;
10956
- const angle = el.rotation ?? 0;
10957
- if (angle !== 0) {
10958
- const b = getElementBounds(el);
10959
- if (b) {
10960
- point = rotatePoint(point, { x: b.x + b.w / 2, y: b.y + b.h / 2 }, -angle);
12005
+ getOptions() {
12006
+ return { radius: this.radius, mode: this.mode };
12007
+ }
12008
+ setOptions(options) {
12009
+ if (options.mode !== void 0) this.mode = options.mode;
12010
+ if (options.radius !== void 0) {
12011
+ this.radius = options.radius;
12012
+ this.cursor = makeEraserCursor(this.radius);
10961
12013
  }
10962
12014
  }
10963
- if (el.type === "shape" && el.shape === "line") {
10964
- const [a, b] = lineEndpoints(el);
10965
- const threshold = Math.max(el.strokeWidth / 2, 6);
10966
- return distSqToSegment(point, a, b) <= threshold * threshold;
12015
+ onActivate(ctx) {
12016
+ ctx.setCursor?.(this.cursor);
10967
12017
  }
10968
- if ("size" in el) {
10969
- const s = el.size;
10970
- 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;
12018
+ onDeactivate(ctx) {
12019
+ ctx.setCursor?.("default");
10971
12020
  }
10972
- if (el.type === "stroke") {
10973
- return hitTestStroke(el, point, 10);
12021
+ onPointerDown(state, ctx) {
12022
+ this.erasing = true;
12023
+ this.eraseAt(state, ctx);
10974
12024
  }
10975
- if (el.type === "arrow") {
10976
- return isNearBezier(point, el.from, el.to, el.bend, 10);
12025
+ onPointerMove(state, ctx) {
12026
+ if (!this.erasing) return;
12027
+ this.eraseAt(state, ctx);
10977
12028
  }
10978
- if (el.type === "template") {
10979
- const bounds = getElementBounds(el);
10980
- if (!bounds) return false;
10981
- return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
12029
+ onPointerUp(_state, _ctx) {
12030
+ this.erasing = false;
10982
12031
  }
10983
- return false;
10984
- }
10985
- function hitTestResizeHandle(world, ctx, selectedIds) {
10986
- if (selectedIds.length === 0) return null;
10987
- const zoom = ctx.camera.zoom;
10988
- const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
10989
- for (const id of selectedIds) {
10990
- const el = ctx.store.getById(id);
10991
- if (!el || !("size" in el)) continue;
10992
- if (el.locked) continue;
10993
- if (el.type === "shape" && el.shape === "line") continue;
10994
- const layout = getOverlayLayout(el, zoom);
10995
- if (!layout) continue;
10996
- for (const [handle, pos] of layout.corners) {
10997
- if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
10998
- return { elementId: id, handle };
12032
+ eraseAt(state, ctx) {
12033
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
12034
+ const worldRadius = this.radius / ctx.camera.zoom;
12035
+ const queryBounds = {
12036
+ x: world.x - worldRadius,
12037
+ y: world.y - worldRadius,
12038
+ w: worldRadius * 2,
12039
+ h: worldRadius * 2
12040
+ };
12041
+ const candidates = ctx.store.queryRect(queryBounds);
12042
+ let erased = false;
12043
+ for (const el of candidates) {
12044
+ if (el.type !== "stroke") continue;
12045
+ if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
12046
+ if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
12047
+ if (!this.strokeIntersects(el, world, worldRadius)) continue;
12048
+ if (this.mode === "stroke") {
12049
+ ctx.store.remove(el.id);
12050
+ erased = true;
12051
+ continue;
12052
+ }
12053
+ const localEraser = { x: world.x - el.position.x, y: world.y - el.position.y };
12054
+ const runs = erasePoints(el.points, localEraser, worldRadius);
12055
+ if (runs === null) continue;
12056
+ ctx.store.remove(el.id);
12057
+ for (const run of runs) {
12058
+ ctx.store.add(
12059
+ createStroke({
12060
+ points: run,
12061
+ color: el.color,
12062
+ width: el.width,
12063
+ opacity: el.opacity,
12064
+ layerId: el.layerId,
12065
+ zIndex: el.zIndex,
12066
+ position: el.position
12067
+ })
12068
+ );
10999
12069
  }
12070
+ erased = true;
11000
12071
  }
12072
+ if (erased) ctx.requestRender();
11001
12073
  }
11002
- return null;
11003
- }
11004
- function hitTestRotateHandle(world, ctx, selectedIds) {
11005
- if (selectedIds.length !== 1) return null;
11006
- const id = selectedIds[0];
11007
- if (!id) return null;
11008
- const el = ctx.store.getById(id);
11009
- if (!el || el.locked || !ROTATABLE_TYPES.has(el.type)) return null;
11010
- const layout = getOverlayLayout(el, ctx.camera.zoom);
11011
- if (!layout) return null;
11012
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
11013
- const dx = world.x - layout.rotateHandle.x;
11014
- const dy = world.y - layout.rotateHandle.y;
11015
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
11016
- }
11017
- function hitTestLineHandles(world, ctx, selectedIds) {
11018
- if (selectedIds.length === 0) return null;
11019
- const zoom = ctx.camera.zoom;
11020
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
11021
- const r2 = r * r;
11022
- for (const id of selectedIds) {
11023
- const el = ctx.store.getById(id);
11024
- if (!el || el.type !== "shape" || el.shape !== "line" || el.locked || (ctx.isLayerLocked?.(el.layerId) ?? false))
11025
- continue;
11026
- const [a, b] = lineEndpoints(el);
11027
- if ((world.x - a.x) ** 2 + (world.y - a.y) ** 2 <= r2) return { elementId: id, fixed: b };
11028
- if ((world.x - b.x) ** 2 + (world.y - b.y) ** 2 <= r2) return { elementId: id, fixed: a };
12074
+ strokeIntersects(stroke, point, worldRadius) {
12075
+ return hitTestStroke(stroke, point, worldRadius);
11029
12076
  }
11030
- return null;
11031
- }
11032
- function hitTestTemplateResizeHandle(world, ctx, selectedIds) {
11033
- if (selectedIds.length === 0) return null;
11034
- const zoom = ctx.camera.zoom;
11035
- const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
11036
- for (const id of selectedIds) {
11037
- const el = ctx.store.getById(id);
11038
- if (!el || el.type !== "template") continue;
11039
- if (el.templateShape === "rectangle") continue;
11040
- const bounds = getElementBounds(el);
11041
- if (!bounds) continue;
11042
- const hx = bounds.x + bounds.w;
11043
- const hy = bounds.y + bounds.h;
11044
- if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
11045
- return id;
12077
+ };
12078
+
12079
+ // src/elements/group.ts
12080
+ function expandToGroups(ids, elements) {
12081
+ const byId = new Map(elements.map((e) => [e.id, e]));
12082
+ const groupIds = /* @__PURE__ */ new Set();
12083
+ for (const id of ids) {
12084
+ const g = byId.get(id)?.groupId;
12085
+ if (g) groupIds.add(g);
12086
+ }
12087
+ if (groupIds.size === 0) return ids;
12088
+ const idSet = new Set(ids);
12089
+ const result = [...ids];
12090
+ for (const el of elements) {
12091
+ if (el.groupId && groupIds.has(el.groupId) && !idSet.has(el.id)) {
12092
+ result.push(el.id);
12093
+ idSet.add(el.id);
11046
12094
  }
11047
12095
  }
11048
- return null;
11049
- }
11050
- function hitTestTemplateAimHandle(world, ctx, selectedIds) {
11051
- if (selectedIds.length !== 1) return null;
11052
- const id = selectedIds[0];
11053
- if (!id) return null;
11054
- const el = ctx.store.getById(id);
11055
- if (!el || el.locked) return null;
11056
- const knob = templateAimKnob(el, ctx.camera.zoom);
11057
- if (!knob) return null;
11058
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
11059
- const dx = world.x - knob.knob.x;
11060
- const dy = world.y - knob.knob.y;
11061
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
12096
+ return result;
11062
12097
  }
11063
- function hitTestRectangleLengthHandle(world, ctx, selectedIds) {
11064
- if (selectedIds.length !== 1) return null;
11065
- const id = selectedIds[0];
11066
- if (!id) return null;
11067
- const el = ctx.store.getById(id);
11068
- if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
11069
- const zoom = ctx.camera.zoom;
11070
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
11071
- const hx = el.position.x + el.radius * Math.cos(el.angle);
11072
- const hy = el.position.y + el.radius * Math.sin(el.angle);
11073
- const dx = world.x - hx;
11074
- const dy = world.y - hy;
11075
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
12098
+
12099
+ // src/elements/snap-guides.ts
12100
+ function xAnchors(b) {
12101
+ return { lo: b.x, mid: b.x + b.w / 2, hi: b.x + b.w };
11076
12102
  }
11077
- function hitTestRectangleWidthHandle(world, ctx, selectedIds) {
11078
- if (selectedIds.length !== 1) return null;
11079
- const id = selectedIds[0];
11080
- if (!id) return null;
11081
- const el = ctx.store.getById(id);
11082
- if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
11083
- const zoom = ctx.camera.zoom;
11084
- const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
11085
- const cos = Math.cos(el.angle);
11086
- const sin = Math.sin(el.angle);
11087
- const halfW = (el.width ?? 0) / 2;
11088
- const hx = el.position.x + el.radius / 2 * cos + halfW * -sin;
11089
- const hy = el.position.y + el.radius / 2 * sin + halfW * cos;
11090
- const dx = world.x - hx;
11091
- const dy = world.y - hy;
11092
- return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
12103
+ function yAnchors(b) {
12104
+ return { lo: b.y, mid: b.y + b.h / 2, hi: b.y + b.h };
11093
12105
  }
11094
- function findElementsInRect(marquee, ctx) {
11095
- const candidates = ctx.store.queryRect(marquee);
11096
- const ids = [];
11097
- for (const el of candidates) {
11098
- if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
11099
- if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
11100
- if (el.type === "grid") continue;
11101
- const bounds = getElementBounds(el);
11102
- if (bounds && rectsOverlap(marquee, rotatedAABB(bounds, el.rotation ?? 0))) {
11103
- ids.push(el.id);
12106
+ function bestAxisSnap(moving, targets, anchorsFn, threshold) {
12107
+ let best = null;
12108
+ for (const t of targets) {
12109
+ const ta = anchorsFn(t);
12110
+ const pairs = [
12111
+ // colinear alignment: same-type edges/centers line up
12112
+ [ta.lo - moving.lo, ta.lo],
12113
+ [ta.mid - moving.mid, ta.mid],
12114
+ [ta.hi - moving.hi, ta.hi],
12115
+ // abutment: the moving box sits flush against the target's opposite edge
12116
+ [ta.lo - moving.hi, ta.lo],
12117
+ [ta.hi - moving.lo, ta.hi]
12118
+ ];
12119
+ for (const [delta, position] of pairs) {
12120
+ const abs = Math.abs(delta);
12121
+ if (abs <= threshold && (best === null || abs < Math.abs(best.delta))) {
12122
+ best = { delta, position };
12123
+ }
11104
12124
  }
11105
12125
  }
11106
- return ids;
12126
+ return best;
11107
12127
  }
11108
- function rectsOverlap(a, b) {
11109
- 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;
12128
+ function computeSnapGuides(moving, targets, threshold) {
12129
+ const xSnap = bestAxisSnap(xAnchors(moving), targets, xAnchors, threshold);
12130
+ const ySnap = bestAxisSnap(yAnchors(moving), targets, yAnchors, threshold);
12131
+ const guides = [];
12132
+ if (xSnap) guides.push({ axis: "x", position: xSnap.position });
12133
+ if (ySnap) guides.push({ axis: "y", position: ySnap.position });
12134
+ return { dx: xSnap?.delta ?? 0, dy: ySnap?.delta ?? 0, guides };
11110
12135
  }
11111
12136
 
11112
12137
  // src/tools/select-resize.ts
@@ -12937,7 +13962,7 @@ var PingTool = class {
12937
13962
  };
12938
13963
 
12939
13964
  // src/index.ts
12940
- var VERSION = "0.61.0";
13965
+ var VERSION = "0.62.0";
12941
13966
  // Annotate the CommonJS export names for ESM import in node:
12942
13967
  0 && (module.exports = {
12943
13968
  ArrowTool,
@@ -12950,6 +13975,8 @@ var VERSION = "0.61.0";
12950
13975
  FOCUS_PRESENCE_KIND,
12951
13976
  HandTool,
12952
13977
  HistoryStack,
13978
+ HtmlPainterMissingError,
13979
+ HtmlPainterRegistry,
12953
13980
  ImageTool,
12954
13981
  IndexedDBAdapter,
12955
13982
  LASER_TRAIL_PRESENCE_KIND,
@@ -13013,6 +14040,7 @@ var VERSION = "0.61.0";
13013
14040
  isMeasurePresence,
13014
14041
  isNearBezier,
13015
14042
  isPingPresence,
14043
+ resolveHtmlRouting,
13016
14044
  setFontSize,
13017
14045
  smartSnap,
13018
14046
  snapPoint,