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