@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/README.md +706 -705
- package/dist/index.cjs +2434 -1406
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +209 -3
- package/dist/index.d.ts +209 -3
- package/dist/index.js +2431 -1406
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 (
|
|
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
|
|
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
|
|
6494
|
-
const
|
|
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
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
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
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
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 =
|
|
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/
|
|
8301
|
-
|
|
8302
|
-
|
|
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
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
|
|
8347
|
-
|
|
8348
|
-
|
|
8349
|
-
|
|
8350
|
-
|
|
8351
|
-
|
|
8352
|
-
|
|
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:
|
|
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.
|
|
9929
|
-
|
|
9930
|
-
this.
|
|
9931
|
-
this.
|
|
9932
|
-
this.
|
|
9933
|
-
this.
|
|
9934
|
-
|
|
9935
|
-
this.
|
|
9936
|
-
this.
|
|
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
|
-
|
|
9941
|
-
|
|
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
|
-
|
|
9961
|
-
|
|
9962
|
-
|
|
9963
|
-
|
|
9964
|
-
|
|
9965
|
-
|
|
9966
|
-
|
|
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
|
-
|
|
9985
|
-
|
|
9986
|
-
|
|
9987
|
-
|
|
9988
|
-
x:
|
|
9989
|
-
y:
|
|
9990
|
-
|
|
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
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
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
|
-
|
|
9999
|
-
|
|
10000
|
-
if (this.
|
|
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
|
-
|
|
10008
|
-
if (this.
|
|
10009
|
-
|
|
10010
|
-
|
|
10011
|
-
}
|
|
11362
|
+
cancelPress() {
|
|
11363
|
+
if (this.press === null) return;
|
|
11364
|
+
clearTimeout(this.press.timer);
|
|
11365
|
+
this.press = null;
|
|
10012
11366
|
}
|
|
10013
|
-
|
|
10014
|
-
|
|
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(
|
|
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/
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
10116
|
-
|
|
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
|
-
|
|
10124
|
-
|
|
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
|
-
|
|
10129
|
-
|
|
10130
|
-
|
|
10131
|
-
|
|
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
|
-
|
|
10136
|
-
|
|
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/
|
|
10142
|
-
var
|
|
10143
|
-
var
|
|
10144
|
-
var
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
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
|
-
|
|
10173
|
-
|
|
11518
|
+
get animating() {
|
|
11519
|
+
return this.to !== null;
|
|
10174
11520
|
}
|
|
10175
|
-
|
|
10176
|
-
|
|
11521
|
+
onEnd(listener) {
|
|
11522
|
+
this.endListeners.add(listener);
|
|
11523
|
+
return () => this.endListeners.delete(listener);
|
|
10177
11524
|
}
|
|
10178
|
-
|
|
10179
|
-
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
|
|
10185
|
-
|
|
10186
|
-
|
|
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
|
-
|
|
10190
|
-
this.
|
|
10191
|
-
|
|
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
|
-
|
|
10194
|
-
if (
|
|
10195
|
-
|
|
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
|
-
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
10208
|
-
|
|
10209
|
-
|
|
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
|
-
|
|
10212
|
-
|
|
10213
|
-
|
|
10214
|
-
|
|
10215
|
-
|
|
10216
|
-
|
|
10217
|
-
|
|
10218
|
-
|
|
10219
|
-
|
|
10220
|
-
|
|
10221
|
-
|
|
10222
|
-
if (this.
|
|
10223
|
-
|
|
10224
|
-
|
|
10225
|
-
|
|
10226
|
-
|
|
10227
|
-
this.
|
|
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
|
-
|
|
11596
|
+
return size;
|
|
10230
11597
|
}
|
|
10231
|
-
|
|
10232
|
-
if (
|
|
10233
|
-
this.
|
|
10234
|
-
|
|
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
|
|
10239
|
-
|
|
10240
|
-
|
|
10241
|
-
|
|
10242
|
-
|
|
10243
|
-
|
|
10244
|
-
|
|
10245
|
-
|
|
10246
|
-
}
|
|
10247
|
-
|
|
10248
|
-
|
|
10249
|
-
this.
|
|
10250
|
-
|
|
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
|
-
|
|
10253
|
-
|
|
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
|
-
|
|
10256
|
-
|
|
10257
|
-
|
|
10258
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
|
|
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/
|
|
10277
|
-
|
|
10278
|
-
|
|
10279
|
-
|
|
10280
|
-
|
|
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
|
-
|
|
10284
|
-
|
|
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
|
-
|
|
10303
|
-
|
|
10304
|
-
|
|
10305
|
-
|
|
10306
|
-
|
|
10307
|
-
|
|
10308
|
-
|
|
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/
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10377
|
-
return
|
|
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
|
|
10380
|
-
|
|
10381
|
-
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10387
|
-
this.
|
|
10388
|
-
this.
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
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
|
-
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
|
|
10397
|
-
|
|
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?.(
|
|
11756
|
+
ctx.setCursor?.("grab");
|
|
10402
11757
|
}
|
|
10403
11758
|
onDeactivate(ctx) {
|
|
10404
11759
|
ctx.setCursor?.("default");
|
|
10405
11760
|
}
|
|
10406
11761
|
onPointerDown(state, ctx) {
|
|
10407
|
-
this.
|
|
10408
|
-
this.
|
|
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.
|
|
10412
|
-
this.
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
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
|
-
|
|
10460
|
-
|
|
11773
|
+
onPointerUp(_state, ctx) {
|
|
11774
|
+
this.panning = false;
|
|
11775
|
+
ctx.setCursor?.("grab");
|
|
10461
11776
|
}
|
|
10462
11777
|
};
|
|
10463
11778
|
|
|
10464
|
-
// src/
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
|
|
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
|
-
|
|
10473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10530
|
-
|
|
10531
|
-
|
|
10532
|
-
|
|
10533
|
-
|
|
10534
|
-
|
|
10535
|
-
|
|
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
|
-
|
|
10573
|
-
|
|
10574
|
-
|
|
10575
|
-
|
|
10576
|
-
|
|
10577
|
-
|
|
10578
|
-
|
|
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
|
-
|
|
10581
|
-
|
|
10582
|
-
|
|
10583
|
-
|
|
10584
|
-
|
|
10585
|
-
|
|
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
|
-
|
|
10602
|
-
|
|
10603
|
-
|
|
10604
|
-
|
|
10605
|
-
|
|
10606
|
-
|
|
10607
|
-
|
|
10608
|
-
|
|
10609
|
-
|
|
10610
|
-
|
|
10611
|
-
|
|
10612
|
-
|
|
10613
|
-
|
|
10614
|
-
|
|
10615
|
-
|
|
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
|
-
|
|
10641
|
-
|
|
10642
|
-
|
|
10643
|
-
|
|
10644
|
-
|
|
10645
|
-
|
|
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
|
-
|
|
10698
|
-
|
|
10699
|
-
|
|
10700
|
-
|
|
10701
|
-
|
|
10702
|
-
|
|
10703
|
-
|
|
10704
|
-
|
|
10705
|
-
|
|
10706
|
-
|
|
10707
|
-
|
|
10708
|
-
|
|
10709
|
-
|
|
10710
|
-
|
|
10711
|
-
|
|
10712
|
-
ctx
|
|
10713
|
-
|
|
10714
|
-
|
|
10715
|
-
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
10722
|
-
|
|
10723
|
-
|
|
10724
|
-
|
|
10725
|
-
|
|
10726
|
-
|
|
10727
|
-
|
|
10728
|
-
|
|
10729
|
-
|
|
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
|
-
|
|
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
|
|
10761
|
-
|
|
10762
|
-
|
|
10763
|
-
|
|
10764
|
-
|
|
10765
|
-
|
|
10766
|
-
|
|
10767
|
-
|
|
10768
|
-
|
|
10769
|
-
|
|
10770
|
-
|
|
10771
|
-
|
|
10772
|
-
|
|
10773
|
-
|
|
10774
|
-
|
|
10775
|
-
|
|
10776
|
-
|
|
10777
|
-
if (
|
|
10778
|
-
|
|
10779
|
-
|
|
10780
|
-
|
|
10781
|
-
|
|
10782
|
-
|
|
10783
|
-
|
|
10784
|
-
|
|
10785
|
-
|
|
10786
|
-
|
|
10787
|
-
|
|
10788
|
-
|
|
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
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
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 (
|
|
10911
|
-
|
|
10912
|
-
|
|
11966
|
+
if (tLo > tHi) {
|
|
11967
|
+
if (current.length === 0) current.push(a);
|
|
11968
|
+
current.push(b);
|
|
11969
|
+
continue;
|
|
10913
11970
|
}
|
|
10914
|
-
|
|
10915
|
-
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
|
|
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
|
-
|
|
10933
|
-
|
|
10934
|
-
|
|
10935
|
-
|
|
11977
|
+
flush();
|
|
11978
|
+
}
|
|
11979
|
+
if (tHi < 1) {
|
|
11980
|
+
current = [lerp2(a, b, tHi), b];
|
|
10936
11981
|
}
|
|
10937
|
-
ctx.stroke();
|
|
10938
11982
|
}
|
|
10939
|
-
|
|
11983
|
+
flush();
|
|
11984
|
+
return erased ? runs : null;
|
|
10940
11985
|
}
|
|
10941
11986
|
|
|
10942
|
-
// src/tools/
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
const
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
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
|
-
|
|
10953
|
-
}
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
|
|
10958
|
-
|
|
10959
|
-
|
|
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
|
-
|
|
10964
|
-
|
|
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
|
-
|
|
10969
|
-
|
|
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
|
-
|
|
10973
|
-
|
|
12021
|
+
onPointerDown(state, ctx) {
|
|
12022
|
+
this.erasing = true;
|
|
12023
|
+
this.eraseAt(state, ctx);
|
|
10974
12024
|
}
|
|
10975
|
-
|
|
10976
|
-
|
|
12025
|
+
onPointerMove(state, ctx) {
|
|
12026
|
+
if (!this.erasing) return;
|
|
12027
|
+
this.eraseAt(state, ctx);
|
|
10977
12028
|
}
|
|
10978
|
-
|
|
10979
|
-
|
|
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
|
-
|
|
10984
|
-
}
|
|
10985
|
-
|
|
10986
|
-
|
|
10987
|
-
|
|
10988
|
-
|
|
10989
|
-
|
|
10990
|
-
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
const
|
|
10995
|
-
|
|
10996
|
-
|
|
10997
|
-
if (
|
|
10998
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11031
|
-
|
|
11032
|
-
|
|
11033
|
-
|
|
11034
|
-
const
|
|
11035
|
-
const
|
|
11036
|
-
for (const id of
|
|
11037
|
-
const
|
|
11038
|
-
if (
|
|
11039
|
-
|
|
11040
|
-
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
|
|
11044
|
-
if (
|
|
11045
|
-
|
|
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
|
|
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
|
-
|
|
11064
|
-
|
|
11065
|
-
|
|
11066
|
-
|
|
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
|
|
11078
|
-
|
|
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
|
|
11095
|
-
|
|
11096
|
-
const
|
|
11097
|
-
|
|
11098
|
-
|
|
11099
|
-
|
|
11100
|
-
|
|
11101
|
-
|
|
11102
|
-
|
|
11103
|
-
|
|
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
|
|
12126
|
+
return best;
|
|
11107
12127
|
}
|
|
11108
|
-
function
|
|
11109
|
-
|
|
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.
|
|
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,
|