@grida/svg-editor 1.0.0-alpha.2 → 1.0.0-alpha.20
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 +343 -189
- package/dist/chunk-D7D4PA-g.mjs +13 -0
- package/dist/dom-CQkWJNrK.d.ts +237 -0
- package/dist/dom-CfYDV311.js +5018 -0
- package/dist/dom-Dub-TMoN.mjs +4963 -0
- package/dist/dom-Dw2SPHgc.d.mts +239 -0
- package/dist/dom.d.mts +3 -16
- package/dist/dom.d.ts +3 -16
- package/dist/dom.js +9 -1
- package/dist/dom.mjs +2 -2
- package/dist/editor-4U9LAZ6r.js +2935 -0
- package/dist/editor-B1GmFnS9.mjs +2929 -0
- package/dist/editor-CcW4BVth.d.mts +2359 -0
- package/dist/editor-CxqRhhzP.d.ts +2359 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/index.mjs +3 -2
- package/dist/model-CzL6_zId.js +5513 -0
- package/dist/model-DS5MxDrd.mjs +5330 -0
- package/dist/presets.d.mts +61 -0
- package/dist/presets.d.ts +61 -0
- package/dist/presets.js +60 -0
- package/dist/presets.mjs +54 -0
- package/dist/react.d.mts +133 -12
- package/dist/react.d.ts +133 -12
- package/dist/react.js +214 -19
- package/dist/react.mjs +203 -21
- package/package.json +40 -9
- package/dist/dom-CfP_ZURh.js +0 -963
- package/dist/dom-kA8NDuVh.mjs +0 -929
- package/dist/editor-BryibVvr.d.mts +0 -612
- package/dist/editor-DllAMsDu.js +0 -1835
- package/dist/editor-M6j8XGO5.mjs +0 -1823
- package/dist/editor-klT8wu-x.d.ts +0 -612
- package/dist/paint-DHq_3iwU.js +0 -509
- package/dist/paint-DuCg6Y-K.mjs +0 -461
|
@@ -0,0 +1,4963 @@
|
|
|
1
|
+
import { S as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, h as transform, i as TOOL_CURSOR, l as RotateOrchestrator, m as group, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel, w as default_nudge_handler, x as array_shallow_equal } from "./model-DS5MxDrd.mjs";
|
|
2
|
+
import cmath from "@grida/cmath";
|
|
3
|
+
import { svg_parse } from "@grida/svg/parse";
|
|
4
|
+
import { SVGShapes } from "@grida/svg/pathdata";
|
|
5
|
+
import vn from "@grida/vn";
|
|
6
|
+
import { createTextEditor } from "@grida/text-editor/dom";
|
|
7
|
+
import { NO_MODS, Surface, measurementToHUDDraw, snapGuideToHUDDraw } from "@grida/hud";
|
|
8
|
+
import { cursors } from "@grida/hud/cursors";
|
|
9
|
+
import { measure } from "@grida/cmath/_measurement";
|
|
10
|
+
import { guide, snapToCanvasGeometry } from "@grida/cmath/_snap";
|
|
11
|
+
//#region src/core/camera.ts
|
|
12
|
+
/**
|
|
13
|
+
* Surface-scoped pan/zoom state.
|
|
14
|
+
*
|
|
15
|
+
* The public shape leads with the peer convention (`center` / `zoom` /
|
|
16
|
+
* `bounds`) and keeps the matrix as an advanced read. Methods mirror
|
|
17
|
+
* Figma/Penpot where they overlap.
|
|
18
|
+
*/
|
|
19
|
+
var Camera = class {
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.viewport_w = 0;
|
|
22
|
+
this.viewport_h = 0;
|
|
23
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
24
|
+
this._constraints = null;
|
|
25
|
+
this._transform = opts.initial ?? cmath.transform.identity;
|
|
26
|
+
this.resolve_bounds = opts.resolve_bounds;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Current viewport constraint, or `null` for free pan/zoom. Set with
|
|
30
|
+
* `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
|
|
31
|
+
* to clamp zoom + pan; assign `null` to clear.
|
|
32
|
+
*
|
|
33
|
+
* Constraints are applied synchronously inside `set_transform` (and
|
|
34
|
+
* `_set_viewport_size`), so every public mutation respects them
|
|
35
|
+
* automatically — the host never needs to subscribe-and-clamp itself.
|
|
36
|
+
*/
|
|
37
|
+
get constraints() {
|
|
38
|
+
return this._constraints;
|
|
39
|
+
}
|
|
40
|
+
set constraints(c) {
|
|
41
|
+
this._constraints = c;
|
|
42
|
+
if (c) this.reenforce();
|
|
43
|
+
}
|
|
44
|
+
/** Underlying 2D affine. World→screen. */
|
|
45
|
+
get transform() {
|
|
46
|
+
return this._transform;
|
|
47
|
+
}
|
|
48
|
+
/** Uniform scale factor. 1 = 100 %. */
|
|
49
|
+
get zoom() {
|
|
50
|
+
return this._transform[0][0];
|
|
51
|
+
}
|
|
52
|
+
/** World-space point currently at viewport center. */
|
|
53
|
+
get center() {
|
|
54
|
+
return this.screen_to_world({
|
|
55
|
+
x: this.viewport_w / 2,
|
|
56
|
+
y: this.viewport_h / 2
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** World-space rectangle visible in the viewport. */
|
|
60
|
+
get bounds() {
|
|
61
|
+
const tl = this.screen_to_world({
|
|
62
|
+
x: 0,
|
|
63
|
+
y: 0
|
|
64
|
+
});
|
|
65
|
+
const br = this.screen_to_world({
|
|
66
|
+
x: this.viewport_w,
|
|
67
|
+
y: this.viewport_h
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
x: tl.x,
|
|
71
|
+
y: tl.y,
|
|
72
|
+
width: br.x - tl.x,
|
|
73
|
+
height: br.y - tl.y
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Translate the camera by a screen-space delta. */
|
|
77
|
+
pan(delta_screen) {
|
|
78
|
+
const t = this._transform;
|
|
79
|
+
this.set_transform([[
|
|
80
|
+
t[0][0],
|
|
81
|
+
t[0][1],
|
|
82
|
+
t[0][2] + delta_screen.x
|
|
83
|
+
], [
|
|
84
|
+
t[1][0],
|
|
85
|
+
t[1][1],
|
|
86
|
+
t[1][2] + delta_screen.y
|
|
87
|
+
]]);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Multiply zoom by `factor` keeping `origin_screen` fixed in world space.
|
|
91
|
+
* Used by wheel-zoom-at-cursor and pinch-zoom.
|
|
92
|
+
*/
|
|
93
|
+
zoom_at(factor, origin_screen) {
|
|
94
|
+
const t = this._transform;
|
|
95
|
+
const s2 = t[0][0] * factor;
|
|
96
|
+
const tx2 = origin_screen.x * (1 - factor) + factor * t[0][2];
|
|
97
|
+
const ty2 = origin_screen.y * (1 - factor) + factor * t[1][2];
|
|
98
|
+
this.set_transform([[
|
|
99
|
+
s2,
|
|
100
|
+
0,
|
|
101
|
+
tx2
|
|
102
|
+
], [
|
|
103
|
+
0,
|
|
104
|
+
s2,
|
|
105
|
+
ty2
|
|
106
|
+
]]);
|
|
107
|
+
}
|
|
108
|
+
/** Pan so `c` lands at the viewport center. Zoom unchanged. */
|
|
109
|
+
set_center(c) {
|
|
110
|
+
const s = this._transform[0][0];
|
|
111
|
+
const tx = this.viewport_w / 2 - s * c.x;
|
|
112
|
+
const ty = this.viewport_h / 2 - s * c.y;
|
|
113
|
+
this.set_transform([[
|
|
114
|
+
s,
|
|
115
|
+
0,
|
|
116
|
+
tx
|
|
117
|
+
], [
|
|
118
|
+
0,
|
|
119
|
+
s,
|
|
120
|
+
ty
|
|
121
|
+
]]);
|
|
122
|
+
}
|
|
123
|
+
/** Set zoom directly; pivot defaults to viewport center. */
|
|
124
|
+
set_zoom(z, pivot_screen) {
|
|
125
|
+
const current = this._transform[0][0];
|
|
126
|
+
if (current === 0) return;
|
|
127
|
+
const factor = z / current;
|
|
128
|
+
const pivot = pivot_screen ?? {
|
|
129
|
+
x: this.viewport_w / 2,
|
|
130
|
+
y: this.viewport_h / 2
|
|
131
|
+
};
|
|
132
|
+
this.zoom_at(factor, pivot);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Replace the entire transform.
|
|
136
|
+
*
|
|
137
|
+
* Idempotent: when the new transform is element-wise equal to the current
|
|
138
|
+
* one, this is a no-op (no notification fires). This is the seam that
|
|
139
|
+
* makes external constraint loops (e.g. "subscribe → compute clamped →
|
|
140
|
+
* set_transform") terminate: the clamp re-emits the same transform on
|
|
141
|
+
* the second pass, set_transform short-circuits, no recursion.
|
|
142
|
+
*
|
|
143
|
+
* When `camera.constraints` is non-null, the input transform is clamped
|
|
144
|
+
* synchronously before being stored — every public mutation respects the
|
|
145
|
+
* constraint automatically.
|
|
146
|
+
*/
|
|
147
|
+
set_transform(t) {
|
|
148
|
+
const next = this.apply_constraints(t);
|
|
149
|
+
if (transform_equal(this._transform, next)) return;
|
|
150
|
+
this._transform = next;
|
|
151
|
+
this.notify();
|
|
152
|
+
}
|
|
153
|
+
/** Viewport size in screen pixels. Read by host code computing constraints. */
|
|
154
|
+
get viewport_size() {
|
|
155
|
+
return {
|
|
156
|
+
width: this.viewport_w,
|
|
157
|
+
height: this.viewport_h
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Fit a target into the viewport.
|
|
162
|
+
*
|
|
163
|
+
* - `"<root>"` — the document root's content bounds (host-resolved).
|
|
164
|
+
* - `"<selection>"` — current editor.state.selection's union bounds.
|
|
165
|
+
* - `NodeId` — that node's content bounds.
|
|
166
|
+
* - `Rect` — an explicit world-space rectangle.
|
|
167
|
+
*
|
|
168
|
+
* No-ops if the target resolves to `null` (e.g. empty selection) or if
|
|
169
|
+
* the viewport size is 0 (no container).
|
|
170
|
+
*/
|
|
171
|
+
fit(target, opts) {
|
|
172
|
+
if (this.viewport_w <= 0 || this.viewport_h <= 0) return;
|
|
173
|
+
const rect = typeof target === "string" ? this.resolve_bounds(target) : target;
|
|
174
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
175
|
+
const margin = opts?.margin ?? 64;
|
|
176
|
+
const viewport = {
|
|
177
|
+
x: 0,
|
|
178
|
+
y: 0,
|
|
179
|
+
width: this.viewport_w,
|
|
180
|
+
height: this.viewport_h
|
|
181
|
+
};
|
|
182
|
+
this.set_transform(cmath.ext.viewport.transformToFit(viewport, rect, margin));
|
|
183
|
+
}
|
|
184
|
+
/** Snap back to identity. */
|
|
185
|
+
reset() {
|
|
186
|
+
this.set_transform(cmath.transform.identity);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Subscribe to camera changes. Fires on every mutation. Cheap channel —
|
|
190
|
+
* does NOT bump `editor.state.version`. Same pattern as
|
|
191
|
+
* `editor.subscribe_surface_hover`.
|
|
192
|
+
*/
|
|
193
|
+
subscribe(cb) {
|
|
194
|
+
this.listeners.add(cb);
|
|
195
|
+
return () => {
|
|
196
|
+
this.listeners.delete(cb);
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/** @internal Surface drives this on container resize. */
|
|
200
|
+
_set_viewport_size(w, h) {
|
|
201
|
+
if (w === this.viewport_w && h === this.viewport_h) return;
|
|
202
|
+
this.viewport_w = w;
|
|
203
|
+
this.viewport_h = h;
|
|
204
|
+
if (this._constraints) {
|
|
205
|
+
const next = this.apply_constraints(this._transform);
|
|
206
|
+
if (!transform_equal(this._transform, next)) this._transform = next;
|
|
207
|
+
}
|
|
208
|
+
this.notify();
|
|
209
|
+
}
|
|
210
|
+
/** Convert a screen-space point to world-space. */
|
|
211
|
+
screen_to_world(p) {
|
|
212
|
+
const inv = cmath.transform.invert(this._transform);
|
|
213
|
+
const [wx, wy] = cmath.vector2.transform([p.x, p.y], inv);
|
|
214
|
+
return {
|
|
215
|
+
x: wx,
|
|
216
|
+
y: wy
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Convert a world-space point to screen-space. */
|
|
220
|
+
world_to_screen(p) {
|
|
221
|
+
const [sx, sy] = cmath.vector2.transform([p.x, p.y], this._transform);
|
|
222
|
+
return {
|
|
223
|
+
x: sx,
|
|
224
|
+
y: sy
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Apply the current constraint (if any) to a candidate transform.
|
|
229
|
+
* Pure: returns the clamped result, never mutates state. Returns the
|
|
230
|
+
* input unchanged when constraints are null / bounds are unresolvable /
|
|
231
|
+
* viewport is 0.
|
|
232
|
+
*/
|
|
233
|
+
apply_constraints(t) {
|
|
234
|
+
if (!this._constraints) return t;
|
|
235
|
+
if (this.viewport_w <= 0 || this.viewport_h <= 0) return t;
|
|
236
|
+
switch (this._constraints.type) {
|
|
237
|
+
case "cover": return clamp_cover(t, this._constraints, this.viewport_w, this.viewport_h, this.resolve_bounds);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Re-clamp the stored transform against the current constraint. Called
|
|
242
|
+
* from the `constraints` setter; `_set_viewport_size` has its own
|
|
243
|
+
* notify-inclusive path.
|
|
244
|
+
*/
|
|
245
|
+
reenforce() {
|
|
246
|
+
if (!this._constraints) return;
|
|
247
|
+
const next = this.apply_constraints(this._transform);
|
|
248
|
+
if (transform_equal(this._transform, next)) return;
|
|
249
|
+
this._transform = next;
|
|
250
|
+
this.notify();
|
|
251
|
+
}
|
|
252
|
+
notify() {
|
|
253
|
+
for (const cb of this.listeners) cb();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Clamp a transform under a `'cover'` constraint:
|
|
258
|
+
* - Zoom lower-bounded at fit-with-padding (the slide always fills the
|
|
259
|
+
* viewport edge-to-edge).
|
|
260
|
+
* - When at min-zoom the slide is locked centered (bounds smaller than
|
|
261
|
+
* viewport on the constrained axis is impossible above min_zoom; below
|
|
262
|
+
* is impossible because zoom is clamped up).
|
|
263
|
+
* - When zoomed in, pan is clamped so the slide always covers the viewport
|
|
264
|
+
* (no black bars).
|
|
265
|
+
*
|
|
266
|
+
* Returns the input transform unchanged when bounds can't be resolved or
|
|
267
|
+
* are degenerate.
|
|
268
|
+
*/
|
|
269
|
+
function clamp_cover(t, c, vp_w, vp_h, resolve) {
|
|
270
|
+
const bounds = typeof c.bounds === "string" ? resolve(c.bounds) : c.bounds;
|
|
271
|
+
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return t;
|
|
272
|
+
const padding = c.padding ?? 0;
|
|
273
|
+
const eff_w = vp_w - 2 * padding;
|
|
274
|
+
const eff_h = vp_h - 2 * padding;
|
|
275
|
+
if (eff_w <= 0 || eff_h <= 0) return t;
|
|
276
|
+
const min_zoom = Math.min(eff_w / bounds.width, eff_h / bounds.height);
|
|
277
|
+
const s = Math.max(t[0][0], min_zoom);
|
|
278
|
+
const sw = s * bounds.width;
|
|
279
|
+
const sh = s * bounds.height;
|
|
280
|
+
const o = Math.max(0, c.pan_overshoot ?? 0);
|
|
281
|
+
const tx = sw > vp_w ? cmath.clamp(t[0][2], vp_w - s * (bounds.x + bounds.width) - o, -s * bounds.x + o) : (vp_w - sw) / 2 - s * bounds.x;
|
|
282
|
+
const ty = sh > vp_h ? cmath.clamp(t[1][2], vp_h - s * (bounds.y + bounds.height) - o, -s * bounds.y + o) : (vp_h - sh) / 2 - s * bounds.y;
|
|
283
|
+
return [[
|
|
284
|
+
s,
|
|
285
|
+
0,
|
|
286
|
+
tx
|
|
287
|
+
], [
|
|
288
|
+
0,
|
|
289
|
+
s,
|
|
290
|
+
ty
|
|
291
|
+
]];
|
|
292
|
+
}
|
|
293
|
+
function transform_equal(a, b) {
|
|
294
|
+
return a[0][0] === b[0][0] && a[0][1] === b[0][1] && a[0][2] === b[0][2] && a[1][0] === b[1][0] && a[1][1] === b[1][1] && a[1][2] === b[1][2];
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/core/geometry.ts
|
|
298
|
+
/**
|
|
299
|
+
* Caches `bounds_of` results keyed on `NodeId`; full-clears on either
|
|
300
|
+
* `structure_version` or `geometry_version` bump. See ../../docs/geometry.md for
|
|
301
|
+
* why the cache is load-bearing under the surface's per-tick re-render.
|
|
302
|
+
*/
|
|
303
|
+
var MemoizedGeometryProvider = class {
|
|
304
|
+
constructor(driver, signals) {
|
|
305
|
+
this.unsubscribers = [];
|
|
306
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
307
|
+
this.driver = driver;
|
|
308
|
+
const invalidate = () => this.cache.clear();
|
|
309
|
+
this.unsubscribers.push(signals.subscribe_structure(invalidate));
|
|
310
|
+
this.unsubscribers.push(signals.subscribe_geometry(invalidate));
|
|
311
|
+
}
|
|
312
|
+
bounds_of(id) {
|
|
313
|
+
if (this.cache.has(id)) return this.cache.get(id) ?? null;
|
|
314
|
+
const r = this.driver.bounds_of(id);
|
|
315
|
+
this.cache.set(id, r);
|
|
316
|
+
return r;
|
|
317
|
+
}
|
|
318
|
+
bounds_of_many(ids) {
|
|
319
|
+
const out = /* @__PURE__ */ new Map();
|
|
320
|
+
const missing = [];
|
|
321
|
+
for (const id of ids) if (this.cache.has(id)) {
|
|
322
|
+
const r = this.cache.get(id);
|
|
323
|
+
if (r) out.set(id, r);
|
|
324
|
+
} else missing.push(id);
|
|
325
|
+
if (missing.length > 0) {
|
|
326
|
+
const fresh = this.driver.bounds_of_many(missing);
|
|
327
|
+
for (const id of missing) {
|
|
328
|
+
const r = fresh.get(id) ?? null;
|
|
329
|
+
this.cache.set(id, r);
|
|
330
|
+
if (r) out.set(id, r);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Pass-through. These are less hot than `bounds_of` (called once per
|
|
337
|
+
* gesture frame at most) and their result is sensitive to current
|
|
338
|
+
* viewport state, so caching them would be a footgun.
|
|
339
|
+
*/
|
|
340
|
+
nodes_in_rect(rect) {
|
|
341
|
+
return this.driver.nodes_in_rect(rect);
|
|
342
|
+
}
|
|
343
|
+
node_at_point(p) {
|
|
344
|
+
return this.driver.node_at_point(p);
|
|
345
|
+
}
|
|
346
|
+
/** Pass-through. Frame projection depends on live layout, not on the
|
|
347
|
+
* bounds cache, so there is nothing to memoize. Falls back to the raw
|
|
348
|
+
* delta when the driver can't resolve a frame. */
|
|
349
|
+
world_delta_to_local(id, delta) {
|
|
350
|
+
return this.driver.world_delta_to_local?.(id, delta) ?? delta;
|
|
351
|
+
}
|
|
352
|
+
/** Unsubscribe from both signals. Call on surface detach. */
|
|
353
|
+
dispose() {
|
|
354
|
+
for (const unsub of this.unsubscribers) unsub();
|
|
355
|
+
this.unsubscribers.length = 0;
|
|
356
|
+
this.cache.clear();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/core/hit-shape.ts
|
|
361
|
+
/**
|
|
362
|
+
* Shortest distance from world-space `p` to `shape`. Returns `0` for
|
|
363
|
+
* points strictly inside a filled region (rect / ellipse / polygon
|
|
364
|
+
* interior); the picker treats this as "inside the fill" — an exact
|
|
365
|
+
* hit, no tolerance required.
|
|
366
|
+
*
|
|
367
|
+
* For `path`, this is *edge* distance (no interior test). v1 doesn't
|
|
368
|
+
* model winding-rule fill regions for paths; see docs/wg/feat-svg-editor/hit-test.md.
|
|
369
|
+
*/
|
|
370
|
+
function point_distance_to_shape(p, shape) {
|
|
371
|
+
const pv = [p.x, p.y];
|
|
372
|
+
switch (shape.kind) {
|
|
373
|
+
case "rect": {
|
|
374
|
+
const r = {
|
|
375
|
+
x: shape.x,
|
|
376
|
+
y: shape.y,
|
|
377
|
+
width: shape.width,
|
|
378
|
+
height: shape.height
|
|
379
|
+
};
|
|
380
|
+
if (cmath.rect.containsPoint(r, pv)) return 0;
|
|
381
|
+
const [dx, dy] = cmath.rect.offset(r, pv);
|
|
382
|
+
return Math.hypot(dx, dy);
|
|
383
|
+
}
|
|
384
|
+
case "ellipse": {
|
|
385
|
+
if (shape.rx <= 0 || shape.ry <= 0) return Math.hypot(p.x - shape.cx, p.y - shape.cy);
|
|
386
|
+
const nx = (p.x - shape.cx) / shape.rx;
|
|
387
|
+
const ny = (p.y - shape.cy) / shape.ry;
|
|
388
|
+
const r_norm = Math.hypot(nx, ny);
|
|
389
|
+
if (r_norm <= 1) return 0;
|
|
390
|
+
return (r_norm - 1) * Math.min(shape.rx, shape.ry);
|
|
391
|
+
}
|
|
392
|
+
case "segment": return cmath.segment.point_distance(pv, [shape.a.x, shape.a.y], [shape.b.x, shape.b.y]);
|
|
393
|
+
case "polyline": return cmath.polyline.point_distance(pv, shape.pts.map((q) => [q.x, q.y]), false);
|
|
394
|
+
case "polygon": {
|
|
395
|
+
const ring = shape.pts.map((q) => [q.x, q.y]);
|
|
396
|
+
if (ring.length >= 3 && cmath.polygon.pointInPolygon(pv, ring)) return 0;
|
|
397
|
+
return cmath.polyline.point_distance(pv, ring, true);
|
|
398
|
+
}
|
|
399
|
+
case "path": return cmath.polyline.point_distance(pv, shape.pts.map((q) => [q.x, q.y]), shape.closed);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Topmost node within `tolerance_world` of `p`. Returns `null` if no
|
|
404
|
+
* candidate qualifies.
|
|
405
|
+
*
|
|
406
|
+
* Algorithm:
|
|
407
|
+
* 1. Walk `ordered_ids` topmost-first.
|
|
408
|
+
* 2. AABB pre-filter via `cmath.rect.pad(bounds_of(id), tolerance)`.
|
|
409
|
+
* 3. Distance check via `point_distance_to_shape`.
|
|
410
|
+
* 4. First candidate with `dist <= tolerance` wins.
|
|
411
|
+
*
|
|
412
|
+
* Candidates whose `hit_shape_of` returns `null` are skipped. Candidates
|
|
413
|
+
* whose `bounds_of` returns `null` are skipped.
|
|
414
|
+
*/
|
|
415
|
+
function pick_at_world(p, opts) {
|
|
416
|
+
const { tolerance_world, ordered_ids, bounds_of, hit_shape_of } = opts;
|
|
417
|
+
const pv = [p.x, p.y];
|
|
418
|
+
for (let i = ordered_ids.length - 1; i >= 0; i--) {
|
|
419
|
+
const id = ordered_ids[i];
|
|
420
|
+
const bounds = bounds_of(id);
|
|
421
|
+
if (!bounds) continue;
|
|
422
|
+
const inflated = cmath.rect.pad(bounds, tolerance_world);
|
|
423
|
+
if (!cmath.rect.containsPoint(inflated, pv)) continue;
|
|
424
|
+
const shape = hit_shape_of(id);
|
|
425
|
+
if (!shape) continue;
|
|
426
|
+
if (point_distance_to_shape(p, shape) <= tolerance_world) return id;
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
/** Caches `hit_shape_of` results keyed on NodeId; full-clears on either
|
|
431
|
+
* `structure_version` or `geometry_version` bump. Mirror of
|
|
432
|
+
* `MemoizedGeometryProvider` in `core/geometry.ts`. */
|
|
433
|
+
var MemoizedHitShapeProvider = class {
|
|
434
|
+
constructor(driver, signals) {
|
|
435
|
+
this.unsubscribers = [];
|
|
436
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
437
|
+
this.driver = driver;
|
|
438
|
+
const invalidate = () => this.cache.clear();
|
|
439
|
+
this.unsubscribers.push(signals.subscribe_structure(invalidate));
|
|
440
|
+
this.unsubscribers.push(signals.subscribe_geometry(invalidate));
|
|
441
|
+
}
|
|
442
|
+
hit_shape_of(id) {
|
|
443
|
+
if (this.cache.has(id)) return this.cache.get(id) ?? null;
|
|
444
|
+
const s = this.driver.hit_shape_of(id);
|
|
445
|
+
this.cache.set(id, s);
|
|
446
|
+
return s;
|
|
447
|
+
}
|
|
448
|
+
dispose() {
|
|
449
|
+
for (const unsub of this.unsubscribers) unsub();
|
|
450
|
+
this.unsubscribers.length = 0;
|
|
451
|
+
this.cache.clear();
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/core/snap/session.ts
|
|
456
|
+
/**
|
|
457
|
+
* Sub-pixel tolerance used as the effective threshold for `"aligned"`
|
|
458
|
+
* policy. cmath's `snap1D` only fires when |delta| ≤ threshold, so
|
|
459
|
+
* setting the threshold near zero means the engine only matches
|
|
460
|
+
* alignments that are already exact (within rounding noise). We can't
|
|
461
|
+
* use a post-hoc `correction == 0` check instead — `snap1D` locks in
|
|
462
|
+
* the FIRST signed delta it finds (lexicographic over the 9-point
|
|
463
|
+
* order), so a touching pair where `agent.left↔anchor.left` needs +10
|
|
464
|
+
* while `agent.right↔anchor.left` needs 0 will return correction = 10,
|
|
465
|
+
* not 0. The tiny-threshold trick lets only the 0-delta matches pass.
|
|
466
|
+
*/
|
|
467
|
+
const ALIGNED_THRESHOLD = .5;
|
|
468
|
+
/**
|
|
469
|
+
* Gesture-scoped snap state. Constructor freezes agent + neighbor rects
|
|
470
|
+
* once; `snap()` runs the cmath engine per frame against the frozen
|
|
471
|
+
* inputs. Rects are in whatever space the caller chose (svg-editor uses
|
|
472
|
+
* HUD-container CSS px); the engine is space-agnostic as long as agents,
|
|
473
|
+
* neighbors, delta, and threshold all share that space.
|
|
474
|
+
*/
|
|
475
|
+
var SnapSession = class {
|
|
476
|
+
constructor(opts) {
|
|
477
|
+
this._last_guide = void 0;
|
|
478
|
+
const live_agents = opts.agents.filter((r) => r.width > 0 || r.height > 0);
|
|
479
|
+
const live_neighbors = opts.neighbors.filter((r) => r.width > 0 || r.height > 0);
|
|
480
|
+
this.agents = live_agents;
|
|
481
|
+
this.anchors = live_neighbors;
|
|
482
|
+
const xs = [];
|
|
483
|
+
const ys = [];
|
|
484
|
+
for (const a of live_neighbors) {
|
|
485
|
+
xs.push(a.x, a.x + a.width / 2, a.x + a.width);
|
|
486
|
+
ys.push(a.y, a.y + a.height / 2, a.y + a.height);
|
|
487
|
+
}
|
|
488
|
+
this.anchor_xs = xs;
|
|
489
|
+
this.anchor_ys = ys;
|
|
490
|
+
this.baseline_union = live_agents.length > 0 ? cmath.rect.union([...live_agents]) : null;
|
|
491
|
+
}
|
|
492
|
+
/** Read-only snapshot of the most recent guide. Host code consumes
|
|
493
|
+
* this from `compute_snap_extra()` style helpers. */
|
|
494
|
+
get last_guide() {
|
|
495
|
+
return this._last_guide;
|
|
496
|
+
}
|
|
497
|
+
/** Read-only snapshot of the pre-translation agent-union rect.
|
|
498
|
+
* Consumed by the translate pipeline's `stage_pixel_grid` to anchor
|
|
499
|
+
* the integer-grid quantization on the gesture's starting origin
|
|
500
|
+
* (so a rect at `x=0.5` settles to `x=1` after first nudge, not on
|
|
501
|
+
* every fractional drag). Returns `null` when no agents were frozen. */
|
|
502
|
+
get baseline_union_readonly() {
|
|
503
|
+
return this.baseline_union;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Run snap for a candidate cumulative delta.
|
|
507
|
+
*
|
|
508
|
+
* Returns the corrected delta (== input when no snap fires or snap
|
|
509
|
+
* is disabled) and a `SnapGuide` for HUD rendering (`undefined` when
|
|
510
|
+
* no guide should be drawn). Guide emission is governed by `policy`
|
|
511
|
+
* — see `SnapGuidePolicy`.
|
|
512
|
+
*
|
|
513
|
+
* The same guide is also stashed on `last_guide`.
|
|
514
|
+
*/
|
|
515
|
+
snap(delta, opts, policy = "engine") {
|
|
516
|
+
const baseline = this.baseline_union;
|
|
517
|
+
if (!opts.enabled || !baseline || !this.agents || !this.anchors || this.anchors.length === 0) {
|
|
518
|
+
this._last_guide = void 0;
|
|
519
|
+
return {
|
|
520
|
+
delta,
|
|
521
|
+
guide: void 0
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const dx = delta.x;
|
|
525
|
+
const dy = delta.y;
|
|
526
|
+
const translated = [];
|
|
527
|
+
for (const r of this.agents) translated.push({
|
|
528
|
+
x: r.x + dx,
|
|
529
|
+
y: r.y + dy,
|
|
530
|
+
width: r.width,
|
|
531
|
+
height: r.height
|
|
532
|
+
});
|
|
533
|
+
const agent_rect = cmath.rect.union(translated);
|
|
534
|
+
const padX = opts.threshold_px + agent_rect.width;
|
|
535
|
+
const padY = opts.threshold_px + agent_rect.height;
|
|
536
|
+
const minX = agent_rect.x - padX;
|
|
537
|
+
const maxX = agent_rect.x + agent_rect.width + padX;
|
|
538
|
+
const minY = agent_rect.y - padY;
|
|
539
|
+
const maxY = agent_rect.y + agent_rect.height + padY;
|
|
540
|
+
const nearby = [];
|
|
541
|
+
for (const r of this.anchors) {
|
|
542
|
+
const rMaxX = r.x + r.width;
|
|
543
|
+
const rMaxY = r.y + r.height;
|
|
544
|
+
const x_overlap = rMaxX >= minX && r.x <= maxX;
|
|
545
|
+
const y_overlap = rMaxY >= minY && r.y <= maxY;
|
|
546
|
+
if (x_overlap || y_overlap) nearby.push(r);
|
|
547
|
+
}
|
|
548
|
+
const result = snapToCanvasGeometry(agent_rect, { objects: nearby }, {
|
|
549
|
+
x: opts.threshold_px,
|
|
550
|
+
y: opts.threshold_px
|
|
551
|
+
});
|
|
552
|
+
const corrected = result.by_objects ? {
|
|
553
|
+
x: result.by_objects.translated.x - baseline.x,
|
|
554
|
+
y: result.by_objects.translated.y - baseline.y
|
|
555
|
+
} : {
|
|
556
|
+
x: dx,
|
|
557
|
+
y: dy
|
|
558
|
+
};
|
|
559
|
+
const guide_source = policy === "aligned" ? snapToCanvasGeometry(agent_rect, { objects: nearby }, {
|
|
560
|
+
x: ALIGNED_THRESHOLD,
|
|
561
|
+
y: ALIGNED_THRESHOLD
|
|
562
|
+
}) : result;
|
|
563
|
+
const sg = guide.plot(guide_source);
|
|
564
|
+
const guide$1 = sg.lines.length > 0 || sg.points.length > 0 || sg.rules.length > 0 ? sg : void 0;
|
|
565
|
+
this._last_guide = guide$1;
|
|
566
|
+
return {
|
|
567
|
+
delta: corrected,
|
|
568
|
+
guide: guide$1
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Resize-snap pass.
|
|
573
|
+
*
|
|
574
|
+
* Snaps the *moving edges* of an effective rect (post per-element
|
|
575
|
+
* constraint) against the frozen neighbor anchors. The caller is
|
|
576
|
+
* responsible for computing the effective rect — see
|
|
577
|
+
* `effective_resize` in `../resize-capability.ts`. This entrypoint
|
|
578
|
+
* does NOT understand circle / text uniformity; it only sees the rect
|
|
579
|
+
* and the edge mask.
|
|
580
|
+
*
|
|
581
|
+
* Per-axis snap candidates are the neighbors' left / center-x / right
|
|
582
|
+
* (for x) and top / center-y / bottom (for y), exactly what
|
|
583
|
+
* translate-snap uses. Only the moving edge participates as the agent
|
|
584
|
+
* — the opposite edge is fixed by the resize anchor and must not
|
|
585
|
+
* contribute corrections.
|
|
586
|
+
*
|
|
587
|
+
* Returns signed corrections in the rect's space. Guide is a SnapGuide
|
|
588
|
+
* compatible with `snapGuideToHUDDraw`.
|
|
589
|
+
*/
|
|
590
|
+
snap_resize(effective_rect, edges, opts) {
|
|
591
|
+
if (!opts.enabled || !this.anchor_xs || this.anchor_xs.length === 0 || edges.x === null && edges.y === null) {
|
|
592
|
+
this._last_guide = void 0;
|
|
593
|
+
return {
|
|
594
|
+
dx: 0,
|
|
595
|
+
dy: 0,
|
|
596
|
+
guide: void 0
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const agent_x = edges.x === "left" ? effective_rect.x : edges.x === "right" ? effective_rect.x + effective_rect.width : null;
|
|
600
|
+
const agent_y = edges.y === "top" ? effective_rect.y : edges.y === "bottom" ? effective_rect.y + effective_rect.height : null;
|
|
601
|
+
let dx = 0;
|
|
602
|
+
let dy = 0;
|
|
603
|
+
let hit_x_offset = null;
|
|
604
|
+
let hit_y_offset = null;
|
|
605
|
+
if (agent_x !== null) {
|
|
606
|
+
const r = cmath.ext.snap.snap1D([agent_x], this.anchor_xs, opts.threshold_px);
|
|
607
|
+
if (Number.isFinite(r.distance)) {
|
|
608
|
+
dx = r.distance;
|
|
609
|
+
hit_x_offset = agent_x + r.distance;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (agent_y !== null && this.anchor_ys) {
|
|
613
|
+
const r = cmath.ext.snap.snap1D([agent_y], this.anchor_ys, opts.threshold_px);
|
|
614
|
+
if (Number.isFinite(r.distance)) {
|
|
615
|
+
dy = r.distance;
|
|
616
|
+
hit_y_offset = agent_y + r.distance;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
let guide = void 0;
|
|
620
|
+
if (hit_x_offset !== null || hit_y_offset !== null) {
|
|
621
|
+
const rules = [];
|
|
622
|
+
if (hit_x_offset !== null) rules.push(["x", hit_x_offset]);
|
|
623
|
+
if (hit_y_offset !== null) rules.push(["y", hit_y_offset]);
|
|
624
|
+
guide = {
|
|
625
|
+
lines: [],
|
|
626
|
+
points: [],
|
|
627
|
+
rules
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
this._last_guide = guide;
|
|
631
|
+
return {
|
|
632
|
+
dx,
|
|
633
|
+
dy,
|
|
634
|
+
guide
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/** Release frozen refs. After dispose, `snap()` returns identity and
|
|
638
|
+
* `last_guide` is undefined. */
|
|
639
|
+
dispose() {
|
|
640
|
+
this.agents = null;
|
|
641
|
+
this.anchors = null;
|
|
642
|
+
this.anchor_xs = null;
|
|
643
|
+
this.anchor_ys = null;
|
|
644
|
+
this.baseline_union = null;
|
|
645
|
+
this._last_guide = void 0;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/core/snap/neighborhood.ts
|
|
650
|
+
/** Tags whose subtree is never rendered (resource containers). Children
|
|
651
|
+
* inside these are referenced via `<use>` or `clip-path`, not drawn
|
|
652
|
+
* directly, so they must not surface as snap targets. */
|
|
653
|
+
const NON_RENDERED_CONTAINER_TAGS = new Set([
|
|
654
|
+
"defs",
|
|
655
|
+
"symbol",
|
|
656
|
+
"clipPath",
|
|
657
|
+
"mask",
|
|
658
|
+
"pattern",
|
|
659
|
+
"marker",
|
|
660
|
+
"filter",
|
|
661
|
+
"linearGradient",
|
|
662
|
+
"radialGradient"
|
|
663
|
+
]);
|
|
664
|
+
/** Self-only render check. Does NOT walk ancestors — callers that descend
|
|
665
|
+
* from a known-rendered root already inherit ancestor-rendered as an
|
|
666
|
+
* invariant. Returns `false` for `display="none"`, `visibility="hidden"`,
|
|
667
|
+
* and for resource-container tags whose contents are never drawn. */
|
|
668
|
+
function is_self_rendered(doc, id) {
|
|
669
|
+
const tag = doc.tag_of(id);
|
|
670
|
+
if (NON_RENDERED_CONTAINER_TAGS.has(tag)) return false;
|
|
671
|
+
if (doc.get_attr(id, "display") === "none") return false;
|
|
672
|
+
if (doc.get_attr(id, "visibility") === "hidden") return false;
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
/** Recursive descent into a `<g>`. Pushes every rendered structural
|
|
676
|
+
* descendant — including nested `<g>` ids themselves — into `out`.
|
|
677
|
+
* Caller is responsible for adding the root id. */
|
|
678
|
+
function collect_rendered_subtree(doc, parent, out) {
|
|
679
|
+
for (const child of doc.element_children_of(parent)) {
|
|
680
|
+
if (!is_self_rendered(doc, child)) continue;
|
|
681
|
+
if (!group.STRUCTURAL_GRAPHICS.has(doc.tag_of(child))) continue;
|
|
682
|
+
out.add(child);
|
|
683
|
+
if (doc.tag_of(child) === "g") collect_rendered_subtree(doc, child, out);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Expand a single id into its snap-candidate id set.
|
|
688
|
+
*
|
|
689
|
+
* - Non-`<g>`: returns `[id]`.
|
|
690
|
+
* - `<g>`: returns `[id, ...rendered structural descendants]` — the
|
|
691
|
+
* group's own bbox stays in the set (preserving group-to-group
|
|
692
|
+
* alignment) AND every rendered leaf inside it becomes its own
|
|
693
|
+
* candidate. Nested `<g>` ids are included as bboxes; their
|
|
694
|
+
* children flatten in too.
|
|
695
|
+
*
|
|
696
|
+
* Editor-agnostic w.r.t. rect resolution — this returns ids only.
|
|
697
|
+
* Callers feed the ids to the geometry provider to get rects.
|
|
698
|
+
*/
|
|
699
|
+
function snap_descent(doc, id) {
|
|
700
|
+
if (doc.tag_of(id) !== "g") return [id];
|
|
701
|
+
if (!is_self_rendered(doc, id)) return [];
|
|
702
|
+
const out = new Set([id]);
|
|
703
|
+
collect_rendered_subtree(doc, id, out);
|
|
704
|
+
return [...out];
|
|
705
|
+
}
|
|
706
|
+
function compute_neighborhood(doc, dragged) {
|
|
707
|
+
if (dragged.length === 0) return [];
|
|
708
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
709
|
+
for (const id of dragged) {
|
|
710
|
+
excluded.add(id);
|
|
711
|
+
if (doc.tag_of(id) === "g") collect_rendered_subtree(doc, id, excluded);
|
|
712
|
+
}
|
|
713
|
+
const out = /* @__PURE__ */ new Set();
|
|
714
|
+
for (const id of dragged) {
|
|
715
|
+
const parent = doc.parent_of(id);
|
|
716
|
+
if (parent === null) continue;
|
|
717
|
+
if (!excluded.has(parent) && group.STRUCTURAL_GRAPHICS.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
|
|
718
|
+
for (const sib of doc.element_children_of(parent)) {
|
|
719
|
+
if (excluded.has(sib)) continue;
|
|
720
|
+
if (!group.STRUCTURAL_GRAPHICS.has(doc.tag_of(sib))) continue;
|
|
721
|
+
if (!is_self_rendered(doc, sib)) continue;
|
|
722
|
+
for (const inner of snap_descent(doc, sib)) {
|
|
723
|
+
if (excluded.has(inner)) continue;
|
|
724
|
+
out.add(inner);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return [...out];
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/core/snap/options.ts
|
|
732
|
+
const DEFAULT_SNAP_OPTIONS = {
|
|
733
|
+
enabled: true,
|
|
734
|
+
threshold_px: 6
|
|
735
|
+
};
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/core/text-edit.ts
|
|
738
|
+
/**
|
|
739
|
+
* Decide what happens when inline text content-editing exits.
|
|
740
|
+
*
|
|
741
|
+
* `result` is the text that should remain — the typed text on commit, the
|
|
742
|
+
* original on cancel. "Empty" means zero-length: a space is authored
|
|
743
|
+
* content and is kept. The rule is unconditional — an empty result deletes
|
|
744
|
+
* the node however it got there (freshly placed and never typed, cleared by
|
|
745
|
+
* the author, or already empty on entry).
|
|
746
|
+
*
|
|
747
|
+
* See docs/wg/feat-svg-editor/text-tool.md.
|
|
748
|
+
*/
|
|
749
|
+
function resolve_text_exit(input) {
|
|
750
|
+
const is_empty = input.result.length === 0;
|
|
751
|
+
if (input.origin === "fresh") return is_empty ? { kind: "discard_insert" } : { kind: "commit_insert" };
|
|
752
|
+
if (is_empty) return { kind: "remove" };
|
|
753
|
+
if (input.result !== input.original) return {
|
|
754
|
+
kind: "set_text",
|
|
755
|
+
value: input.result
|
|
756
|
+
};
|
|
757
|
+
return { kind: "noop" };
|
|
758
|
+
}
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/selection/marquee.ts
|
|
761
|
+
let marquee_selection;
|
|
762
|
+
(function(_marquee_selection) {
|
|
763
|
+
function hits(boxes, rect) {
|
|
764
|
+
const touched = boxes.filter(([, box]) => cmath.rect.intersects(box, rect));
|
|
765
|
+
const front = touched.length - 1;
|
|
766
|
+
const out = [];
|
|
767
|
+
for (let i = 0; i < touched.length; i++) {
|
|
768
|
+
const [id, box] = touched[i];
|
|
769
|
+
if (i !== front && cmath.rect.contains(box, rect)) continue;
|
|
770
|
+
out.push(id);
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
_marquee_selection.hits = hits;
|
|
775
|
+
function resolve(boxes, rect, baseline, opts = {}) {
|
|
776
|
+
const h = hits(boxes, rect);
|
|
777
|
+
if (!opts.additive) return h;
|
|
778
|
+
const out = [...baseline];
|
|
779
|
+
const seen = new Set(baseline);
|
|
780
|
+
for (const id of h) if (!seen.has(id)) {
|
|
781
|
+
seen.add(id);
|
|
782
|
+
out.push(id);
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
_marquee_selection.resolve = resolve;
|
|
787
|
+
})(marquee_selection || (marquee_selection = {}));
|
|
788
|
+
//#endregion
|
|
789
|
+
//#region src/gestures/gestures.ts
|
|
790
|
+
/**
|
|
791
|
+
* Sibling to `Keymap`. Owns a list of installed gesture bindings; each
|
|
792
|
+
* binding's `install(ctx)` is called eagerly when bound and uninstalled
|
|
793
|
+
* on `unbind` or surface detach.
|
|
794
|
+
*/
|
|
795
|
+
var Gestures = class {
|
|
796
|
+
constructor(ctx) {
|
|
797
|
+
this.ctx = ctx;
|
|
798
|
+
this.entries = [];
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Install a gesture binding. Returns an unbind function.
|
|
802
|
+
* Re-binding the same `id` does NOT replace — both will be active.
|
|
803
|
+
* Use `unbind({ id })` first if you want a clean swap.
|
|
804
|
+
*/
|
|
805
|
+
bind(binding) {
|
|
806
|
+
const uninstall = binding.install(this.ctx);
|
|
807
|
+
const entry = {
|
|
808
|
+
binding,
|
|
809
|
+
uninstall
|
|
810
|
+
};
|
|
811
|
+
this.entries.push(entry);
|
|
812
|
+
return () => {
|
|
813
|
+
const i = this.entries.indexOf(entry);
|
|
814
|
+
if (i < 0) return;
|
|
815
|
+
this.entries.splice(i, 1);
|
|
816
|
+
uninstall();
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Remove bindings matching the spec. With `{ id }`, all bindings with
|
|
821
|
+
* that id are uninstalled. With no spec, this is a no-op (use
|
|
822
|
+
* `dispose()` to nuke everything).
|
|
823
|
+
*/
|
|
824
|
+
unbind(spec) {
|
|
825
|
+
if (spec.id === void 0) return;
|
|
826
|
+
const remaining = [];
|
|
827
|
+
for (const entry of this.entries) if (entry.binding.id === spec.id) entry.uninstall();
|
|
828
|
+
else remaining.push(entry);
|
|
829
|
+
this.entries = remaining;
|
|
830
|
+
}
|
|
831
|
+
/** All currently installed bindings. Order is registration order. */
|
|
832
|
+
bindings() {
|
|
833
|
+
return this.entries.map((e) => e.binding);
|
|
834
|
+
}
|
|
835
|
+
/** @internal Uninstall every binding. Surface calls on detach. */
|
|
836
|
+
_dispose() {
|
|
837
|
+
for (const entry of this.entries) entry.uninstall();
|
|
838
|
+
this.entries = [];
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
//#endregion
|
|
842
|
+
//#region src/gestures/defaults.ts
|
|
843
|
+
/** Default margin for `camera.fit` from keyboard shortcuts. */
|
|
844
|
+
const KEYBOARD_FIT_MARGIN = 64;
|
|
845
|
+
/** Default zoom step for `Cmd/Ctrl+=` / `Cmd/Ctrl+-`. */
|
|
846
|
+
const ZOOM_STEP = 1.2;
|
|
847
|
+
/** Per-wheel-unit zoom sensitivity for Cmd/Ctrl+wheel + pinch. */
|
|
848
|
+
const WHEEL_ZOOM_SENSITIVITY = .01;
|
|
849
|
+
/** Min/max zoom clamps. Generous; hosts that want tighter limits can
|
|
850
|
+
* unbind these defaults and bind their own. */
|
|
851
|
+
const MIN_ZOOM = .02;
|
|
852
|
+
const MAX_ZOOM = 256;
|
|
853
|
+
function clamp_zoom(z) {
|
|
854
|
+
return cmath.clamp(z, MIN_ZOOM, MAX_ZOOM);
|
|
855
|
+
}
|
|
856
|
+
/** wheel-pan-zoom: plain wheel = pan, Cmd/Ctrl+wheel + pinch = zoom-at-cursor. */
|
|
857
|
+
const WHEEL_PAN_ZOOM = {
|
|
858
|
+
id: "wheel-pan-zoom",
|
|
859
|
+
install({ container, camera }) {
|
|
860
|
+
const on_wheel = (e) => {
|
|
861
|
+
e.preventDefault();
|
|
862
|
+
if (e.ctrlKey || e.metaKey) {
|
|
863
|
+
const factor = 1 - e.deltaY * WHEEL_ZOOM_SENSITIVITY;
|
|
864
|
+
const eff = clamp_zoom(camera.zoom * factor) / camera.zoom;
|
|
865
|
+
if (eff === 1) return;
|
|
866
|
+
const rect = container.getBoundingClientRect();
|
|
867
|
+
camera.zoom_at(eff, {
|
|
868
|
+
x: e.clientX - rect.left,
|
|
869
|
+
y: e.clientY - rect.top
|
|
870
|
+
});
|
|
871
|
+
} else camera.pan({
|
|
872
|
+
x: -e.deltaX,
|
|
873
|
+
y: -e.deltaY
|
|
874
|
+
});
|
|
875
|
+
};
|
|
876
|
+
container.addEventListener("wheel", on_wheel, { passive: false });
|
|
877
|
+
return () => container.removeEventListener("wheel", on_wheel);
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
/**
|
|
881
|
+
* Begin a drag-pan from a pointerdown. Attaches `pointermove` / `pointerup`
|
|
882
|
+
* listeners scoped to the gesture lifetime, then detaches them on release.
|
|
883
|
+
* This is the d3-drag pattern: global listeners only exist while a drag is
|
|
884
|
+
* in flight, not for the surface's whole lifetime.
|
|
885
|
+
*/
|
|
886
|
+
function begin_drag_pan(e, container, camera, on_release) {
|
|
887
|
+
let last_x = e.clientX;
|
|
888
|
+
let last_y = e.clientY;
|
|
889
|
+
try {
|
|
890
|
+
container.setPointerCapture(e.pointerId);
|
|
891
|
+
} catch {}
|
|
892
|
+
e.preventDefault();
|
|
893
|
+
e.stopPropagation();
|
|
894
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
895
|
+
const on_pointermove = (ev) => {
|
|
896
|
+
const dx = ev.clientX - last_x;
|
|
897
|
+
const dy = ev.clientY - last_y;
|
|
898
|
+
last_x = ev.clientX;
|
|
899
|
+
last_y = ev.clientY;
|
|
900
|
+
camera.pan({
|
|
901
|
+
x: dx,
|
|
902
|
+
y: dy
|
|
903
|
+
});
|
|
904
|
+
ev.preventDefault();
|
|
905
|
+
ev.stopPropagation();
|
|
906
|
+
};
|
|
907
|
+
const cleanup = () => {
|
|
908
|
+
win.removeEventListener("pointermove", on_pointermove, true);
|
|
909
|
+
win.removeEventListener("pointerup", on_pointerup, true);
|
|
910
|
+
win.removeEventListener("pointercancel", on_pointerup, true);
|
|
911
|
+
on_release?.();
|
|
912
|
+
};
|
|
913
|
+
const on_pointerup = () => cleanup();
|
|
914
|
+
win.addEventListener("pointermove", on_pointermove, true);
|
|
915
|
+
win.addEventListener("pointerup", on_pointerup, true);
|
|
916
|
+
win.addEventListener("pointercancel", on_pointerup, true);
|
|
917
|
+
}
|
|
918
|
+
/** The data-driven default set. Order = install order. */
|
|
919
|
+
const DEFAULT_GESTURE_BINDINGS = [
|
|
920
|
+
WHEEL_PAN_ZOOM,
|
|
921
|
+
{
|
|
922
|
+
id: "space-drag-pan",
|
|
923
|
+
install({ container, camera, is_attended }) {
|
|
924
|
+
let space_held = false;
|
|
925
|
+
let prev_cursor = null;
|
|
926
|
+
const set_cursor = (next) => {
|
|
927
|
+
if (prev_cursor === null) prev_cursor = container.style.cursor;
|
|
928
|
+
container.style.cursor = next ?? prev_cursor ?? "";
|
|
929
|
+
if (next === null) prev_cursor = null;
|
|
930
|
+
};
|
|
931
|
+
const on_keydown = (e) => {
|
|
932
|
+
if (e.code !== "Space" || e.repeat) return;
|
|
933
|
+
if (is_text_input_focused()) return;
|
|
934
|
+
if (!is_attended()) return;
|
|
935
|
+
space_held = true;
|
|
936
|
+
set_cursor("grab");
|
|
937
|
+
e.preventDefault();
|
|
938
|
+
};
|
|
939
|
+
const on_keyup = (e) => {
|
|
940
|
+
if (e.code !== "Space") return;
|
|
941
|
+
space_held = false;
|
|
942
|
+
set_cursor(null);
|
|
943
|
+
};
|
|
944
|
+
const on_pointerdown = (e) => {
|
|
945
|
+
if (!space_held || e.button !== 0) return;
|
|
946
|
+
set_cursor("grabbing");
|
|
947
|
+
begin_drag_pan(e, container, camera, () => set_cursor(space_held ? "grab" : null));
|
|
948
|
+
};
|
|
949
|
+
const on_blur = () => {
|
|
950
|
+
space_held = false;
|
|
951
|
+
set_cursor(null);
|
|
952
|
+
};
|
|
953
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
954
|
+
win.addEventListener("keydown", on_keydown);
|
|
955
|
+
win.addEventListener("keyup", on_keyup);
|
|
956
|
+
container.addEventListener("pointerdown", on_pointerdown, true);
|
|
957
|
+
win.addEventListener("blur", on_blur);
|
|
958
|
+
return () => {
|
|
959
|
+
win.removeEventListener("keydown", on_keydown);
|
|
960
|
+
win.removeEventListener("keyup", on_keyup);
|
|
961
|
+
container.removeEventListener("pointerdown", on_pointerdown, true);
|
|
962
|
+
win.removeEventListener("blur", on_blur);
|
|
963
|
+
if (prev_cursor !== null) container.style.cursor = prev_cursor;
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
id: "middle-mouse-pan",
|
|
969
|
+
install({ container, camera }) {
|
|
970
|
+
const on_pointerdown = (e) => {
|
|
971
|
+
if (e.button !== 1) return;
|
|
972
|
+
begin_drag_pan(e, container, camera);
|
|
973
|
+
};
|
|
974
|
+
const on_auxclick = (e) => {
|
|
975
|
+
if (e.button === 1) e.preventDefault();
|
|
976
|
+
};
|
|
977
|
+
container.addEventListener("pointerdown", on_pointerdown, true);
|
|
978
|
+
container.addEventListener("auxclick", on_auxclick);
|
|
979
|
+
return () => {
|
|
980
|
+
container.removeEventListener("pointerdown", on_pointerdown, true);
|
|
981
|
+
container.removeEventListener("auxclick", on_auxclick);
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
id: "keyboard-zoom",
|
|
987
|
+
install({ container, camera, is_attended }) {
|
|
988
|
+
const owner_doc = container.ownerDocument;
|
|
989
|
+
const on_keydown = (e) => {
|
|
990
|
+
if (!is_attended()) return;
|
|
991
|
+
if (is_text_input_focused()) return;
|
|
992
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
993
|
+
if (e.shiftKey && !mod && (e.code === "Digit0" || e.code === "Numpad0")) {
|
|
994
|
+
camera.reset();
|
|
995
|
+
e.preventDefault();
|
|
996
|
+
} else if (e.shiftKey && !mod && (e.code === "Digit1" || e.code === "Digit9" || e.code === "Numpad1" || e.code === "Numpad9")) {
|
|
997
|
+
camera.fit("<root>", { margin: KEYBOARD_FIT_MARGIN });
|
|
998
|
+
e.preventDefault();
|
|
999
|
+
} else if (e.shiftKey && !mod && (e.code === "Digit2" || e.code === "Numpad2")) {
|
|
1000
|
+
camera.fit("<selection>", { margin: KEYBOARD_FIT_MARGIN });
|
|
1001
|
+
e.preventDefault();
|
|
1002
|
+
} else if (mod && (e.code === "Equal" || e.code === "NumpadAdd")) {
|
|
1003
|
+
camera.set_zoom(clamp_zoom(camera.zoom * ZOOM_STEP));
|
|
1004
|
+
e.preventDefault();
|
|
1005
|
+
} else if (mod && (e.code === "Minus" || e.code === "NumpadSubtract")) {
|
|
1006
|
+
camera.set_zoom(clamp_zoom(camera.zoom / ZOOM_STEP));
|
|
1007
|
+
e.preventDefault();
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
owner_doc.addEventListener("keydown", on_keydown);
|
|
1011
|
+
return () => owner_doc.removeEventListener("keydown", on_keydown);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
];
|
|
1015
|
+
/** Install every default binding into the gesture layer. */
|
|
1016
|
+
function applyDefaultGestures(gestures) {
|
|
1017
|
+
for (const b of DEFAULT_GESTURE_BINDINGS) gestures.bind(b);
|
|
1018
|
+
}
|
|
1019
|
+
//#endregion
|
|
1020
|
+
//#region src/util/attention.ts
|
|
1021
|
+
/**
|
|
1022
|
+
* Install pointer-tracking listeners on `container` and return the
|
|
1023
|
+
* read-side handle. The tracker is owned by the surface and disposed
|
|
1024
|
+
* alongside it; gesture bindings that need to consult it receive the
|
|
1025
|
+
* read-only `is_attended` predicate through `GestureContext`. Hosts
|
|
1026
|
+
* extend the scope through `handle.attention` (`dom.ts`), which fronts
|
|
1027
|
+
* {@link AttentionTracker.add} / {@link AttentionTracker.remove}.
|
|
1028
|
+
*/
|
|
1029
|
+
function create_attention_tracker(container) {
|
|
1030
|
+
/** Elements of the scope the pointer is currently over. Per-element
|
|
1031
|
+
* membership (not a single boolean) so crossing from the container
|
|
1032
|
+
* onto overlapping registered chrome — `leave` and `enter` firing in
|
|
1033
|
+
* either order — never reads as a gap in attention. */
|
|
1034
|
+
const hovered = /* @__PURE__ */ new Set();
|
|
1035
|
+
/** Registered extras → their hover-tracking teardown. */
|
|
1036
|
+
const extras = /* @__PURE__ */ new Map();
|
|
1037
|
+
let disposed = false;
|
|
1038
|
+
/** Start hover-tracking `element`; returns the exact undo. */
|
|
1039
|
+
const track = (element) => {
|
|
1040
|
+
const enter = () => {
|
|
1041
|
+
hovered.add(element);
|
|
1042
|
+
};
|
|
1043
|
+
const leave = () => {
|
|
1044
|
+
hovered.delete(element);
|
|
1045
|
+
};
|
|
1046
|
+
element.addEventListener("pointerenter", enter);
|
|
1047
|
+
element.addEventListener("pointerleave", leave);
|
|
1048
|
+
return () => {
|
|
1049
|
+
element.removeEventListener("pointerenter", enter);
|
|
1050
|
+
element.removeEventListener("pointerleave", leave);
|
|
1051
|
+
hovered.delete(element);
|
|
1052
|
+
};
|
|
1053
|
+
};
|
|
1054
|
+
const untrack_container = track(container);
|
|
1055
|
+
const is_focus_within = () => {
|
|
1056
|
+
const owner = container.ownerDocument;
|
|
1057
|
+
if (!owner) return false;
|
|
1058
|
+
const active = owner.activeElement;
|
|
1059
|
+
if (!active || active === owner.body) return false;
|
|
1060
|
+
if (container.contains(active)) return true;
|
|
1061
|
+
for (const element of extras.keys()) if (element.contains(active)) return true;
|
|
1062
|
+
return false;
|
|
1063
|
+
};
|
|
1064
|
+
const is_attended = () => {
|
|
1065
|
+
return hovered.size > 0 || is_focus_within();
|
|
1066
|
+
};
|
|
1067
|
+
return {
|
|
1068
|
+
is_attended,
|
|
1069
|
+
is_focus_within,
|
|
1070
|
+
add: (element) => {
|
|
1071
|
+
if (disposed) return;
|
|
1072
|
+
if (element === container || extras.has(element)) return;
|
|
1073
|
+
extras.set(element, track(element));
|
|
1074
|
+
if (typeof element.matches === "function" && element.matches(":hover")) hovered.add(element);
|
|
1075
|
+
},
|
|
1076
|
+
remove: (element) => {
|
|
1077
|
+
const untrack = extras.get(element);
|
|
1078
|
+
if (!untrack) return;
|
|
1079
|
+
extras.delete(element);
|
|
1080
|
+
untrack();
|
|
1081
|
+
},
|
|
1082
|
+
dispose: () => {
|
|
1083
|
+
disposed = true;
|
|
1084
|
+
untrack_container();
|
|
1085
|
+
for (const untrack of extras.values()) untrack();
|
|
1086
|
+
extras.clear();
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
//#endregion
|
|
1091
|
+
//#region src/text-surface.ts
|
|
1092
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
1093
|
+
const XML_NS = "http://www.w3.org/XML/1998/namespace";
|
|
1094
|
+
var SvgTextSurface = class {
|
|
1095
|
+
constructor(textEl) {
|
|
1096
|
+
this.prevXmlSpace = void 0;
|
|
1097
|
+
this.prevPointerEvents = void 0;
|
|
1098
|
+
this.last_caret_idx = -1;
|
|
1099
|
+
this.last_caret_visible = false;
|
|
1100
|
+
this.last_sel_start = -1;
|
|
1101
|
+
this.last_sel_end = -1;
|
|
1102
|
+
this.textEl = textEl;
|
|
1103
|
+
const ownerDoc = textEl.ownerDocument;
|
|
1104
|
+
let mountAnchor = textEl;
|
|
1105
|
+
while (mountAnchor.parentElement instanceof SVGElement && (mountAnchor.localName === "tspan" || mountAnchor.localName === "textPath")) mountAnchor = mountAnchor.parentElement;
|
|
1106
|
+
const parent = mountAnchor.parentNode;
|
|
1107
|
+
if (!parent) throw new Error("text element has no parent");
|
|
1108
|
+
const computedWhitespace = ownerDoc.defaultView?.getComputedStyle(textEl).whiteSpace;
|
|
1109
|
+
if (!(computedWhitespace === "pre" || computedWhitespace === "pre-wrap" || computedWhitespace === "break-spaces")) {
|
|
1110
|
+
this.prevXmlSpace = textEl.getAttributeNS(XML_NS, "space");
|
|
1111
|
+
textEl.setAttributeNS(XML_NS, "xml:space", "preserve");
|
|
1112
|
+
}
|
|
1113
|
+
this.prevPointerEvents = textEl.getAttribute("pointer-events");
|
|
1114
|
+
textEl.setAttribute("pointer-events", "bounding-box");
|
|
1115
|
+
const selection = ownerDoc.createElementNS(SVG_NS, "rect");
|
|
1116
|
+
selection.setAttribute("fill", "#2563eb");
|
|
1117
|
+
selection.setAttribute("fill-opacity", "0.25");
|
|
1118
|
+
selection.setAttribute("pointer-events", "none");
|
|
1119
|
+
selection.setAttribute("data-svg-text-edit-selection", "");
|
|
1120
|
+
selection.style.display = "none";
|
|
1121
|
+
parent.insertBefore(selection, mountAnchor);
|
|
1122
|
+
this.selectionRect = selection;
|
|
1123
|
+
const caret = ownerDoc.createElementNS(SVG_NS, "rect");
|
|
1124
|
+
caret.setAttribute("fill", "#2563eb");
|
|
1125
|
+
caret.setAttribute("pointer-events", "none");
|
|
1126
|
+
caret.setAttribute("data-svg-text-edit-caret", "");
|
|
1127
|
+
caret.style.display = "none";
|
|
1128
|
+
parent.insertBefore(caret, mountAnchor.nextSibling);
|
|
1129
|
+
this.caretRect = caret;
|
|
1130
|
+
}
|
|
1131
|
+
setText(text) {
|
|
1132
|
+
if (this.textEl.textContent !== text) this.textEl.textContent = text;
|
|
1133
|
+
}
|
|
1134
|
+
setCaret(index, visible) {
|
|
1135
|
+
if (index === this.last_caret_idx && visible === this.last_caret_visible) return;
|
|
1136
|
+
this.last_caret_idx = index;
|
|
1137
|
+
this.last_caret_visible = visible;
|
|
1138
|
+
if (!visible) {
|
|
1139
|
+
this.caretRect.style.display = "none";
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const m = this.metrics();
|
|
1143
|
+
const x = this.charX(index);
|
|
1144
|
+
this.caretRect.setAttribute("x", String(x - .75));
|
|
1145
|
+
this.caretRect.setAttribute("y", String(m.top));
|
|
1146
|
+
this.caretRect.setAttribute("width", "1.5");
|
|
1147
|
+
this.caretRect.setAttribute("height", String(m.height));
|
|
1148
|
+
this.caretRect.style.display = "block";
|
|
1149
|
+
}
|
|
1150
|
+
setSelection(start, end) {
|
|
1151
|
+
if (start === this.last_sel_start && end === this.last_sel_end) return;
|
|
1152
|
+
this.last_sel_start = start;
|
|
1153
|
+
this.last_sel_end = end;
|
|
1154
|
+
if (start === end) {
|
|
1155
|
+
this.selectionRect.style.display = "none";
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const m = this.metrics();
|
|
1159
|
+
const x1 = this.charX(start);
|
|
1160
|
+
const x2 = this.charX(end);
|
|
1161
|
+
this.selectionRect.setAttribute("x", String(Math.min(x1, x2)));
|
|
1162
|
+
this.selectionRect.setAttribute("y", String(m.top));
|
|
1163
|
+
this.selectionRect.setAttribute("width", String(Math.abs(x2 - x1)));
|
|
1164
|
+
this.selectionRect.setAttribute("height", String(m.height));
|
|
1165
|
+
this.selectionRect.style.display = "block";
|
|
1166
|
+
}
|
|
1167
|
+
dispose(keepEditMutations = false) {
|
|
1168
|
+
this.caretRect.remove();
|
|
1169
|
+
this.selectionRect.remove();
|
|
1170
|
+
if (this.prevXmlSpace !== void 0 && !keepEditMutations) if (this.prevXmlSpace === null) this.textEl.removeAttributeNS(XML_NS, "space");
|
|
1171
|
+
else this.textEl.setAttributeNS(XML_NS, "xml:space", this.prevXmlSpace);
|
|
1172
|
+
if (this.prevPointerEvents !== void 0) if (this.prevPointerEvents === null) this.textEl.removeAttribute("pointer-events");
|
|
1173
|
+
else this.textEl.setAttribute("pointer-events", this.prevPointerEvents);
|
|
1174
|
+
this.prevXmlSpace = void 0;
|
|
1175
|
+
this.prevPointerEvents = void 0;
|
|
1176
|
+
}
|
|
1177
|
+
positionAtPoint(clientX, clientY) {
|
|
1178
|
+
const ctm = this.textEl.getScreenCTM();
|
|
1179
|
+
const svg = this.textEl.ownerSVGElement;
|
|
1180
|
+
if (!ctm || !svg) return 0;
|
|
1181
|
+
const pt = svg.createSVGPoint();
|
|
1182
|
+
pt.x = clientX;
|
|
1183
|
+
pt.y = clientY;
|
|
1184
|
+
const local = pt.matrixTransform(ctm.inverse());
|
|
1185
|
+
return this.localXToCharIndex(local.x);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Single-line `<text>` element: there's no "previous visual line" to move
|
|
1189
|
+
* to. Cocoa single-line convention: Up/PageUp/line_start → doc start;
|
|
1190
|
+
* Down/PageDown/line_end → doc end.
|
|
1191
|
+
*/
|
|
1192
|
+
positionForNavigation(_index, direction) {
|
|
1193
|
+
const text = this.textEl.textContent ?? "";
|
|
1194
|
+
switch (direction) {
|
|
1195
|
+
case "up":
|
|
1196
|
+
case "page_up":
|
|
1197
|
+
case "line_start": return 0;
|
|
1198
|
+
case "down":
|
|
1199
|
+
case "page_down":
|
|
1200
|
+
case "line_end": return text.length;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
metrics() {
|
|
1204
|
+
try {
|
|
1205
|
+
const b = this.textEl.getBBox();
|
|
1206
|
+
if (b.height > 0) return {
|
|
1207
|
+
top: b.y,
|
|
1208
|
+
height: b.height
|
|
1209
|
+
};
|
|
1210
|
+
} catch {}
|
|
1211
|
+
const fontSize = parseFloat(this.textEl.ownerDocument.defaultView?.getComputedStyle(this.textEl).fontSize ?? "16") || 16;
|
|
1212
|
+
return {
|
|
1213
|
+
top: parseFloat(this.textEl.getAttribute("y") ?? "0") - fontSize * .85,
|
|
1214
|
+
height: fontSize
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
charX(i) {
|
|
1218
|
+
const text = this.textEl.textContent ?? "";
|
|
1219
|
+
const baseX = parseFloat(this.textEl.getAttribute("x") ?? "0");
|
|
1220
|
+
if (text.length === 0) return baseX;
|
|
1221
|
+
if (i <= 0) try {
|
|
1222
|
+
return this.textEl.getStartPositionOfChar(0).x;
|
|
1223
|
+
} catch {
|
|
1224
|
+
return baseX;
|
|
1225
|
+
}
|
|
1226
|
+
if (i >= text.length) try {
|
|
1227
|
+
return this.textEl.getEndPositionOfChar(text.length - 1).x;
|
|
1228
|
+
} catch {
|
|
1229
|
+
return baseX;
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
return this.textEl.getStartPositionOfChar(i).x;
|
|
1233
|
+
} catch {
|
|
1234
|
+
return baseX;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
localXToCharIndex(localX) {
|
|
1238
|
+
const text = this.textEl.textContent ?? "";
|
|
1239
|
+
if (!text) return 0;
|
|
1240
|
+
for (let i = 0; i < text.length; i++) try {
|
|
1241
|
+
const ext = this.textEl.getExtentOfChar(i);
|
|
1242
|
+
if (localX < ext.x + ext.width / 2) return i;
|
|
1243
|
+
} catch {
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
return text.length;
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
//#endregion
|
|
1250
|
+
//#region src/core/vector-edit/session.ts
|
|
1251
|
+
function tangents_equal(a, b) {
|
|
1252
|
+
return a[0] === b[0] && a[1] === b[1];
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Shallow equality over the three sub-selection arrays. Order-sensitive
|
|
1256
|
+
* (mirrors how the host stores them). Used by the orchestrator to skip
|
|
1257
|
+
* pushing a no-op undo entry when a selection handler resolves to the
|
|
1258
|
+
* same state — e.g. clicking an already-selected vertex in `replace`
|
|
1259
|
+
* mode.
|
|
1260
|
+
*/
|
|
1261
|
+
function sub_selection_equal(a, b) {
|
|
1262
|
+
if (a === b) return true;
|
|
1263
|
+
if (a.vertices.length !== b.vertices.length) return false;
|
|
1264
|
+
if (a.segments.length !== b.segments.length) return false;
|
|
1265
|
+
if (a.tangents.length !== b.tangents.length) return false;
|
|
1266
|
+
for (let i = 0; i < a.vertices.length; i++) if (a.vertices[i] !== b.vertices[i]) return false;
|
|
1267
|
+
for (let i = 0; i < a.segments.length; i++) if (a.segments[i] !== b.segments[i]) return false;
|
|
1268
|
+
for (let i = 0; i < a.tangents.length; i++) if (!tangents_equal(a.tangents[i], b.tangents[i])) return false;
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Host-side state for vector content-edit (vertex / segment / tangent
|
|
1273
|
+
* gestures) on the supported source tags. "Vector" here names the
|
|
1274
|
+
* editing mode — NOT a `vn.VectorNetwork` wrapper. The session holds
|
|
1275
|
+
* the source tag's authored attrs plus a path-form session-d, and
|
|
1276
|
+
* delegates geometry to {@link PathModel} via the apply.ts shim.
|
|
1277
|
+
*/
|
|
1278
|
+
var VectorEditSession = class {
|
|
1279
|
+
constructor(node_id, source, session_d) {
|
|
1280
|
+
this.node_id = node_id;
|
|
1281
|
+
this._source = source;
|
|
1282
|
+
this._source_before_promotion = null;
|
|
1283
|
+
this._session_d = session_d;
|
|
1284
|
+
this._last_seen_d = session_d;
|
|
1285
|
+
this._selected_vertices = [];
|
|
1286
|
+
this._selected_segments = [];
|
|
1287
|
+
this._selected_tangents = [];
|
|
1288
|
+
this._hovered_control = null;
|
|
1289
|
+
}
|
|
1290
|
+
/** Source tag the session currently projects through. See `_source`. */
|
|
1291
|
+
get source() {
|
|
1292
|
+
return this._source;
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Flip the source to `path` after the underlying element was promoted
|
|
1296
|
+
* (rect / circle / ellipse → `<path>`). Idempotent: a second call while
|
|
1297
|
+
* already promoted does nothing, so the pre-promotion source captured by
|
|
1298
|
+
* the first flip is never clobbered.
|
|
1299
|
+
*/
|
|
1300
|
+
promote_source_to_path() {
|
|
1301
|
+
if (this._source_before_promotion !== null) return;
|
|
1302
|
+
if (this._source.kind === "path") return;
|
|
1303
|
+
this._source_before_promotion = this._source;
|
|
1304
|
+
this._source = {
|
|
1305
|
+
kind: "path",
|
|
1306
|
+
d: this._session_d
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
/** Reverse a {@link promote_source_to_path} (gesture undo). No-op if the
|
|
1310
|
+
* source was never promoted. */
|
|
1311
|
+
restore_source() {
|
|
1312
|
+
if (this._source_before_promotion === null) return;
|
|
1313
|
+
this._source = this._source_before_promotion;
|
|
1314
|
+
this._source_before_promotion = null;
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Re-sync the source to the document's current tag, outright. Unlike
|
|
1318
|
+
* {@link promote_source_to_path} / {@link restore_source} (which manage a
|
|
1319
|
+
* single primitive→path flip within one gesture), this sets the source to
|
|
1320
|
+
* an authoritative value derived from the live document and clears the
|
|
1321
|
+
* promotion bookkeeping.
|
|
1322
|
+
*
|
|
1323
|
+
* The host calls this when an undo/redo re-types the node out from under a
|
|
1324
|
+
* *different* live session object than the one that performed the original
|
|
1325
|
+
* flip (exit + undo-exit creates a fresh session; the captured session's
|
|
1326
|
+
* `restore_source` then no-ops). Without it the live session could keep
|
|
1327
|
+
* `source.kind === "path"` while the node is back to a primitive, and the
|
|
1328
|
+
* next gesture would write a stray `d` onto the native tag. Re-deriving
|
|
1329
|
+
* from the document keeps the live session authoritative.
|
|
1330
|
+
*/
|
|
1331
|
+
sync_source(source) {
|
|
1332
|
+
this._source = source;
|
|
1333
|
+
this._source_before_promotion = null;
|
|
1334
|
+
}
|
|
1335
|
+
/** The session's current PathModel-form `d`. Gesture handlers read
|
|
1336
|
+
* this instead of `doc.get_attr(node_id, "d")` so they stay tag-
|
|
1337
|
+
* oblivious (non-path sources have no `d` on the document). */
|
|
1338
|
+
get current_d() {
|
|
1339
|
+
return this._session_d;
|
|
1340
|
+
}
|
|
1341
|
+
/** Update the session's view after a write produced `next_d`. Caller
|
|
1342
|
+
* is `apply_session_d` (or the gesture handler that called it). */
|
|
1343
|
+
update_session_d(next_d) {
|
|
1344
|
+
this._session_d = next_d;
|
|
1345
|
+
}
|
|
1346
|
+
get last_seen_d() {
|
|
1347
|
+
return this._last_seen_d;
|
|
1348
|
+
}
|
|
1349
|
+
get selected_vertices() {
|
|
1350
|
+
return this._selected_vertices;
|
|
1351
|
+
}
|
|
1352
|
+
get selected_segments() {
|
|
1353
|
+
return this._selected_segments;
|
|
1354
|
+
}
|
|
1355
|
+
get selected_tangents() {
|
|
1356
|
+
return this._selected_tangents;
|
|
1357
|
+
}
|
|
1358
|
+
get hovered_control() {
|
|
1359
|
+
return this._hovered_control;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Record that the host's most recent gesture write produced `d`.
|
|
1363
|
+
* Updates both the session-d (the in-session canonical form) and the
|
|
1364
|
+
* last-seen mark. The next state-change tick uses last_seen to
|
|
1365
|
+
* distinguish "we wrote this" from "the document changed under us".
|
|
1366
|
+
*/
|
|
1367
|
+
mark_seen(d) {
|
|
1368
|
+
this._session_d = d;
|
|
1369
|
+
this._last_seen_d = d;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* The session's response to a detected external mutation of `d`
|
|
1373
|
+
* (undo / redo / programmatic / collab). Atomically (a) advances
|
|
1374
|
+
* `last_seen_d` to the now-current value and (b) drops sub-selection
|
|
1375
|
+
* — selection indices reference vertices and segments by ordinal
|
|
1376
|
+
* position, and an external mutation may have shifted or removed
|
|
1377
|
+
* them.
|
|
1378
|
+
*
|
|
1379
|
+
* Exposed as a single method so callers cannot get the two halves
|
|
1380
|
+
* out of order. Doing `clear_selection` without `mark_seen` would
|
|
1381
|
+
* leave us "stuck dirty" — the next tick would reconcile again.
|
|
1382
|
+
* Doing `mark_seen` without `clear_selection` would leave stale
|
|
1383
|
+
* indices pointing into a geometry that no longer matches.
|
|
1384
|
+
*/
|
|
1385
|
+
reconcile_after_external_mutation(d) {
|
|
1386
|
+
this.mark_seen(d);
|
|
1387
|
+
this.clear_selection();
|
|
1388
|
+
}
|
|
1389
|
+
select_vertex(index, mode) {
|
|
1390
|
+
switch (mode) {
|
|
1391
|
+
case "replace":
|
|
1392
|
+
this._selected_vertices = [index];
|
|
1393
|
+
break;
|
|
1394
|
+
case "add":
|
|
1395
|
+
if (!this._selected_vertices.includes(index)) this._selected_vertices = [...this._selected_vertices, index];
|
|
1396
|
+
break;
|
|
1397
|
+
case "toggle":
|
|
1398
|
+
this._selected_vertices = this._selected_vertices.includes(index) ? this._selected_vertices.filter((v) => v !== index) : [...this._selected_vertices, index];
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
if (mode === "replace") {
|
|
1402
|
+
this._selected_segments = [];
|
|
1403
|
+
this._selected_tangents = [];
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
select_segment(index, mode) {
|
|
1407
|
+
switch (mode) {
|
|
1408
|
+
case "replace":
|
|
1409
|
+
this._selected_segments = [index];
|
|
1410
|
+
break;
|
|
1411
|
+
case "add":
|
|
1412
|
+
if (!this._selected_segments.includes(index)) this._selected_segments = [...this._selected_segments, index];
|
|
1413
|
+
break;
|
|
1414
|
+
case "toggle":
|
|
1415
|
+
this._selected_segments = this._selected_segments.includes(index) ? this._selected_segments.filter((s) => s !== index) : [...this._selected_segments, index];
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
if (mode === "replace") {
|
|
1419
|
+
this._selected_vertices = [];
|
|
1420
|
+
this._selected_tangents = [];
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
select_tangent(ref, mode) {
|
|
1424
|
+
const has = this._selected_tangents.some((t) => tangents_equal(t, ref));
|
|
1425
|
+
switch (mode) {
|
|
1426
|
+
case "replace":
|
|
1427
|
+
this._selected_tangents = [ref];
|
|
1428
|
+
break;
|
|
1429
|
+
case "add":
|
|
1430
|
+
if (!has) this._selected_tangents = [...this._selected_tangents, ref];
|
|
1431
|
+
break;
|
|
1432
|
+
case "toggle":
|
|
1433
|
+
this._selected_tangents = has ? this._selected_tangents.filter((t) => !tangents_equal(t, ref)) : [...this._selected_tangents, ref];
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
if (mode === "replace") {
|
|
1437
|
+
this._selected_vertices = [];
|
|
1438
|
+
this._selected_segments = [];
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Replace the entire sub-selection at once. Useful for marquee /
|
|
1443
|
+
* lasso results, which compute the full set up-front.
|
|
1444
|
+
*/
|
|
1445
|
+
set_selection(next) {
|
|
1446
|
+
this._selected_vertices = [...next.vertices];
|
|
1447
|
+
this._selected_segments = [...next.segments];
|
|
1448
|
+
this._selected_tangents = next.tangents.map((t) => [t[0], t[1]]);
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Capture the current sub-selection as a frozen triple. The
|
|
1452
|
+
* orchestrator closes over snapshots in gesture deltas (so undo
|
|
1453
|
+
* restores selection alongside geometry) and in standalone selection
|
|
1454
|
+
* deltas (so a click on a vertex is itself undoable).
|
|
1455
|
+
*
|
|
1456
|
+
* Returned arrays are fresh copies — safe to retain across
|
|
1457
|
+
* subsequent mutations of the session.
|
|
1458
|
+
*/
|
|
1459
|
+
snapshot_selection() {
|
|
1460
|
+
return Object.freeze({
|
|
1461
|
+
vertices: Object.freeze([...this._selected_vertices]),
|
|
1462
|
+
segments: Object.freeze([...this._selected_segments]),
|
|
1463
|
+
tangents: Object.freeze(this._selected_tangents.map((t) => [t[0], t[1]]))
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Restore a previously-captured sub-selection. Counterpart to
|
|
1468
|
+
* {@link snapshot_selection}. Equivalent to calling
|
|
1469
|
+
* {@link set_selection} with the snapshot's contents.
|
|
1470
|
+
*/
|
|
1471
|
+
restore_selection(snap) {
|
|
1472
|
+
this.set_selection(snap);
|
|
1473
|
+
}
|
|
1474
|
+
clear_selection() {
|
|
1475
|
+
if (this._selected_vertices.length === 0 && this._selected_segments.length === 0 && this._selected_tangents.length === 0) return;
|
|
1476
|
+
this._selected_vertices = [];
|
|
1477
|
+
this._selected_segments = [];
|
|
1478
|
+
this._selected_tangents = [];
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
//#endregion
|
|
1482
|
+
//#region src/core/vector-edit/apply.ts
|
|
1483
|
+
/**
|
|
1484
|
+
* Build the in-session path-form `d` ("session-d") for a freshly-
|
|
1485
|
+
* entered vector-edit. For `<path>` this is the verbatim authored
|
|
1486
|
+
* string; for the two vertex-chain tags we route through
|
|
1487
|
+
* `vn.fromPolyline` / `vn.fromPolygon` and emit via `vn.toSVGPathData`
|
|
1488
|
+
* — same zero-tangent `M`/`L` sequence the gesture handlers'
|
|
1489
|
+
* `PathModel.toSvgPathD()` will produce on subsequent commits.
|
|
1490
|
+
*
|
|
1491
|
+
* The returned string is internal to the session — it is never written
|
|
1492
|
+
* to the document on its own. Native-attr writeback happens through
|
|
1493
|
+
* {@link apply_session_d} (which calls {@link PathModel.toNativeAttrs}
|
|
1494
|
+
* to project the path-form geometry back to source-tag attrs).
|
|
1495
|
+
*/
|
|
1496
|
+
function source_to_session_d(source) {
|
|
1497
|
+
switch (source.kind) {
|
|
1498
|
+
case "path": return source.d;
|
|
1499
|
+
case "line": return vn.toSVGPathData(vn.fromPolyline([[source.x1, source.y1], [source.x2, source.y2]]));
|
|
1500
|
+
case "polyline": return vn.toSVGPathData(vn.fromPolyline(source.points.map((p) => [p[0], p[1]])));
|
|
1501
|
+
case "polygon": return vn.toSVGPathData(vn.fromPolygon(source.points.map((p) => [p[0], p[1]])));
|
|
1502
|
+
case "circle": return vn.toSVGPathData(vn.fromEllipse({
|
|
1503
|
+
x: source.cx - source.r,
|
|
1504
|
+
y: source.cy - source.r,
|
|
1505
|
+
width: source.r * 2,
|
|
1506
|
+
height: source.r * 2
|
|
1507
|
+
}));
|
|
1508
|
+
case "ellipse": return vn.toSVGPathData(vn.fromEllipse({
|
|
1509
|
+
x: source.cx - source.rx,
|
|
1510
|
+
y: source.cy - source.ry,
|
|
1511
|
+
width: source.rx * 2,
|
|
1512
|
+
height: source.ry * 2
|
|
1513
|
+
}));
|
|
1514
|
+
case "rect": return SVGShapes.createRect(source.x, source.y, source.width, source.height, source.rx, source.ry).encode();
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Native-attribute writeback. Given a new path-data `d` from a gesture,
|
|
1519
|
+
* project it back into the source tag's native attrs and write those —
|
|
1520
|
+
* `<path>` takes `d` directly; `<line>` takes `x1/y1/x2/y2`;
|
|
1521
|
+
* `<polyline>` / `<polygon>` take `points`.
|
|
1522
|
+
*
|
|
1523
|
+
* Returns `true` if the geometry was written natively. Returns `false`
|
|
1524
|
+
* when the source tag cannot express the geometry — a curve was introduced
|
|
1525
|
+
* or the topology left the tag's canonical form, OR the source is a
|
|
1526
|
+
* geometry primitive (rect / circle / ellipse) which has no native vector
|
|
1527
|
+
* form at all. A `false` return is the re-type-to-`<path>` signal; the
|
|
1528
|
+
* caller ({@link vector_apply}) handles it. This function never re-types.
|
|
1529
|
+
*
|
|
1530
|
+
* Symmetric across apply / revert: callers use it for both the in-flight
|
|
1531
|
+
* write and the undo-revert (both are just "set the geometry to this d").
|
|
1532
|
+
*/
|
|
1533
|
+
function apply_session_d(doc, node_id, source, d) {
|
|
1534
|
+
if (source.kind === "path") {
|
|
1535
|
+
doc.set_attr(node_id, "d", d);
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
const native = PathModel.fromSvgPathD(d).toNativeAttrs(source.kind);
|
|
1539
|
+
if (native === null) return false;
|
|
1540
|
+
if (native.kind === "line") {
|
|
1541
|
+
doc.set_attr(node_id, "x1", String(native.x1));
|
|
1542
|
+
doc.set_attr(node_id, "y1", String(native.y1));
|
|
1543
|
+
doc.set_attr(node_id, "x2", String(native.x2));
|
|
1544
|
+
doc.set_attr(node_id, "y2", String(native.y2));
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
const points = native.points.map((p) => `${p[0]},${p[1]}`).join(" ");
|
|
1548
|
+
doc.set_attr(node_id, "points", points);
|
|
1549
|
+
return true;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Session-aware geometry write — the single commit chokepoint the DOM
|
|
1553
|
+
* gesture handlers call so re-typing stays in one place rather than being
|
|
1554
|
+
* reimplemented per gesture. One uniform rule across every source:
|
|
1555
|
+
*
|
|
1556
|
+
* 1. Try native writeback ({@link apply_session_d}). For `<path>` and for
|
|
1557
|
+
* a vertex tag (`line` / `polyline` / `polygon`) whose edit still fits
|
|
1558
|
+
* its native form, this writes and we're done — the element keeps its
|
|
1559
|
+
* tag.
|
|
1560
|
+
* 2. If native writeback refused (a curve was introduced, the topology
|
|
1561
|
+
* escaped the canonical chain, or the source is a geometry primitive
|
|
1562
|
+
* with no native form), re-type the element to `<path>` via
|
|
1563
|
+
* {@link SvgDocument.retype_to_path} and flip the session source to
|
|
1564
|
+
* `path` (so every downstream reader — overlay, gates, the
|
|
1565
|
+
* external-mutation reconciler — behaves correctly).
|
|
1566
|
+
*
|
|
1567
|
+
* Returns the {@link RetypeRecord} token iff this call performed a re-type
|
|
1568
|
+
* (so the caller can pair it with the edit in one history bracket and hand
|
|
1569
|
+
* it to {@link vector_revert} on undo); otherwise `null`.
|
|
1570
|
+
*/
|
|
1571
|
+
function vector_apply(doc, session, d) {
|
|
1572
|
+
if (apply_session_d(doc, session.node_id, session.source, d)) return null;
|
|
1573
|
+
const token = doc.retype_to_path(session.node_id, d);
|
|
1574
|
+
if (token) {
|
|
1575
|
+
session.promote_source_to_path();
|
|
1576
|
+
return token;
|
|
1577
|
+
}
|
|
1578
|
+
doc.set_attr(session.node_id, "d", d);
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Counterpart to {@link vector_apply}. If this gesture re-typed the element
|
|
1583
|
+
* (a non-null `promotion` token), restore the original tag/attrs and the
|
|
1584
|
+
* session source — the re-type and the edit undo as one step. Otherwise
|
|
1585
|
+
* re-write the baseline geometry natively; for a geometry primitive that
|
|
1586
|
+
* never re-typed, {@link apply_session_d} writes nothing (a correct no-op).
|
|
1587
|
+
*/
|
|
1588
|
+
function vector_revert(doc, session, baseline_d, promotion) {
|
|
1589
|
+
if (promotion) {
|
|
1590
|
+
doc.revert_retype(session.node_id, promotion);
|
|
1591
|
+
session.restore_source();
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
apply_session_d(doc, session.node_id, session.source, baseline_d);
|
|
1595
|
+
}
|
|
1596
|
+
//#endregion
|
|
1597
|
+
//#region src/core/vector-edit/marquee.ts
|
|
1598
|
+
let marquee;
|
|
1599
|
+
(function(_marquee) {
|
|
1600
|
+
function points_in_rect(candidates, rect) {
|
|
1601
|
+
const hits = [];
|
|
1602
|
+
for (const c of candidates) if (cmath.rect.containsPoint(rect, c.pos)) hits.push(c.key);
|
|
1603
|
+
return hits;
|
|
1604
|
+
}
|
|
1605
|
+
_marquee.points_in_rect = points_in_rect;
|
|
1606
|
+
function points_in_polygon(candidates, polygon) {
|
|
1607
|
+
const hits = [];
|
|
1608
|
+
const poly = polygon;
|
|
1609
|
+
for (const c of candidates) if (cmath.polygon.pointInPolygon(c.pos, poly)) hits.push(c.key);
|
|
1610
|
+
return hits;
|
|
1611
|
+
}
|
|
1612
|
+
_marquee.points_in_polygon = points_in_polygon;
|
|
1613
|
+
function subpath_select_candidates(model, selection, to_doc = identity_proj) {
|
|
1614
|
+
const snap = model.snapshot();
|
|
1615
|
+
const vertices = snap.vertices.map((v, i) => ({
|
|
1616
|
+
key: i,
|
|
1617
|
+
pos: to_doc(v)
|
|
1618
|
+
}));
|
|
1619
|
+
const neigh_set = new Set(model.neighbouringVertices(selection));
|
|
1620
|
+
const tangents = [];
|
|
1621
|
+
for (let si = 0; si < snap.segments.length; si++) {
|
|
1622
|
+
const s = snap.segments[si];
|
|
1623
|
+
if (neigh_set.has(s.a)) {
|
|
1624
|
+
const va = snap.vertices[s.a];
|
|
1625
|
+
tangents.push({
|
|
1626
|
+
key: [s.a, 0],
|
|
1627
|
+
pos: to_doc([va[0] + s.ta[0], va[1] + s.ta[1]])
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
if (neigh_set.has(s.b)) {
|
|
1631
|
+
const vb = snap.vertices[s.b];
|
|
1632
|
+
tangents.push({
|
|
1633
|
+
key: [s.b, 1],
|
|
1634
|
+
pos: to_doc([vb[0] + s.tb[0], vb[1] + s.tb[1]])
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
vertices,
|
|
1640
|
+
tangents,
|
|
1641
|
+
segments: Array.from({ length: snap.segments.length }, (_, i) => i)
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
_marquee.subpath_select_candidates = subpath_select_candidates;
|
|
1645
|
+
function identity_proj(p) {
|
|
1646
|
+
return p;
|
|
1647
|
+
}
|
|
1648
|
+
function merge_subpath_hits(prev, hits, additive) {
|
|
1649
|
+
if (!additive) return {
|
|
1650
|
+
vertices: [...hits.vertices],
|
|
1651
|
+
segments: [...hits.segments],
|
|
1652
|
+
tangents: hits.tangents.map((t) => [t[0], t[1]])
|
|
1653
|
+
};
|
|
1654
|
+
const vertices = Array.from(new Set([...prev.vertices, ...hits.vertices]));
|
|
1655
|
+
const segments = Array.from(new Set([...prev.segments, ...hits.segments]));
|
|
1656
|
+
const tangents = prev.tangents.map((t) => [t[0], t[1]]);
|
|
1657
|
+
for (const t of hits.tangents) if (!tangents.some((x) => x[0] === t[0] && x[1] === t[1])) tangents.push(t);
|
|
1658
|
+
return {
|
|
1659
|
+
vertices,
|
|
1660
|
+
segments,
|
|
1661
|
+
tangents
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
_marquee.merge_subpath_hits = merge_subpath_hits;
|
|
1665
|
+
})(marquee || (marquee = {}));
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region src/dom.ts
|
|
1668
|
+
/** Stamped on every rendered SVG element by `render()` so external
|
|
1669
|
+
* tooling (host inspectors, the layers panel, snapshot tests) can map
|
|
1670
|
+
* a DOM node back to its `NodeId`. The cmath fat-hit picker doesn't
|
|
1671
|
+
* use it — see `_pick_node_at_world`. The legacy elementFromPoint
|
|
1672
|
+
* fallback (active when `hit_tolerance_px <= 0`) walks up via
|
|
1673
|
+
* {@link walk_to_id} below. */
|
|
1674
|
+
const ID_ATTR = "data-grida-id";
|
|
1675
|
+
/** Walk from `target` up through `parentElement`, returning the first id
|
|
1676
|
+
* found on a `[data-grida-id]` ancestor. When `exclude_root` matches an
|
|
1677
|
+
* encountered id, the walk stops and returns `null` — selection HUD
|
|
1678
|
+
* treats root as non-selectable; measurement HUD passes `undefined`
|
|
1679
|
+
* so the root id can be returned. */
|
|
1680
|
+
function walk_to_id(target, exclude_root) {
|
|
1681
|
+
let cur = target;
|
|
1682
|
+
while (cur instanceof Element) {
|
|
1683
|
+
const id = cur.getAttribute(ID_ATTR);
|
|
1684
|
+
if (id) return id === exclude_root ? null : id;
|
|
1685
|
+
cur = cur.parentElement;
|
|
1686
|
+
}
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
const SVG_HUD_GROUP = {
|
|
1690
|
+
selection: "svg-editor.selection",
|
|
1691
|
+
selectionControls: "svg-editor.selection-controls",
|
|
1692
|
+
sizeMeter: "svg-editor.size-meter",
|
|
1693
|
+
memberOutline: "svg-editor.member-outline"
|
|
1694
|
+
};
|
|
1695
|
+
/** KeyboardEvent.key values for the modifiers the surface tracks. Used to
|
|
1696
|
+
* short-circuit window-level `keydown`/`keyup` for non-modifier keystrokes. */
|
|
1697
|
+
const IS_MODIFIER_KEY = {
|
|
1698
|
+
Shift: true,
|
|
1699
|
+
Alt: true,
|
|
1700
|
+
Meta: true,
|
|
1701
|
+
Control: true
|
|
1702
|
+
};
|
|
1703
|
+
/** Sentinel placed in `text_edit` before `createTextEditor` returns, so the
|
|
1704
|
+
* surface skips render() during the in-flight mount and doesn't yank the
|
|
1705
|
+
* live `<text>` element out from under the about-to-mount text surface. */
|
|
1706
|
+
const TEXT_EDIT_PENDING = { __pending: true };
|
|
1707
|
+
/**
|
|
1708
|
+
* Wire a web-font settle source to the editor's geometry channel.
|
|
1709
|
+
*
|
|
1710
|
+
* The DOM surface re-serializes the `<svg>` on every editor tick, but a
|
|
1711
|
+
* `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
|
|
1712
|
+
* finishing load AFTER its `font-family` / `font-size` was already written.
|
|
1713
|
+
* The IR never sees that reflow, so nothing bumps `geometry_version` and
|
|
1714
|
+
* every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
|
|
1715
|
+
* the fallback-face metrics until the next real edit.
|
|
1716
|
+
*
|
|
1717
|
+
* Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
|
|
1718
|
+
* `EventTarget` in tests) and calls `bump` once per settle. COARSE on
|
|
1719
|
+
* purpose: one bump clears the WHOLE bounds cache, not just text nodes —
|
|
1720
|
+
* consistent with the package's pessimistic-invalidation stance, and far
|
|
1721
|
+
* cheaper than scoping the bump to the (possibly many) reflowed runs.
|
|
1722
|
+
*
|
|
1723
|
+
* Also bumps once when `source.ready` resolves (when present): fonts that
|
|
1724
|
+
* settled before attach — a cache hit, or `font-display` resolving the same
|
|
1725
|
+
* tick the surface mounts — never re-fire `loadingdone`, so a document
|
|
1726
|
+
* mounted post-settle still needs one bump to re-read at the real metrics.
|
|
1727
|
+
*
|
|
1728
|
+
* Returns a teardown that removes the listener and neutralizes the pending
|
|
1729
|
+
* `ready` bump (leak guard) — call it on surface detach.
|
|
1730
|
+
*
|
|
1731
|
+
* Factored out of the surface so it can be unit-tested with a fake
|
|
1732
|
+
* `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
|
|
1733
|
+
* incomplete); never a real font / network.
|
|
1734
|
+
*/
|
|
1735
|
+
function install_font_load_geometry_bump(source, bump) {
|
|
1736
|
+
if (!source) return () => {};
|
|
1737
|
+
const on_fonts_settled = () => bump();
|
|
1738
|
+
source.addEventListener("loadingdone", on_fonts_settled);
|
|
1739
|
+
let alive = true;
|
|
1740
|
+
const ready = source.ready;
|
|
1741
|
+
if (ready && typeof ready.then === "function") ready.then(() => {
|
|
1742
|
+
if (alive) bump();
|
|
1743
|
+
});
|
|
1744
|
+
return () => {
|
|
1745
|
+
alive = false;
|
|
1746
|
+
source.removeEventListener("loadingdone", on_fonts_settled);
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
|
|
1751
|
+
* whose `detach()` is the inverse — DOM cleared, listeners removed,
|
|
1752
|
+
* gestures uninstalled.
|
|
1753
|
+
*
|
|
1754
|
+
* Usage is one-shot per container: the surface owns the container's children
|
|
1755
|
+
* for its lifetime, and `detach()` restores it to empty.
|
|
1756
|
+
*/
|
|
1757
|
+
function attach_dom_surface(editor, options) {
|
|
1758
|
+
const surface = new DomSurface(editor, options);
|
|
1759
|
+
const inner = editor.attach(surface);
|
|
1760
|
+
return {
|
|
1761
|
+
detach: () => {
|
|
1762
|
+
surface.detach_gestures();
|
|
1763
|
+
inner.detach();
|
|
1764
|
+
},
|
|
1765
|
+
camera: surface.camera,
|
|
1766
|
+
gestures: surface.gestures,
|
|
1767
|
+
attention: surface.attention_scope
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
var DomSurface = class DomSurface {
|
|
1771
|
+
constructor(editor, options) {
|
|
1772
|
+
this.editor = editor;
|
|
1773
|
+
this.svg_root = null;
|
|
1774
|
+
this.teardown = [];
|
|
1775
|
+
this.element_index = /* @__PURE__ */ new Map();
|
|
1776
|
+
this.rendered_doc_revision = -1;
|
|
1777
|
+
this.last_pointer = {
|
|
1778
|
+
x: 0,
|
|
1779
|
+
y: 0
|
|
1780
|
+
};
|
|
1781
|
+
this.last_pointer_valid = false;
|
|
1782
|
+
this.resize_observer = null;
|
|
1783
|
+
this.redraw_raf_id = null;
|
|
1784
|
+
this._geometry_provider = null;
|
|
1785
|
+
this._hit_shapes = null;
|
|
1786
|
+
this._z_order_cache = [];
|
|
1787
|
+
this._z_order_dirty = true;
|
|
1788
|
+
this.active_preview = null;
|
|
1789
|
+
this.text_edit = null;
|
|
1790
|
+
this.text_edit_target = null;
|
|
1791
|
+
this.text_edit_original = "";
|
|
1792
|
+
this.pending_text_insert = null;
|
|
1793
|
+
this.vector_edit = null;
|
|
1794
|
+
this.point_snap_guide = void 0;
|
|
1795
|
+
this.suppress_point_snap = false;
|
|
1796
|
+
this.vector_edit_region_baseline = null;
|
|
1797
|
+
this.scene_marquee_baseline = null;
|
|
1798
|
+
this.current_tool = TOOL_CURSOR;
|
|
1799
|
+
this.pending_insert = null;
|
|
1800
|
+
this.editor_hover_internal = null;
|
|
1801
|
+
this.container = options.container;
|
|
1802
|
+
const container = this.container;
|
|
1803
|
+
this.fit_on_attach = options.fit === true;
|
|
1804
|
+
this.clipboard_enabled = options.clipboard !== false;
|
|
1805
|
+
this.attention = create_attention_tracker(container);
|
|
1806
|
+
this.attention_scope = {
|
|
1807
|
+
add: (element) => this.attention.add(element),
|
|
1808
|
+
remove: (element) => this.attention.remove(element)
|
|
1809
|
+
};
|
|
1810
|
+
this.teardown.push(() => this.attention.dispose());
|
|
1811
|
+
if (process.env.NODE_ENV !== "production" && container.children.length > 0) console.warn("@grida/svg-editor: surface container is not empty at attach time. Render chrome (toolbars, layer lists, inspectors) as siblings of the container, not children — otherwise clicks on those children will silently break. See README §Surface.");
|
|
1812
|
+
if (getComputedStyle(container).position === "static") container.style.position = "relative";
|
|
1813
|
+
container.style.overflow = "hidden";
|
|
1814
|
+
container.style.userSelect = "none";
|
|
1815
|
+
container.style.webkitUserSelect = "none";
|
|
1816
|
+
container.tabIndex = -1;
|
|
1817
|
+
container.style.outline = "none";
|
|
1818
|
+
const translate_options = () => {
|
|
1819
|
+
const style = this.editor.style;
|
|
1820
|
+
const zoom = this.camera.zoom || 1;
|
|
1821
|
+
return {
|
|
1822
|
+
pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
|
|
1823
|
+
snap_enabled: style.snap_enabled,
|
|
1824
|
+
snap_threshold_px: style.snap_threshold_px / zoom
|
|
1825
|
+
};
|
|
1826
|
+
};
|
|
1827
|
+
this.translate_orchestrator = new TranslateOrchestrator({
|
|
1828
|
+
get_doc: () => this.editor_internal().doc,
|
|
1829
|
+
emit: () => this.editor_internal().emit(),
|
|
1830
|
+
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1831
|
+
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1832
|
+
options: translate_options,
|
|
1833
|
+
project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d,
|
|
1834
|
+
set_selection: (ids) => this.editor.commands.select(ids),
|
|
1835
|
+
on_clone_commit: (record) => this.editor_internal().seed_duplication(record)
|
|
1836
|
+
});
|
|
1837
|
+
const resize_options = () => {
|
|
1838
|
+
const style = this.editor.style;
|
|
1839
|
+
const zoom = this.camera.zoom || 1;
|
|
1840
|
+
return {
|
|
1841
|
+
pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
|
|
1842
|
+
snap_enabled: style.snap_enabled,
|
|
1843
|
+
snap_threshold_px: style.snap_threshold_px / zoom
|
|
1844
|
+
};
|
|
1845
|
+
};
|
|
1846
|
+
this.resize_orchestrator = new ResizeOrchestrator({
|
|
1847
|
+
get_doc: () => this.editor_internal().doc,
|
|
1848
|
+
emit: () => this.editor_internal().emit(),
|
|
1849
|
+
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1850
|
+
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1851
|
+
options: resize_options,
|
|
1852
|
+
bbox_world: (id) => this.bbox_world(id) ?? {
|
|
1853
|
+
x: 0,
|
|
1854
|
+
y: 0,
|
|
1855
|
+
width: 0,
|
|
1856
|
+
height: 0
|
|
1857
|
+
}
|
|
1858
|
+
});
|
|
1859
|
+
const rotate_options = () => ({ angle_snap_step_radians: this.editor.style.angle_snap_step_radians });
|
|
1860
|
+
this.rotate_orchestrator = new RotateOrchestrator({
|
|
1861
|
+
get_doc: () => this.editor_internal().doc,
|
|
1862
|
+
emit: () => this.editor_internal().emit(),
|
|
1863
|
+
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1864
|
+
options: rotate_options,
|
|
1865
|
+
bbox_world: (id) => this.bbox_world(id) ?? {
|
|
1866
|
+
x: 0,
|
|
1867
|
+
y: 0,
|
|
1868
|
+
width: 0,
|
|
1869
|
+
height: 0
|
|
1870
|
+
}
|
|
1871
|
+
});
|
|
1872
|
+
const editor_ref = this.editor;
|
|
1873
|
+
const editor_for_watcher = {
|
|
1874
|
+
get document() {
|
|
1875
|
+
return editor_ref.document;
|
|
1876
|
+
},
|
|
1877
|
+
get state() {
|
|
1878
|
+
return editor_ref.state;
|
|
1879
|
+
},
|
|
1880
|
+
subscribe_translate_commit: (cb) => this.editor_internal().subscribe_translate_commit(cb)
|
|
1881
|
+
};
|
|
1882
|
+
this.nudge_dwell_watcher = new NudgeDwellWatcher({
|
|
1883
|
+
editor: editor_for_watcher,
|
|
1884
|
+
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1885
|
+
options: translate_options,
|
|
1886
|
+
on_guides_change: () => this.request_redraw(),
|
|
1887
|
+
window: container.ownerDocument.defaultView ?? window
|
|
1888
|
+
});
|
|
1889
|
+
this.teardown.push(() => this.nudge_dwell_watcher.dispose());
|
|
1890
|
+
this.hud_canvas = container.ownerDocument.createElement("canvas");
|
|
1891
|
+
Object.assign(this.hud_canvas.style, {
|
|
1892
|
+
position: "absolute",
|
|
1893
|
+
left: "0",
|
|
1894
|
+
top: "0",
|
|
1895
|
+
pointerEvents: "none"
|
|
1896
|
+
});
|
|
1897
|
+
container.appendChild(this.hud_canvas);
|
|
1898
|
+
this.hud = new Surface(this.hud_canvas, {
|
|
1899
|
+
pick: (p) => this.hit_test(p[0], p[1]),
|
|
1900
|
+
shapeOf: (id) => this.shape_of(id),
|
|
1901
|
+
vectorOf: (id) => this.vector_of(id),
|
|
1902
|
+
onIntent: (i) => this.commit_intent(i),
|
|
1903
|
+
onTap: (t) => this.handle_tap(t),
|
|
1904
|
+
style: {
|
|
1905
|
+
chromeColor: editor.style.chrome_color,
|
|
1906
|
+
showRotationHandles: true
|
|
1907
|
+
},
|
|
1908
|
+
groups: {
|
|
1909
|
+
selection: SVG_HUD_GROUP.selection,
|
|
1910
|
+
selectionControls: SVG_HUD_GROUP.selectionControls
|
|
1911
|
+
},
|
|
1912
|
+
visibility: ({ gesture }) => {
|
|
1913
|
+
if (gesture.kind !== "translate") return void 0;
|
|
1914
|
+
return { hidden: [
|
|
1915
|
+
SVG_HUD_GROUP.selection,
|
|
1916
|
+
SVG_HUD_GROUP.selectionControls,
|
|
1917
|
+
SVG_HUD_GROUP.sizeMeter
|
|
1918
|
+
] };
|
|
1919
|
+
},
|
|
1920
|
+
pixelGrid: {
|
|
1921
|
+
enabled: editor.style.pixel_grid,
|
|
1922
|
+
zoomThreshold: 4,
|
|
1923
|
+
transform: options.initial_camera
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
this.hud.setCursorRenderer(cursors.defaultRenderer());
|
|
1927
|
+
this.camera = new Camera({
|
|
1928
|
+
resolve_bounds: (target) => this.resolve_world_bounds(target),
|
|
1929
|
+
initial: options.initial_camera
|
|
1930
|
+
});
|
|
1931
|
+
this.hud.setPixelGridTransform(this.camera.transform);
|
|
1932
|
+
this.teardown.push(this.camera.subscribe(() => {
|
|
1933
|
+
this.apply_camera_transform();
|
|
1934
|
+
this.hud.setPixelGridTransform(this.camera.transform);
|
|
1935
|
+
this.sync_surface_selection();
|
|
1936
|
+
this.redraw();
|
|
1937
|
+
}));
|
|
1938
|
+
this.render();
|
|
1939
|
+
this.sync_canvas_size();
|
|
1940
|
+
this.sync_surface_selection();
|
|
1941
|
+
this.redraw();
|
|
1942
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
1943
|
+
const raf = win.requestAnimationFrame(() => {
|
|
1944
|
+
this.sync_canvas_size();
|
|
1945
|
+
this.honor_initial_fit();
|
|
1946
|
+
this.redraw();
|
|
1947
|
+
});
|
|
1948
|
+
this.teardown.push(() => win.cancelAnimationFrame(raf));
|
|
1949
|
+
this.gestures = new Gestures({
|
|
1950
|
+
container,
|
|
1951
|
+
svg_root: () => this.svg_root,
|
|
1952
|
+
hud_canvas: this.hud_canvas,
|
|
1953
|
+
camera: this.camera,
|
|
1954
|
+
editor,
|
|
1955
|
+
handle: { detach: () => {} },
|
|
1956
|
+
is_attended: () => this.attention.is_attended()
|
|
1957
|
+
});
|
|
1958
|
+
if (options.gestures !== false) applyDefaultGestures(this.gestures);
|
|
1959
|
+
const unsub = editor.subscribe(() => {
|
|
1960
|
+
this.current_tool = editor.state.tool;
|
|
1961
|
+
this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
|
|
1962
|
+
this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
|
|
1963
|
+
this.flush_dom();
|
|
1964
|
+
this.sync_surface_selection();
|
|
1965
|
+
this.hud.setPixelGrid({
|
|
1966
|
+
enabled: editor.style.pixel_grid,
|
|
1967
|
+
zoomThreshold: 4
|
|
1968
|
+
});
|
|
1969
|
+
this.sync_canvas_size();
|
|
1970
|
+
this.sync_cursor();
|
|
1971
|
+
if (this.pending_insert) {
|
|
1972
|
+
const t = this.current_tool;
|
|
1973
|
+
const cur = this.pending_insert;
|
|
1974
|
+
if (t.type === "insert" ? t.tag !== cur.tag : cur.phase === "armed") {
|
|
1975
|
+
if (cur.phase === "drawing") cur.session.discard();
|
|
1976
|
+
this.pending_insert = null;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
if (this.vector_edit && !this.active_preview) {
|
|
1980
|
+
const ses = this.vector_edit;
|
|
1981
|
+
const live_source = this.editor_internal().doc.is_vector_edit_target(ses.node_id);
|
|
1982
|
+
const had_selection = ses.selected_vertices.length > 0 || ses.selected_segments.length > 0 || ses.selected_tangents.length > 0;
|
|
1983
|
+
if (live_source === null || live_source.kind !== ses.source.kind) {
|
|
1984
|
+
if (had_selection) {
|
|
1985
|
+
ses.clear_selection();
|
|
1986
|
+
this.sync_selection_mirror();
|
|
1987
|
+
}
|
|
1988
|
+
} else {
|
|
1989
|
+
const live_d = source_to_session_d(live_source);
|
|
1990
|
+
if (live_d !== ses.last_seen_d) {
|
|
1991
|
+
ses.reconcile_after_external_mutation(live_d);
|
|
1992
|
+
if (had_selection) this.sync_selection_mirror();
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
this.request_redraw();
|
|
1997
|
+
});
|
|
1998
|
+
this.teardown.push(unsub);
|
|
1999
|
+
this.teardown.push(editor.subscribe_geometry(() => this.request_redraw()));
|
|
2000
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
2001
|
+
this.resize_observer = new ResizeObserver(() => this.sync_canvas_size());
|
|
2002
|
+
this.resize_observer.observe(container);
|
|
2003
|
+
this.teardown.push(() => this.resize_observer?.disconnect());
|
|
2004
|
+
} else {
|
|
2005
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
2006
|
+
const fn = () => this.sync_canvas_size();
|
|
2007
|
+
win.addEventListener("resize", fn);
|
|
2008
|
+
this.teardown.push(() => win.removeEventListener("resize", fn));
|
|
2009
|
+
}
|
|
2010
|
+
const detach_font_listener = install_font_load_geometry_bump(options.font_load_source ?? container.ownerDocument.fonts ?? null, () => editor._internal.bump_geometry());
|
|
2011
|
+
this.teardown.push(detach_font_listener);
|
|
2012
|
+
this.wire_events();
|
|
2013
|
+
const internal = editor._internal;
|
|
2014
|
+
this.editor_hover_internal = internal;
|
|
2015
|
+
internal.set_content_edit_driver((id) => this.enter_content_edit(id));
|
|
2016
|
+
this.teardown.push(() => internal.set_content_edit_driver(null));
|
|
2017
|
+
internal.register_command("transform.nudge", (args) => this.handle_nudge_command(args));
|
|
2018
|
+
this.teardown.push(() => internal.register_command("transform.nudge", default_nudge_handler(this.editor)));
|
|
2019
|
+
internal.set_computed_resolver({
|
|
2020
|
+
computed_property: (id, name) => {
|
|
2021
|
+
this.flush_dom();
|
|
2022
|
+
const el = this.element_index.get(id);
|
|
2023
|
+
if (!el) return null;
|
|
2024
|
+
const value = getComputedStyle(el).getPropertyValue(name);
|
|
2025
|
+
return value === "" ? null : value;
|
|
2026
|
+
},
|
|
2027
|
+
computed_paint: (id, channel) => {
|
|
2028
|
+
this.flush_dom();
|
|
2029
|
+
const el = this.element_index.get(id);
|
|
2030
|
+
if (!el) return null;
|
|
2031
|
+
const computed = getComputedStyle(el).getPropertyValue(channel);
|
|
2032
|
+
if (computed === "") return null;
|
|
2033
|
+
return {
|
|
2034
|
+
computed,
|
|
2035
|
+
resolved_paint: paint.parse(computed)
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
this.teardown.push(() => internal.set_computed_resolver(null));
|
|
2040
|
+
const geometry = new MemoizedGeometryProvider(new SvgGeometryDriver({
|
|
2041
|
+
element_for: (id) => this.element_index.get(id) ?? null,
|
|
2042
|
+
root: () => this.svg_root,
|
|
2043
|
+
camera: () => this.camera,
|
|
2044
|
+
container: () => this.container,
|
|
2045
|
+
pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root),
|
|
2046
|
+
flush: () => this.flush_dom()
|
|
2047
|
+
}), {
|
|
2048
|
+
subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
|
|
2049
|
+
subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
|
|
2050
|
+
});
|
|
2051
|
+
this._geometry_provider = geometry;
|
|
2052
|
+
internal.set_geometry(geometry);
|
|
2053
|
+
this.teardown.push(() => {
|
|
2054
|
+
internal.set_geometry(null);
|
|
2055
|
+
geometry.dispose();
|
|
2056
|
+
this._geometry_provider = null;
|
|
2057
|
+
});
|
|
2058
|
+
const hit_shapes = new MemoizedHitShapeProvider(new SvgHitShapeDriver({
|
|
2059
|
+
doc: () => {
|
|
2060
|
+
try {
|
|
2061
|
+
return this.editor_internal().doc;
|
|
2062
|
+
} catch {
|
|
2063
|
+
return null;
|
|
2064
|
+
}
|
|
2065
|
+
},
|
|
2066
|
+
bounds_of: (id) => geometry.bounds_of(id)
|
|
2067
|
+
}), {
|
|
2068
|
+
subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
|
|
2069
|
+
subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
|
|
2070
|
+
});
|
|
2071
|
+
this._hit_shapes = hit_shapes;
|
|
2072
|
+
this._z_order_dirty = true;
|
|
2073
|
+
this.teardown.push(editor.subscribe_with_selector((s) => s.structure_version, () => {
|
|
2074
|
+
this._z_order_dirty = true;
|
|
2075
|
+
}));
|
|
2076
|
+
this.teardown.push(() => {
|
|
2077
|
+
hit_shapes.dispose();
|
|
2078
|
+
this._hit_shapes = null;
|
|
2079
|
+
this._z_order_cache = [];
|
|
2080
|
+
});
|
|
2081
|
+
internal.set_surface_hover_override_driver((id) => {
|
|
2082
|
+
const response = this.hud.setHoverOverride(id);
|
|
2083
|
+
if (response.hoverChanged) internal.push_surface_hover(this.hud.hover());
|
|
2084
|
+
if (response.needsRedraw) this.redraw();
|
|
2085
|
+
});
|
|
2086
|
+
this.teardown.push(() => internal.set_surface_hover_override_driver(null));
|
|
2087
|
+
}
|
|
2088
|
+
hit_test(x, y) {
|
|
2089
|
+
if (this.vector_edit) return null;
|
|
2090
|
+
return this.pick_at(x, y, false);
|
|
2091
|
+
}
|
|
2092
|
+
/** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
|
|
2093
|
+
* `allow_root` is `false`, root hits are rejected (returns `null`) so
|
|
2094
|
+
* the HUD never hovers / selects / drags the document itself —
|
|
2095
|
+
* selection of the root is a host concern. When `true`, the root id
|
|
2096
|
+
* is returned for callers that need it as a measurement candidate
|
|
2097
|
+
* (`<svg>` is a snap target and should be a measurement target too;
|
|
2098
|
+
* see `compute_measurement_extra`). */
|
|
2099
|
+
pick_at(x, y, allow_root) {
|
|
2100
|
+
const world = this.camera.screen_to_world({
|
|
2101
|
+
x,
|
|
2102
|
+
y
|
|
2103
|
+
});
|
|
2104
|
+
return this._pick_node_at_world(world, allow_root);
|
|
2105
|
+
}
|
|
2106
|
+
/** Resolve a world-space point to a node id.
|
|
2107
|
+
*
|
|
2108
|
+
* Two paths, selected at runtime by `EditorStyle.hit_tolerance_px`:
|
|
2109
|
+
*
|
|
2110
|
+
* - **`> 0` (cmath fat-hit picker)** — walks document z-order
|
|
2111
|
+
* topmost-first via {@link pick_at_world}. Each candidate's
|
|
2112
|
+
* hit-shape comes from the memoized `SvgHitShapeDriver`
|
|
2113
|
+
* (intrinsic geometry or world-space bounds-rect fallback for
|
|
2114
|
+
* `<text>` / `<use>` / transformed nodes). Tolerance is screen-
|
|
2115
|
+
* CSS-px, converted to world units via `camera.zoom` so the band
|
|
2116
|
+
* stays the same width on screen regardless of zoom. Has known
|
|
2117
|
+
* issues — see https://grida.co/docs/wg/feat-svg-editor/hit-test.
|
|
2118
|
+
*
|
|
2119
|
+
* - **`<= 0` (legacy elementFromPoint)** — opt-out of the cmath
|
|
2120
|
+
* picker. Uses the browser's painted-pixel hit-test plus a
|
|
2121
|
+
* walk-up of `data-grida-id`. Pixel-exact, no tolerance, but
|
|
2122
|
+
* renderer-correct on every real-world SVG feature (transforms,
|
|
2123
|
+
* cascade, clip-path, fill-rule, pointer-events). This is the
|
|
2124
|
+
* v1-baseline path; useful for A/B comparison and as a safe
|
|
2125
|
+
* fallback if the cmath path misbehaves.
|
|
2126
|
+
*
|
|
2127
|
+
* `allow_root` controls whether the root `<svg>` may be returned:
|
|
2128
|
+
* selection HUD passes `false`, measurement HUD passes `true`.
|
|
2129
|
+
*
|
|
2130
|
+
* Used by both `pick_at` (HUD hover / measurement) and
|
|
2131
|
+
* `SvgGeometryDriver.node_at_point` (core editor selection) so one
|
|
2132
|
+
* source of truth governs every click that resolves to a node. */
|
|
2133
|
+
_pick_node_at_world(p, allow_root) {
|
|
2134
|
+
const root_id = this.editor.tree().root;
|
|
2135
|
+
const tol_px = this.editor.style.hit_tolerance_px;
|
|
2136
|
+
if (tol_px <= 0) return this._pick_node_via_dom(p, allow_root, root_id);
|
|
2137
|
+
const geometry = this._geometry_provider;
|
|
2138
|
+
const hit_shapes = this._hit_shapes;
|
|
2139
|
+
if (geometry && hit_shapes) {
|
|
2140
|
+
const zoom = this.camera.zoom;
|
|
2141
|
+
const hit = pick_at_world(p, {
|
|
2142
|
+
tolerance_world: zoom > 0 ? tol_px / zoom : 0,
|
|
2143
|
+
ordered_ids: this._ensure_z_order(false, root_id),
|
|
2144
|
+
bounds_of: (id) => geometry.bounds_of(id),
|
|
2145
|
+
hit_shape_of: (id) => hit_shapes.hit_shape_of(id)
|
|
2146
|
+
});
|
|
2147
|
+
if (hit !== null) return hit;
|
|
2148
|
+
}
|
|
2149
|
+
if (allow_root && geometry) {
|
|
2150
|
+
const root_bounds = geometry.bounds_of(root_id);
|
|
2151
|
+
if (root_bounds && cmath.rect.containsPoint(root_bounds, [p.x, p.y])) return root_id;
|
|
2152
|
+
}
|
|
2153
|
+
return null;
|
|
2154
|
+
}
|
|
2155
|
+
/** Legacy DOM-based picker. World point → container CSS px via camera,
|
|
2156
|
+
* then `elementFromPoint` + walk-up to `data-grida-id`. No tolerance,
|
|
2157
|
+
* no cmath, no z-order owned by us — the browser stacks paints and
|
|
2158
|
+
* returns the topmost. The picker the package shipped with at v1. */
|
|
2159
|
+
_pick_node_via_dom(p, allow_root, root_id) {
|
|
2160
|
+
const screen = this.camera.world_to_screen(p);
|
|
2161
|
+
const cr = this.container.getBoundingClientRect();
|
|
2162
|
+
const target = this.container.ownerDocument.elementFromPoint(cr.left + screen.x, cr.top + screen.y);
|
|
2163
|
+
if (!(target instanceof SVGElement)) return null;
|
|
2164
|
+
return walk_to_id(target, allow_root ? void 0 : root_id);
|
|
2165
|
+
}
|
|
2166
|
+
/** Lazily rebuild the z-order list. Walks the document depth-first,
|
|
2167
|
+
* emitting element ids in paint order (back → front). The root is
|
|
2168
|
+
* always pushed first; callers that disallow root pass `false` to
|
|
2169
|
+
* have the picker skip it. (Cheaper to keep one canonical list and
|
|
2170
|
+
* filter the root inline than to maintain two parallel arrays.) */
|
|
2171
|
+
_ensure_z_order(allow_root, root_id) {
|
|
2172
|
+
if (this._z_order_dirty) {
|
|
2173
|
+
const out = [];
|
|
2174
|
+
const doc = (() => {
|
|
2175
|
+
try {
|
|
2176
|
+
return this.editor_internal().doc;
|
|
2177
|
+
} catch {
|
|
2178
|
+
return null;
|
|
2179
|
+
}
|
|
2180
|
+
})();
|
|
2181
|
+
if (doc) {
|
|
2182
|
+
const walk = (id) => {
|
|
2183
|
+
out.push(id);
|
|
2184
|
+
for (const c of doc.element_children_of(id)) walk(c);
|
|
2185
|
+
};
|
|
2186
|
+
walk(doc.root);
|
|
2187
|
+
}
|
|
2188
|
+
this._z_order_cache = out;
|
|
2189
|
+
this._z_order_dirty = false;
|
|
2190
|
+
}
|
|
2191
|
+
if (allow_root) return this._z_order_cache;
|
|
2192
|
+
if (this._z_order_cache.length > 0 && this._z_order_cache[0] === root_id) return this._z_order_cache.slice(1);
|
|
2193
|
+
return this._z_order_cache.filter((id) => id !== root_id);
|
|
2194
|
+
}
|
|
2195
|
+
dispose() {
|
|
2196
|
+
if (this.text_edit) {
|
|
2197
|
+
this.text_edit.cancel();
|
|
2198
|
+
this.text_edit = null;
|
|
2199
|
+
this.text_edit_target = null;
|
|
2200
|
+
}
|
|
2201
|
+
if (this.vector_edit) {
|
|
2202
|
+
this.vector_edit = null;
|
|
2203
|
+
this.vector_edit_region_baseline = null;
|
|
2204
|
+
this.hud.setVectorSelection(null);
|
|
2205
|
+
}
|
|
2206
|
+
this.scene_marquee_baseline = null;
|
|
2207
|
+
this.gestures._dispose();
|
|
2208
|
+
this.translate_orchestrator.cancel();
|
|
2209
|
+
this.resize_orchestrator.cancel();
|
|
2210
|
+
this.rotate_orchestrator.cancel();
|
|
2211
|
+
this.active_preview = null;
|
|
2212
|
+
if (this.redraw_raf_id !== null) {
|
|
2213
|
+
(this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.redraw_raf_id);
|
|
2214
|
+
this.redraw_raf_id = null;
|
|
2215
|
+
}
|
|
2216
|
+
for (const fn of this.teardown) fn();
|
|
2217
|
+
this.teardown = [];
|
|
2218
|
+
this.hud.dispose();
|
|
2219
|
+
this.hud_canvas.remove();
|
|
2220
|
+
if (this.svg_root) this.svg_root.remove();
|
|
2221
|
+
this.svg_root = null;
|
|
2222
|
+
this.element_index.clear();
|
|
2223
|
+
this.last_pointer_valid = false;
|
|
2224
|
+
}
|
|
2225
|
+
/** Public — invoked by the `DomSurfaceHandle` wrapper before `detach()`. */
|
|
2226
|
+
detach_gestures() {
|
|
2227
|
+
this.gestures._dispose();
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* Bring the live DOM up to date with the doc IR iff it is stale.
|
|
2231
|
+
*
|
|
2232
|
+
* Staleness contract: anything that reads the LIVE DOM as a proxy for
|
|
2233
|
+
* document state — the geometry driver (`getBBox` / `getCTM`), the
|
|
2234
|
+
* computed-style resolver — MUST call this first. Doc listeners (the
|
|
2235
|
+
* geometry channel, editor `subscribe`) fire synchronously inside the
|
|
2236
|
+
* mutation, BEFORE the surface's render listener has projected the new
|
|
2237
|
+
* attrs into the DOM; a read in that window returns the PREVIOUS
|
|
2238
|
+
* geometry, and through `MemoizedGeometryProvider` it would be cached as
|
|
2239
|
+
* if current — every later consumer (align, resize_to, snap) then plans
|
|
2240
|
+
* against one-mutation-stale bounds. Same model as CSS layout: reading
|
|
2241
|
+
* `offsetWidth` flushes pending layout; reading `bounds_of` flushes the
|
|
2242
|
+
* pending render.
|
|
2243
|
+
*/
|
|
2244
|
+
flush_dom() {
|
|
2245
|
+
if (this.rendered_doc_revision === this.editor._internal.doc.revision) return;
|
|
2246
|
+
this.render();
|
|
2247
|
+
}
|
|
2248
|
+
render() {
|
|
2249
|
+
if (this.text_edit) return;
|
|
2250
|
+
const owner_doc = this.container.ownerDocument;
|
|
2251
|
+
const doc = this.editor._internal.doc;
|
|
2252
|
+
const svg_text = this.editor.serialize();
|
|
2253
|
+
const wrapper = owner_doc.createElement("div");
|
|
2254
|
+
wrapper.innerHTML = svg_text;
|
|
2255
|
+
const new_svg = wrapper.querySelector("svg");
|
|
2256
|
+
if (!(new_svg instanceof SVGSVGElement)) return;
|
|
2257
|
+
if (this.svg_root) this.svg_root.replaceWith(new_svg);
|
|
2258
|
+
else this.container.insertBefore(new_svg, this.hud_canvas);
|
|
2259
|
+
this.svg_root = new_svg;
|
|
2260
|
+
this.apply_svg_layout();
|
|
2261
|
+
this.apply_camera_transform();
|
|
2262
|
+
this.element_index.clear();
|
|
2263
|
+
const ids = doc.all_elements();
|
|
2264
|
+
let i = 0;
|
|
2265
|
+
const tag_walk = (el) => {
|
|
2266
|
+
if (i < ids.length) {
|
|
2267
|
+
const id = ids[i++];
|
|
2268
|
+
el.setAttribute(ID_ATTR, id);
|
|
2269
|
+
this.element_index.set(id, el);
|
|
2270
|
+
}
|
|
2271
|
+
for (let c = el.firstElementChild; c; c = c.nextElementSibling) if (c instanceof SVGElement) tag_walk(c);
|
|
2272
|
+
};
|
|
2273
|
+
tag_walk(new_svg);
|
|
2274
|
+
this.rendered_doc_revision = doc.revision;
|
|
2275
|
+
}
|
|
2276
|
+
sync_canvas_size() {
|
|
2277
|
+
const cr = this.container.getBoundingClientRect();
|
|
2278
|
+
this.hud.setSize(cr.width, cr.height);
|
|
2279
|
+
this.camera._set_viewport_size(cr.width, cr.height);
|
|
2280
|
+
this.redraw();
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Apply absolute positioning + transform-origin to the SVG so the camera's
|
|
2284
|
+
* CSS matrix maps SVG-coord (0,0) cleanly to container-screen (tx, ty).
|
|
2285
|
+
* Called after every render() that may have replaced the root element.
|
|
2286
|
+
*/
|
|
2287
|
+
apply_svg_layout() {
|
|
2288
|
+
if (!this.svg_root) return;
|
|
2289
|
+
const style = this.svg_root.style;
|
|
2290
|
+
style.position = "absolute";
|
|
2291
|
+
style.left = "0";
|
|
2292
|
+
style.top = "0";
|
|
2293
|
+
style.transformOrigin = "0 0";
|
|
2294
|
+
const vb = this.svg_root.getAttribute("viewBox");
|
|
2295
|
+
if (vb) {
|
|
2296
|
+
const parts = vb.split(/[\s,]+/).map(Number);
|
|
2297
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
|
|
2298
|
+
const [, , w, h] = parts;
|
|
2299
|
+
if (w > 0 && h > 0) {
|
|
2300
|
+
style.width = `${w}px`;
|
|
2301
|
+
style.height = `${h}px`;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Push the current camera transform to the SVG as a CSS `matrix(...)`.
|
|
2308
|
+
* The HUD canvas stays at identity — selection chrome reads node bounds
|
|
2309
|
+
* via `getScreenCTM()`, which already includes the CSS transform, so
|
|
2310
|
+
* chrome aligns automatically and stays 1px sharp at any zoom.
|
|
2311
|
+
*/
|
|
2312
|
+
apply_camera_transform() {
|
|
2313
|
+
if (!this.svg_root) return;
|
|
2314
|
+
const t = this.camera.transform;
|
|
2315
|
+
this.svg_root.style.transform = `matrix(${t[0][0]}, ${t[1][0]}, ${t[0][1]}, ${t[1][1]}, ${t[0][2]}, ${t[1][2]})`;
|
|
2316
|
+
}
|
|
2317
|
+
/** One-shot fit-on-attach. Runs after layout has settled. */
|
|
2318
|
+
honor_initial_fit() {
|
|
2319
|
+
if (!this.fit_on_attach) return;
|
|
2320
|
+
this.fit_on_attach = false;
|
|
2321
|
+
this.camera.fit("<root>");
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* BoundsResolver for `Camera.fit(target)`. The Camera class handles Rect
|
|
2325
|
+
* passthrough itself; this resolver only sees string targets — sentinels
|
|
2326
|
+
* ("<root>", "<selection>") and NodeIds.
|
|
2327
|
+
*/
|
|
2328
|
+
resolve_world_bounds(target) {
|
|
2329
|
+
if (target === "<root>") return this.root_world_bounds();
|
|
2330
|
+
const geometry = this.editor.geometry;
|
|
2331
|
+
if (target === "<selection>") {
|
|
2332
|
+
const sel = this.editor.state.selection;
|
|
2333
|
+
if (sel.length === 0 || !geometry) return null;
|
|
2334
|
+
const rects = [...geometry.bounds_of_many(sel).values()];
|
|
2335
|
+
if (rects.length === 0) return null;
|
|
2336
|
+
return cmath.rect.union(rects);
|
|
2337
|
+
}
|
|
2338
|
+
return geometry?.bounds_of(target) ?? null;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* World-space bounds of the root document. Prefer `viewBox` (the SVG's
|
|
2342
|
+
* declared world rect), fall back to `width`/`height` attrs, then the
|
|
2343
|
+
* SVG root's `getBBox()` as a last resort.
|
|
2344
|
+
*/
|
|
2345
|
+
root_world_bounds() {
|
|
2346
|
+
const root_id = this.editor.tree().root;
|
|
2347
|
+
const doc = this.editor.document;
|
|
2348
|
+
const view_box = doc.get_attr(root_id, "viewBox");
|
|
2349
|
+
if (view_box) {
|
|
2350
|
+
const parts = view_box.trim().split(/[\s,]+/).map(Number);
|
|
2351
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) return {
|
|
2352
|
+
x: parts[0],
|
|
2353
|
+
y: parts[1],
|
|
2354
|
+
width: parts[2],
|
|
2355
|
+
height: parts[3]
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
const w = parseFloat(doc.get_attr(root_id, "width") ?? "");
|
|
2359
|
+
const h = parseFloat(doc.get_attr(root_id, "height") ?? "");
|
|
2360
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return {
|
|
2361
|
+
x: 0,
|
|
2362
|
+
y: 0,
|
|
2363
|
+
width: w,
|
|
2364
|
+
height: h
|
|
2365
|
+
};
|
|
2366
|
+
if (this.svg_root) try {
|
|
2367
|
+
const b = this.svg_root.getBBox();
|
|
2368
|
+
if (b.width > 0 && b.height > 0) return {
|
|
2369
|
+
x: b.x,
|
|
2370
|
+
y: b.y,
|
|
2371
|
+
width: b.width,
|
|
2372
|
+
height: b.height
|
|
2373
|
+
};
|
|
2374
|
+
} catch {}
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
/** Single per-frame draw entry — merges host-fed extras with surface chrome. */
|
|
2378
|
+
redraw() {
|
|
2379
|
+
this.hud.draw(merge_hud_draws(this.compute_measurement_extra(), this.compute_size_meter_extra(), this.compute_snap_extra(), this.compute_member_outlines_extra()));
|
|
2380
|
+
}
|
|
2381
|
+
/** RAF-coalesced `redraw` for event sources that may emit many times per
|
|
2382
|
+
* frame (geometry-version bumps mid-drag). Camera/gesture paths still call
|
|
2383
|
+
* `redraw` directly for synchronous chrome alignment. */
|
|
2384
|
+
request_redraw() {
|
|
2385
|
+
if (this.redraw_raf_id !== null) return;
|
|
2386
|
+
const win = this.container.ownerDocument.defaultView ?? window;
|
|
2387
|
+
this.redraw_raf_id = win.requestAnimationFrame(() => {
|
|
2388
|
+
this.redraw_raf_id = null;
|
|
2389
|
+
this.redraw();
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* Build the host-fed measurement guide for the current frame, or
|
|
2394
|
+
* `undefined` if no guide should be drawn.
|
|
2395
|
+
*
|
|
2396
|
+
* Master signal: Alt held (read from `surface.modifiers()`). Each
|
|
2397
|
+
* additional condition is a derivation, not a separate flag — this keeps
|
|
2398
|
+
* a single source of truth and lets future Alt-consumers (constrained
|
|
2399
|
+
* resize, axis-lock, …) live next to this one without re-tracking the key.
|
|
2400
|
+
*/
|
|
2401
|
+
compute_measurement_extra() {
|
|
2402
|
+
if (!this.hud.modifiers().alt) return void 0;
|
|
2403
|
+
if (this.hud.gesture().kind !== "idle") return void 0;
|
|
2404
|
+
const sel = this.editor.state.selection;
|
|
2405
|
+
if (sel.length === 0) return void 0;
|
|
2406
|
+
let hover = this.hud.hover();
|
|
2407
|
+
if (!hover && this.last_pointer_valid) hover = this.pick_at(this.last_pointer.x, this.last_pointer.y, true);
|
|
2408
|
+
if (!hover) return void 0;
|
|
2409
|
+
if (sel.includes(hover)) return void 0;
|
|
2410
|
+
const a_container = sel.map((id) => this.container_box(id)).filter((r) => r !== null);
|
|
2411
|
+
if (a_container.length === 0) return void 0;
|
|
2412
|
+
const b_container = this.container_box(hover);
|
|
2413
|
+
if (!b_container) return void 0;
|
|
2414
|
+
const m_container = measure(cmath.rect.union(a_container), b_container);
|
|
2415
|
+
if (!m_container) return void 0;
|
|
2416
|
+
const draw = measurementToHUDDraw(m_container, this.editor.style.measurement_color);
|
|
2417
|
+
const geometry = this.editor.geometry;
|
|
2418
|
+
if (geometry) {
|
|
2419
|
+
const a_world = sel.map((id) => geometry.bounds_of(id)).filter((r) => r !== null);
|
|
2420
|
+
const b_world = geometry.bounds_of(hover);
|
|
2421
|
+
if (a_world.length > 0 && b_world) {
|
|
2422
|
+
const m_world = measure(cmath.rect.union(a_world), b_world);
|
|
2423
|
+
if (m_world) {
|
|
2424
|
+
let cursor = 0;
|
|
2425
|
+
for (const line of draw.lines ?? []) {
|
|
2426
|
+
if (line.label === void 0) continue;
|
|
2427
|
+
const side = next_labellable_side(cursor, m_container, m_world);
|
|
2428
|
+
if (side < 0) break;
|
|
2429
|
+
line.label = cmath.ui.formatNumber(m_world.distance[side], 1);
|
|
2430
|
+
cursor = side + 1;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
return draw;
|
|
2436
|
+
}
|
|
2437
|
+
/** Pill position is container-space (tracks camera); values are
|
|
2438
|
+
* world-space (zoom-invariant). Hidden in text-edit mode. */
|
|
2439
|
+
compute_size_meter_extra() {
|
|
2440
|
+
if (!this.editor.style.show_size_meter) return void 0;
|
|
2441
|
+
if (this.editor.state.mode === "edit-content") return void 0;
|
|
2442
|
+
const sel = this.editor.state.selection;
|
|
2443
|
+
if (sel.length === 0) return void 0;
|
|
2444
|
+
const geometry = this.editor.geometry;
|
|
2445
|
+
if (!geometry) return void 0;
|
|
2446
|
+
const color = this.editor.style.chrome_color;
|
|
2447
|
+
if (sel.length === 1) {
|
|
2448
|
+
const shape = this.shape_of(sel[0]);
|
|
2449
|
+
if (shape && shape.kind === "transformed") {
|
|
2450
|
+
const { local, matrix } = shape;
|
|
2451
|
+
const { anchor, angle } = pick_lowest_side_anchor(cmath.rect.toCorners(local).map((p) => cmath.vector2.transform(p, matrix)), true);
|
|
2452
|
+
const label = `${cmath.ui.formatNumber(local.width, 1)} × ${cmath.ui.formatNumber(local.height, 1)}`;
|
|
2453
|
+
return { lines: [{
|
|
2454
|
+
x1: anchor[0],
|
|
2455
|
+
y1: anchor[1],
|
|
2456
|
+
x2: anchor[0],
|
|
2457
|
+
y2: anchor[1],
|
|
2458
|
+
label,
|
|
2459
|
+
color,
|
|
2460
|
+
labelAngle: angle,
|
|
2461
|
+
group: SVG_HUD_GROUP.sizeMeter
|
|
2462
|
+
}] };
|
|
2463
|
+
}
|
|
2464
|
+
if (shape && shape.kind === "line") {
|
|
2465
|
+
const { p1, p2 } = shape;
|
|
2466
|
+
const { anchor, angle } = pick_lowest_side_anchor([p1, p2], false);
|
|
2467
|
+
const dx = p2[0] - p1[0];
|
|
2468
|
+
const dy = p2[1] - p1[1];
|
|
2469
|
+
const label = `${cmath.ui.formatNumber(Math.abs(dx), 1)} × ${cmath.ui.formatNumber(Math.abs(dy), 1)}`;
|
|
2470
|
+
return { lines: [{
|
|
2471
|
+
x1: anchor[0],
|
|
2472
|
+
y1: anchor[1],
|
|
2473
|
+
x2: anchor[0],
|
|
2474
|
+
y2: anchor[1],
|
|
2475
|
+
label,
|
|
2476
|
+
color,
|
|
2477
|
+
labelAngle: angle,
|
|
2478
|
+
group: SVG_HUD_GROUP.sizeMeter
|
|
2479
|
+
}] };
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
const world_rects = [];
|
|
2483
|
+
const container_rects = [];
|
|
2484
|
+
for (const id of sel) {
|
|
2485
|
+
const world = geometry.bounds_of(id);
|
|
2486
|
+
const container = this.container_box(id);
|
|
2487
|
+
if (!world || !container) continue;
|
|
2488
|
+
world_rects.push(world);
|
|
2489
|
+
container_rects.push(container);
|
|
2490
|
+
}
|
|
2491
|
+
if (world_rects.length === 0) return void 0;
|
|
2492
|
+
const world_union = cmath.rect.union(world_rects);
|
|
2493
|
+
const container_union = cmath.rect.union(container_rects);
|
|
2494
|
+
const cx = container_union.x + container_union.width / 2;
|
|
2495
|
+
const by = container_union.y + container_union.height;
|
|
2496
|
+
return { lines: [{
|
|
2497
|
+
x1: cx,
|
|
2498
|
+
y1: by,
|
|
2499
|
+
x2: cx,
|
|
2500
|
+
y2: by,
|
|
2501
|
+
label: `${cmath.ui.formatNumber(world_union.width, 1)} × ${cmath.ui.formatNumber(world_union.height, 1)}`,
|
|
2502
|
+
color,
|
|
2503
|
+
group: SVG_HUD_GROUP.sizeMeter
|
|
2504
|
+
}] };
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Thin outline rects for each individually-selected member, drawn
|
|
2508
|
+
* inside the single envelope chrome when a multi-selection is active.
|
|
2509
|
+
* Lets the user see _what_ is selected separately from _what will
|
|
2510
|
+
* resize as a unit_ (the envelope itself, with corner handles).
|
|
2511
|
+
*
|
|
2512
|
+
* Single-member selections already get their outline from the
|
|
2513
|
+
* chrome's own rect renderer — emitting outlines here would be
|
|
2514
|
+
* redundant (double-stroked outline). Returns `undefined` then.
|
|
2515
|
+
*/
|
|
2516
|
+
compute_member_outlines_extra() {
|
|
2517
|
+
if (this.editor.state.mode === "edit-content") return void 0;
|
|
2518
|
+
const sel = this.editor.state.selection;
|
|
2519
|
+
if (sel.length < 2) return void 0;
|
|
2520
|
+
const color = this.editor.style.chrome_color;
|
|
2521
|
+
const rects = [];
|
|
2522
|
+
for (const id of sel) {
|
|
2523
|
+
const r = this.container_box(id);
|
|
2524
|
+
if (!r) continue;
|
|
2525
|
+
rects.push({
|
|
2526
|
+
x: r.x,
|
|
2527
|
+
y: r.y,
|
|
2528
|
+
width: r.width,
|
|
2529
|
+
height: r.height,
|
|
2530
|
+
stroke: true,
|
|
2531
|
+
color,
|
|
2532
|
+
group: SVG_HUD_GROUP.memberOutline
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
return rects.length > 0 ? { rects } : void 0;
|
|
2536
|
+
}
|
|
2537
|
+
compute_snap_extra() {
|
|
2538
|
+
if (this.point_snap_guide) return snapGuideToHUDDraw(this.point_snap_guide, this.editor.style.measurement_color);
|
|
2539
|
+
const insert_guide = this.pending_insert?.phase === "drawing" ? this.pending_insert.snap_session?.last_guide : void 0;
|
|
2540
|
+
if (insert_guide) return snapGuideToHUDDraw(this.project_guide_to_screen(insert_guide), this.editor.style.measurement_color);
|
|
2541
|
+
const guides = this.translate_orchestrator.last_guides.length > 0 ? this.translate_orchestrator.last_guides : this.resize_orchestrator.last_guides.length > 0 ? this.resize_orchestrator.last_guides : this.nudge_dwell_watcher.guides;
|
|
2542
|
+
if (guides.length === 0) return void 0;
|
|
2543
|
+
return snapGuideToHUDDraw(this.project_guide_to_screen(guides[0]), this.editor.style.measurement_color);
|
|
2544
|
+
}
|
|
2545
|
+
/** Project a snap guide from world space (pipeline output) to screen
|
|
2546
|
+
* CSS-px (the HUD canvas's identity-transform coordinate system).
|
|
2547
|
+
* Lines + points project via `camera.world_to_screen`; rules carry
|
|
2548
|
+
* a single axis-offset, so they project a representative point on
|
|
2549
|
+
* that axis (the camera has no rotation, so per-axis scale +
|
|
2550
|
+
* translate fully describes the projection). */
|
|
2551
|
+
project_guide_to_screen(g) {
|
|
2552
|
+
const cam = this.camera;
|
|
2553
|
+
return {
|
|
2554
|
+
lines: g.lines.map((l) => {
|
|
2555
|
+
const p1 = cam.world_to_screen({
|
|
2556
|
+
x: l.x1,
|
|
2557
|
+
y: l.y1
|
|
2558
|
+
});
|
|
2559
|
+
const p2 = cam.world_to_screen({
|
|
2560
|
+
x: l.x2,
|
|
2561
|
+
y: l.y2
|
|
2562
|
+
});
|
|
2563
|
+
return {
|
|
2564
|
+
...l,
|
|
2565
|
+
x1: p1.x,
|
|
2566
|
+
y1: p1.y,
|
|
2567
|
+
x2: p2.x,
|
|
2568
|
+
y2: p2.y
|
|
2569
|
+
};
|
|
2570
|
+
}),
|
|
2571
|
+
points: g.points.map(([x, y]) => {
|
|
2572
|
+
const p = cam.world_to_screen({
|
|
2573
|
+
x,
|
|
2574
|
+
y
|
|
2575
|
+
});
|
|
2576
|
+
return [p.x, p.y];
|
|
2577
|
+
}),
|
|
2578
|
+
rules: g.rules.map(([axis, offset]) => {
|
|
2579
|
+
const p = cam.world_to_screen(axis === "x" ? {
|
|
2580
|
+
x: offset,
|
|
2581
|
+
y: 0
|
|
2582
|
+
} : {
|
|
2583
|
+
x: 0,
|
|
2584
|
+
y: offset
|
|
2585
|
+
});
|
|
2586
|
+
return [axis, axis === "x" ? p.x : p.y];
|
|
2587
|
+
})
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
/** Freeze snap inputs at gesture start: dragged agent rects +
|
|
2591
|
+
* neighbor candidate rects. Both come from `bbox_world_for_snap` →
|
|
2592
|
+
* `bbox_world`, which projects each element's `getBBox()` through
|
|
2593
|
+
* its own `transform=` so the rects sit in **doc space** — the root
|
|
2594
|
+
* SVG's user-coordinate system, with element-local rotations / etc.
|
|
2595
|
+
* already accounted for. The pipeline operates in this same space,
|
|
2596
|
+
* so snap inputs and pipeline deltas share a frame end-to-end.
|
|
2597
|
+
*
|
|
2598
|
+
* Returns a fresh `SnapSession`; the caller (orchestrator OR
|
|
2599
|
+
* nudge-dwell watcher) owns its lifetime. */
|
|
2600
|
+
open_snap_session_for(ids) {
|
|
2601
|
+
const doc = this.editor.document;
|
|
2602
|
+
const neighbor_ids = compute_neighborhood(doc, ids);
|
|
2603
|
+
const agent_id_set = /* @__PURE__ */ new Set();
|
|
2604
|
+
for (const id of ids) for (const inner of snap_descent(doc, id)) agent_id_set.add(inner);
|
|
2605
|
+
const bounds_of = (id) => this._geometry_provider?.bounds_of(id) ?? null;
|
|
2606
|
+
const agents = [];
|
|
2607
|
+
for (const id of agent_id_set) {
|
|
2608
|
+
const r = bounds_of(id);
|
|
2609
|
+
if (r) agents.push(r);
|
|
2610
|
+
}
|
|
2611
|
+
const neighbors = [];
|
|
2612
|
+
for (const id of neighbor_ids) {
|
|
2613
|
+
const r = bounds_of(id);
|
|
2614
|
+
if (r) neighbors.push(r);
|
|
2615
|
+
}
|
|
2616
|
+
return new SnapSession({
|
|
2617
|
+
agents,
|
|
2618
|
+
neighbors
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
/** Cancel any in-flight gesture (orchestrator + active_preview). Used
|
|
2622
|
+
* by Escape and the `cancel_gesture` intent. Returns whether anything
|
|
2623
|
+
* was canceled. */
|
|
2624
|
+
cancel_in_flight() {
|
|
2625
|
+
let canceled = false;
|
|
2626
|
+
if (this.scene_marquee_baseline) {
|
|
2627
|
+
this.scene_marquee_baseline = null;
|
|
2628
|
+
canceled = true;
|
|
2629
|
+
}
|
|
2630
|
+
if (this.translate_orchestrator.has_active_session()) {
|
|
2631
|
+
this.translate_orchestrator.cancel();
|
|
2632
|
+
canceled = true;
|
|
2633
|
+
}
|
|
2634
|
+
if (this.resize_orchestrator.has_active_session()) {
|
|
2635
|
+
this.resize_orchestrator.cancel();
|
|
2636
|
+
canceled = true;
|
|
2637
|
+
}
|
|
2638
|
+
if (this.rotate_orchestrator.has_active_session()) {
|
|
2639
|
+
this.rotate_orchestrator.cancel();
|
|
2640
|
+
canceled = true;
|
|
2641
|
+
}
|
|
2642
|
+
if (this.active_preview) {
|
|
2643
|
+
this.active_preview.session.discard();
|
|
2644
|
+
this.active_preview = null;
|
|
2645
|
+
this.point_snap_guide = void 0;
|
|
2646
|
+
canceled = true;
|
|
2647
|
+
}
|
|
2648
|
+
if (this.pending_insert) {
|
|
2649
|
+
if (this.pending_insert.phase === "drawing") {
|
|
2650
|
+
this.pending_insert.session.discard();
|
|
2651
|
+
this.pending_insert.snap_session?.dispose?.();
|
|
2652
|
+
}
|
|
2653
|
+
this.pending_insert = null;
|
|
2654
|
+
this.editor.set_tool({ type: "cursor" });
|
|
2655
|
+
canceled = true;
|
|
2656
|
+
}
|
|
2657
|
+
if (canceled) this.request_redraw();
|
|
2658
|
+
return canceled;
|
|
2659
|
+
}
|
|
2660
|
+
sync_surface_selection() {
|
|
2661
|
+
const state = this.editor.state;
|
|
2662
|
+
if (state.mode === "edit-content") {
|
|
2663
|
+
this.hud.setSelection([]);
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
if (state.selection.length <= 1) {
|
|
2667
|
+
this.hud.setSelection(state.selection);
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
this.hud.setSelection(this.build_selection_groups(state.selection));
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Build the HUD's `SelectionGroup[]` for a multi-member selection.
|
|
2674
|
+
* Singleton selections do NOT go through this helper — see
|
|
2675
|
+
* {@link sync_surface_selection} for the policy.
|
|
2676
|
+
*/
|
|
2677
|
+
build_selection_groups(selection) {
|
|
2678
|
+
if (selection.length < 2) return [];
|
|
2679
|
+
const rects = [];
|
|
2680
|
+
for (const id of selection) {
|
|
2681
|
+
const r = this.container_box(id);
|
|
2682
|
+
if (r) rects.push(r);
|
|
2683
|
+
}
|
|
2684
|
+
if (rects.length === 0) return [];
|
|
2685
|
+
return [{
|
|
2686
|
+
ids: selection,
|
|
2687
|
+
shape: {
|
|
2688
|
+
kind: "rect",
|
|
2689
|
+
rect: cmath.rect.union(rects)
|
|
2690
|
+
}
|
|
2691
|
+
}];
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Return the selection shape for a node. Vector `<line>` nodes return
|
|
2695
|
+
* `{ kind: "line", p1, p2 }` so the HUD lays out endpoint knobs; all
|
|
2696
|
+
* other nodes return `{ kind: "rect", rect }` using the container-space
|
|
2697
|
+
* bounding box.
|
|
2698
|
+
*/
|
|
2699
|
+
shape_of(id) {
|
|
2700
|
+
const tag = this.tag_of(id);
|
|
2701
|
+
if (tag === "line") {
|
|
2702
|
+
const line = this.line_endpoints_in_container(id);
|
|
2703
|
+
if (line) return {
|
|
2704
|
+
kind: "line",
|
|
2705
|
+
p1: line.p1,
|
|
2706
|
+
p2: line.p2
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
const el = this.element_index.get(id);
|
|
2710
|
+
if (!(el instanceof SVGGraphicsElement) || typeof el.getBBox !== "function" || typeof el.getScreenCTM !== "function" || tag === "svg") {
|
|
2711
|
+
const rect = this.container_box(id);
|
|
2712
|
+
return rect ? {
|
|
2713
|
+
kind: "rect",
|
|
2714
|
+
rect
|
|
2715
|
+
} : null;
|
|
2716
|
+
}
|
|
2717
|
+
let bbox_local;
|
|
2718
|
+
try {
|
|
2719
|
+
const b = el.getBBox();
|
|
2720
|
+
bbox_local = {
|
|
2721
|
+
x: b.x,
|
|
2722
|
+
y: b.y,
|
|
2723
|
+
width: b.width,
|
|
2724
|
+
height: b.height
|
|
2725
|
+
};
|
|
2726
|
+
} catch {
|
|
2727
|
+
const rect = this.container_box(id);
|
|
2728
|
+
return rect ? {
|
|
2729
|
+
kind: "rect",
|
|
2730
|
+
rect
|
|
2731
|
+
} : null;
|
|
2732
|
+
}
|
|
2733
|
+
const ctm = el.getScreenCTM();
|
|
2734
|
+
if (!ctm) {
|
|
2735
|
+
const rect = this.container_box(id);
|
|
2736
|
+
return rect ? {
|
|
2737
|
+
kind: "rect",
|
|
2738
|
+
rect
|
|
2739
|
+
} : null;
|
|
2740
|
+
}
|
|
2741
|
+
if (ctm.b === 0 && ctm.c === 0) {
|
|
2742
|
+
const rect = this.container_box(id);
|
|
2743
|
+
return rect ? {
|
|
2744
|
+
kind: "rect",
|
|
2745
|
+
rect
|
|
2746
|
+
} : null;
|
|
2747
|
+
}
|
|
2748
|
+
const cr = this.container.getBoundingClientRect();
|
|
2749
|
+
const dx = -cr.left + this.container.scrollLeft;
|
|
2750
|
+
const dy = -cr.top + this.container.scrollTop;
|
|
2751
|
+
return {
|
|
2752
|
+
kind: "transformed",
|
|
2753
|
+
local: bbox_local,
|
|
2754
|
+
matrix: [[
|
|
2755
|
+
ctm.a,
|
|
2756
|
+
ctm.c,
|
|
2757
|
+
ctm.e + dx
|
|
2758
|
+
], [
|
|
2759
|
+
ctm.b,
|
|
2760
|
+
ctm.d,
|
|
2761
|
+
ctm.f + dy
|
|
2762
|
+
]]
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Project an SVG `<line>`'s `x1,y1,x2,y2` from its own coordinate space
|
|
2767
|
+
* to the container's coordinate space, where the HUD operates.
|
|
2768
|
+
*/
|
|
2769
|
+
line_endpoints_in_container(id) {
|
|
2770
|
+
const el = this.element_index.get(id);
|
|
2771
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
2772
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
2773
|
+
const ctm = el.getScreenCTM();
|
|
2774
|
+
if (!ctm || !this.svg_root) return null;
|
|
2775
|
+
const x1 = parseFloat(el.getAttribute("x1") ?? "0");
|
|
2776
|
+
const y1 = parseFloat(el.getAttribute("y1") ?? "0");
|
|
2777
|
+
const x2 = parseFloat(el.getAttribute("x2") ?? "0");
|
|
2778
|
+
const y2 = parseFloat(el.getAttribute("y2") ?? "0");
|
|
2779
|
+
if (!Number.isFinite(x1) || !Number.isFinite(y1)) return null;
|
|
2780
|
+
if (!Number.isFinite(x2) || !Number.isFinite(y2)) return null;
|
|
2781
|
+
const project = (px, py) => {
|
|
2782
|
+
return [ctm.a * px + ctm.c * py + ctm.e, ctm.b * px + ctm.d * py + ctm.f];
|
|
2783
|
+
};
|
|
2784
|
+
const cr = this.container.getBoundingClientRect();
|
|
2785
|
+
const [s1x, s1y] = project(x1, y1);
|
|
2786
|
+
const [s2x, s2y] = project(x2, y2);
|
|
2787
|
+
return {
|
|
2788
|
+
p1: [s1x - cr.left + this.container.scrollLeft, s1y - cr.top + this.container.scrollTop],
|
|
2789
|
+
p2: [s2x - cr.left + this.container.scrollLeft, s2y - cr.top + this.container.scrollTop]
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
/** Container-space bounding rect for a node. Callers running a batch
|
|
2793
|
+
* (snap session open, marquee) can pass a pre-read `container_rect`
|
|
2794
|
+
* to avoid the per-call layout flush.
|
|
2795
|
+
*
|
|
2796
|
+
* `<svg>` elements (root or nested) establish a viewport (SVG 2 §7.2).
|
|
2797
|
+
* Their visible canvas is the viewport rect, NOT the union of
|
|
2798
|
+
* descendant geometry that `getBBox()` reports (SVG 2 §4.6.4). For
|
|
2799
|
+
* those we read `getBoundingClientRect()` — the CSSOM rendered box
|
|
2800
|
+
* of the `<svg>` element itself, independent of children. Every
|
|
2801
|
+
* other element type still goes through `getBBox` + `getScreenCTM`. */
|
|
2802
|
+
container_box(id, container_rect) {
|
|
2803
|
+
const el = this.element_index.get(id);
|
|
2804
|
+
if (!el) return null;
|
|
2805
|
+
const ge = el;
|
|
2806
|
+
if (typeof ge.getBBox !== "function" || typeof ge.getScreenCTM !== "function") return null;
|
|
2807
|
+
const cr = container_rect ?? this.container.getBoundingClientRect();
|
|
2808
|
+
if (this.tag_of(id) === "svg") {
|
|
2809
|
+
const r = el.getBoundingClientRect();
|
|
2810
|
+
return {
|
|
2811
|
+
x: r.left - cr.left + this.container.scrollLeft,
|
|
2812
|
+
y: r.top - cr.top + this.container.scrollTop,
|
|
2813
|
+
width: r.width,
|
|
2814
|
+
height: r.height
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
let bbox;
|
|
2818
|
+
try {
|
|
2819
|
+
const b = ge.getBBox();
|
|
2820
|
+
bbox = {
|
|
2821
|
+
x: b.x,
|
|
2822
|
+
y: b.y,
|
|
2823
|
+
width: b.width,
|
|
2824
|
+
height: b.height
|
|
2825
|
+
};
|
|
2826
|
+
} catch {
|
|
2827
|
+
return null;
|
|
2828
|
+
}
|
|
2829
|
+
const ctm = ge.getScreenCTM();
|
|
2830
|
+
if (!ctm) return null;
|
|
2831
|
+
const project = (px, py) => ({
|
|
2832
|
+
x: ctm.a * px + ctm.c * py + ctm.e,
|
|
2833
|
+
y: ctm.b * px + ctm.d * py + ctm.f
|
|
2834
|
+
});
|
|
2835
|
+
const corners = [
|
|
2836
|
+
project(bbox.x, bbox.y),
|
|
2837
|
+
project(bbox.x + bbox.width, bbox.y),
|
|
2838
|
+
project(bbox.x + bbox.width, bbox.y + bbox.height),
|
|
2839
|
+
project(bbox.x, bbox.y + bbox.height)
|
|
2840
|
+
];
|
|
2841
|
+
const xs = corners.map((c) => c.x);
|
|
2842
|
+
const ys = corners.map((c) => c.y);
|
|
2843
|
+
const left = Math.min(...xs);
|
|
2844
|
+
const top = Math.min(...ys);
|
|
2845
|
+
const right = Math.max(...xs);
|
|
2846
|
+
const bottom = Math.max(...ys);
|
|
2847
|
+
return {
|
|
2848
|
+
x: left - cr.left + this.container.scrollLeft,
|
|
2849
|
+
y: top - cr.top + this.container.scrollTop,
|
|
2850
|
+
width: right - left,
|
|
2851
|
+
height: bottom - top
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
wire_events() {
|
|
2855
|
+
const owner_doc = this.container.ownerDocument;
|
|
2856
|
+
const win = owner_doc.defaultView ?? window;
|
|
2857
|
+
const on = (target, event, handler) => {
|
|
2858
|
+
target.addEventListener(event, handler);
|
|
2859
|
+
this.teardown.push(() => target.removeEventListener(event, handler));
|
|
2860
|
+
};
|
|
2861
|
+
on(this.container, "pointerdown", (e) => this.dispatch_pointer(e, "pointer_down"));
|
|
2862
|
+
on(win, "pointermove", (e) => this.dispatch_pointer(e, "pointer_move"));
|
|
2863
|
+
on(win, "pointerup", (e) => this.dispatch_pointer(e, "pointer_up"));
|
|
2864
|
+
on(owner_doc, "keydown", (e) => this.on_keydown(e));
|
|
2865
|
+
on(win, "keydown", (e) => {
|
|
2866
|
+
if (e.repeat || !IS_MODIFIER_KEY[e.key]) return;
|
|
2867
|
+
this.sync_modifiers(e);
|
|
2868
|
+
});
|
|
2869
|
+
on(win, "keyup", (e) => {
|
|
2870
|
+
if (!IS_MODIFIER_KEY[e.key]) return;
|
|
2871
|
+
this.sync_modifiers(e);
|
|
2872
|
+
});
|
|
2873
|
+
on(win, "blur", () => this.sync_modifiers(null));
|
|
2874
|
+
on(this.container, "contextmenu", (e) => e.preventDefault());
|
|
2875
|
+
if (this.clipboard_enabled) {
|
|
2876
|
+
on(owner_doc, "copy", (e) => this.on_copy_or_cut(e, "copy"));
|
|
2877
|
+
on(owner_doc, "cut", (e) => this.on_copy_or_cut(e, "cut"));
|
|
2878
|
+
on(owner_doc, "paste", (e) => this.on_paste(e));
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
/**
|
|
2882
|
+
* Gate for claiming a native clipboard gesture. Deliberately STRICTER
|
|
2883
|
+
* than the keyboard attention gate: focus-based only — pointer-over is
|
|
2884
|
+
* a sufficient signal for a keystroke (worst case: a stolen scroll) but
|
|
2885
|
+
* not for clipboard (worst case: destroying what the user believed they
|
|
2886
|
+
* copied, or routing a paste meant for a host text field into the
|
|
2887
|
+
* document). A user with text selected in a sibling panel and the
|
|
2888
|
+
* pointer idly over the canvas must get their text copy.
|
|
2889
|
+
*/
|
|
2890
|
+
claims_clipboard(kind) {
|
|
2891
|
+
if (this.text_edit) return false;
|
|
2892
|
+
if (this.editor.state.mode !== "select") return false;
|
|
2893
|
+
if (!this.attention.is_focus_within()) return false;
|
|
2894
|
+
if (is_text_input_focused()) return false;
|
|
2895
|
+
if (kind !== "paste") {
|
|
2896
|
+
const sel = this.container.ownerDocument.getSelection();
|
|
2897
|
+
if (sel && !sel.isCollapsed) return false;
|
|
2898
|
+
}
|
|
2899
|
+
return true;
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Act-then-claim: an empty selection returns without `preventDefault()`,
|
|
2903
|
+
* leaving the browser default (and the OS clipboard) untouched. The
|
|
2904
|
+
* buffer-only `_internal.clipboard` variants are used here — the event's
|
|
2905
|
+
* DataTransfer is this gesture's ONE external channel (the public
|
|
2906
|
+
* commands would additionally write the provider; one gesture, one
|
|
2907
|
+
* external write — FRD §Transport).
|
|
2908
|
+
*/
|
|
2909
|
+
on_copy_or_cut(e, kind) {
|
|
2910
|
+
if (!this.claims_clipboard(kind)) return;
|
|
2911
|
+
if (!e.clipboardData) return;
|
|
2912
|
+
const internal = this.editor_internal();
|
|
2913
|
+
const payload = kind === "copy" ? internal.clipboard.copy() : internal.clipboard.cut();
|
|
2914
|
+
if (payload === null) return;
|
|
2915
|
+
e.clipboardData.setData("text/plain", payload);
|
|
2916
|
+
e.preventDefault();
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Claim-then-act (mirrors the keydown claim doctrine: swallow when the
|
|
2920
|
+
* gesture is aimed at the editor, not just when a handler consumed):
|
|
2921
|
+
* a refused paste — junk text — still claims; the suppressed default is
|
|
2922
|
+
* a no-op on a div anyway.
|
|
2923
|
+
*/
|
|
2924
|
+
on_paste(e) {
|
|
2925
|
+
if (!this.claims_clipboard("paste")) return;
|
|
2926
|
+
e.preventDefault();
|
|
2927
|
+
const text = e.clipboardData?.getData("text/plain");
|
|
2928
|
+
if (text) this.editor.commands.paste(text);
|
|
2929
|
+
}
|
|
2930
|
+
/**
|
|
2931
|
+
* Master signal for modifier-driven UX consumers (measurement, future
|
|
2932
|
+
* constrained-resize, …). Modifier changes aren't on the pointer-event
|
|
2933
|
+
* path, so derived overlays would otherwise wait for the next pointer
|
|
2934
|
+
* move; redraw eagerly. `null` means modifiers are forced clear
|
|
2935
|
+
* (blur / focus-out).
|
|
2936
|
+
*/
|
|
2937
|
+
sync_modifiers(e) {
|
|
2938
|
+
const next = e ? {
|
|
2939
|
+
shift: e.shiftKey,
|
|
2940
|
+
alt: e.altKey,
|
|
2941
|
+
meta: e.metaKey,
|
|
2942
|
+
ctrl: e.ctrlKey
|
|
2943
|
+
} : NO_MODS;
|
|
2944
|
+
const prev = this.hud.modifiers();
|
|
2945
|
+
if (prev.shift === next.shift && prev.alt === next.alt && prev.meta === next.meta && prev.ctrl === next.ctrl) return;
|
|
2946
|
+
const response = this.hud.dispatch({
|
|
2947
|
+
kind: "modifiers",
|
|
2948
|
+
mods: next
|
|
2949
|
+
});
|
|
2950
|
+
if ((prev.shift !== next.shift || prev.alt !== next.alt) && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
|
|
2951
|
+
if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
|
|
2952
|
+
if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
|
|
2953
|
+
this.redraw();
|
|
2954
|
+
if (response.cursorChanged) this.sync_cursor();
|
|
2955
|
+
if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
|
|
2956
|
+
}
|
|
2957
|
+
dispatch_pointer(e, kind) {
|
|
2958
|
+
if (this.text_edit) {
|
|
2959
|
+
const target_el = this.text_edit_target ? this.element_index.get(this.text_edit_target) : null;
|
|
2960
|
+
const over_target = !!target_el && e.target instanceof Element && (e.target === target_el || target_el.contains(e.target));
|
|
2961
|
+
if (kind === "pointer_down") {
|
|
2962
|
+
e.preventDefault();
|
|
2963
|
+
if (over_target) this.text_edit.pointerDown(e.clientX, e.clientY, e.shiftKey);
|
|
2964
|
+
else this.text_edit.commit();
|
|
2965
|
+
} else if (kind === "pointer_move") {
|
|
2966
|
+
this.text_edit.pointerMove(e.clientX, e.clientY);
|
|
2967
|
+
this.container.style.cursor = over_target ? "text" : "default";
|
|
2968
|
+
} else if (kind === "pointer_up") this.text_edit.pointerUp();
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
if (kind === "pointer_down") this.container.focus({ preventScroll: true });
|
|
2972
|
+
const cr = this.container.getBoundingClientRect();
|
|
2973
|
+
const x = e.clientX - cr.left;
|
|
2974
|
+
const y = e.clientY - cr.top;
|
|
2975
|
+
this.last_pointer.x = x;
|
|
2976
|
+
this.last_pointer.y = y;
|
|
2977
|
+
this.last_pointer_valid = true;
|
|
2978
|
+
const mods = {
|
|
2979
|
+
shift: e.shiftKey,
|
|
2980
|
+
alt: e.altKey,
|
|
2981
|
+
meta: e.metaKey,
|
|
2982
|
+
ctrl: e.ctrlKey
|
|
2983
|
+
};
|
|
2984
|
+
const tool = this.current_tool;
|
|
2985
|
+
if (tool.type === "insert") {
|
|
2986
|
+
if (kind === "pointer_down") {
|
|
2987
|
+
if (e.button === 0) {
|
|
2988
|
+
try {
|
|
2989
|
+
this.container.setPointerCapture(e.pointerId);
|
|
2990
|
+
} catch {}
|
|
2991
|
+
this.start_insert_gesture(tool.tag, {
|
|
2992
|
+
x,
|
|
2993
|
+
y
|
|
2994
|
+
});
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
} else if (this.pending_insert) {
|
|
2998
|
+
if (kind === "pointer_move") {
|
|
2999
|
+
this.update_insert_gesture({
|
|
3000
|
+
x,
|
|
3001
|
+
y
|
|
3002
|
+
}, mods);
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
if (kind === "pointer_up" && e.button === 0) {
|
|
3006
|
+
this.commit_insert_gesture({
|
|
3007
|
+
x,
|
|
3008
|
+
y
|
|
3009
|
+
}, mods);
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
if (tool.type === "insert-text") {
|
|
3015
|
+
if (kind === "pointer_down" && e.button === 0) {
|
|
3016
|
+
const world = this.camera.screen_to_world({
|
|
3017
|
+
x,
|
|
3018
|
+
y
|
|
3019
|
+
});
|
|
3020
|
+
this.editor.set_tool({ type: "cursor" });
|
|
3021
|
+
this.begin_text_insert(world);
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
const button = e.button === 0 ? "primary" : e.button === 2 ? "secondary" : "middle";
|
|
3026
|
+
let event;
|
|
3027
|
+
if (kind === "pointer_move") event = {
|
|
3028
|
+
kind,
|
|
3029
|
+
x,
|
|
3030
|
+
y,
|
|
3031
|
+
mods
|
|
3032
|
+
};
|
|
3033
|
+
else event = {
|
|
3034
|
+
kind,
|
|
3035
|
+
x,
|
|
3036
|
+
y,
|
|
3037
|
+
button,
|
|
3038
|
+
mods
|
|
3039
|
+
};
|
|
3040
|
+
const gesture_before_kind = kind === "pointer_up" ? null : this.hud.gesture().kind;
|
|
3041
|
+
const response = this.hud.dispatch(event);
|
|
3042
|
+
if (gesture_before_kind === "idle" && this.hud.gesture().kind !== "idle") try {
|
|
3043
|
+
this.container.setPointerCapture(e.pointerId);
|
|
3044
|
+
} catch {}
|
|
3045
|
+
if (response.needsRedraw) this.redraw();
|
|
3046
|
+
if (response.cursorChanged) this.sync_cursor();
|
|
3047
|
+
if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
|
|
3048
|
+
}
|
|
3049
|
+
static {
|
|
3050
|
+
this.INSERT_DRAG_THRESHOLD_PX_SQ = 4;
|
|
3051
|
+
}
|
|
3052
|
+
/** Arm an insertion gesture on pointer-down. No IR mutation — keeping
|
|
3053
|
+
* the IR pristine through the click window lets click-no-drag commit
|
|
3054
|
+
* as one atomic `commands.insert` rather than create-zero-then-resize. */
|
|
3055
|
+
start_insert_gesture(tag, screen_pt) {
|
|
3056
|
+
this.pending_insert = {
|
|
3057
|
+
phase: "armed",
|
|
3058
|
+
tag,
|
|
3059
|
+
anchor: this.camera.screen_to_world(screen_pt),
|
|
3060
|
+
anchor_screen: {
|
|
3061
|
+
x: screen_pt.x,
|
|
3062
|
+
y: screen_pt.y
|
|
3063
|
+
}
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
/** Per-frame update. `armed` waits for the drag threshold; `drawing`
|
|
3067
|
+
* pushes a frame through the preview session. */
|
|
3068
|
+
update_insert_gesture(screen_pt, mods) {
|
|
3069
|
+
const cur = this.pending_insert;
|
|
3070
|
+
if (!cur) return;
|
|
3071
|
+
if (cur.phase === "armed") {
|
|
3072
|
+
const dx = screen_pt.x - cur.anchor_screen.x;
|
|
3073
|
+
const dy = screen_pt.y - cur.anchor_screen.y;
|
|
3074
|
+
if (dx * dx + dy * dy < DomSurface.INSERT_DRAG_THRESHOLD_PX_SQ) return;
|
|
3075
|
+
this.arm_to_draw(cur);
|
|
3076
|
+
}
|
|
3077
|
+
const live = this.pending_insert;
|
|
3078
|
+
if (live?.phase !== "drawing") return;
|
|
3079
|
+
this.push_drawing_frame(live, screen_pt, mods);
|
|
3080
|
+
}
|
|
3081
|
+
/** Transition `armed` → `drawing`: open `insert_preview` + snap session. */
|
|
3082
|
+
arm_to_draw(armed) {
|
|
3083
|
+
const session = this.editor.commands.insert_preview(armed.tag, insertions.initial_attrs(armed.tag, armed.anchor));
|
|
3084
|
+
const snap_session = this.editor.style.snap_enabled && (armed.tag === "rect" || armed.tag === "ellipse") ? this.open_snap_session_for([session.id]) : null;
|
|
3085
|
+
this.pending_insert = {
|
|
3086
|
+
phase: "drawing",
|
|
3087
|
+
tag: armed.tag,
|
|
3088
|
+
anchor: armed.anchor,
|
|
3089
|
+
anchor_screen: armed.anchor_screen,
|
|
3090
|
+
session,
|
|
3091
|
+
snap_session
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
/** Push one drag frame through the preview session. */
|
|
3095
|
+
push_drawing_frame(drawing, screen_pt, mods) {
|
|
3096
|
+
let world = this.camera.screen_to_world(screen_pt);
|
|
3097
|
+
if (drawing.snap_session) {
|
|
3098
|
+
const corrected = this.snap_insert_point(drawing.tag, drawing.anchor, drawing.anchor_screen, world, drawing.snap_session);
|
|
3099
|
+
if (corrected) world = corrected;
|
|
3100
|
+
}
|
|
3101
|
+
const dm = {
|
|
3102
|
+
shift: mods.shift,
|
|
3103
|
+
alt: mods.alt
|
|
3104
|
+
};
|
|
3105
|
+
drawing.session.update(insertions.compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
|
|
3106
|
+
}
|
|
3107
|
+
/** Commit on pointer-up. `armed` → one-shot `commands.insert` with
|
|
3108
|
+
* `default_attrs` (click-no-drag, never touches the IR mid-gesture).
|
|
3109
|
+
* `drawing` → push final frame + close the preview. */
|
|
3110
|
+
commit_insert_gesture(screen_pt, mods) {
|
|
3111
|
+
const cur = this.pending_insert;
|
|
3112
|
+
if (!cur) return;
|
|
3113
|
+
if (cur.phase === "armed") this.editor.commands.insert(cur.tag, insertions.default_attrs(cur.tag, cur.anchor));
|
|
3114
|
+
else {
|
|
3115
|
+
this.push_drawing_frame(cur, screen_pt, mods);
|
|
3116
|
+
cur.session.commit();
|
|
3117
|
+
cur.snap_session?.dispose?.();
|
|
3118
|
+
}
|
|
3119
|
+
this.pending_insert = null;
|
|
3120
|
+
this.editor.set_tool({ type: "cursor" });
|
|
3121
|
+
}
|
|
3122
|
+
/** Snap the in-progress insert's moving corner to neighbor geometry.
|
|
3123
|
+
* Returns a corrected world-space pointer, or `null` to leave the
|
|
3124
|
+
* input uncorrected. Operates in container CSS-px (the snap engine's
|
|
3125
|
+
* native space) and projects the correction back to world via
|
|
3126
|
+
* `camera.zoom`. Rect / ellipse only. */
|
|
3127
|
+
snap_insert_point(tag, anchor, anchor_screen, current, snap_session) {
|
|
3128
|
+
if (tag !== "rect" && tag !== "ellipse") return null;
|
|
3129
|
+
const zoom = this.camera.zoom;
|
|
3130
|
+
if (zoom <= 0) return null;
|
|
3131
|
+
if (current.x === anchor.x && current.y === anchor.y) return null;
|
|
3132
|
+
const current_screen = this.camera.world_to_screen(current);
|
|
3133
|
+
const effective = {
|
|
3134
|
+
x: Math.min(anchor_screen.x, current_screen.x),
|
|
3135
|
+
y: Math.min(anchor_screen.y, current_screen.y),
|
|
3136
|
+
width: Math.abs(current_screen.x - anchor_screen.x),
|
|
3137
|
+
height: Math.abs(current_screen.y - anchor_screen.y)
|
|
3138
|
+
};
|
|
3139
|
+
const edges_x = current.x === anchor.x ? null : current.x > anchor.x ? "right" : "left";
|
|
3140
|
+
const edges_y = current.y === anchor.y ? null : current.y > anchor.y ? "bottom" : "top";
|
|
3141
|
+
const opts = {
|
|
3142
|
+
enabled: this.editor.style.snap_enabled,
|
|
3143
|
+
threshold_px: this.editor.style.snap_threshold_px
|
|
3144
|
+
};
|
|
3145
|
+
const result = snap_session.snap_resize(effective, {
|
|
3146
|
+
x: edges_x,
|
|
3147
|
+
y: edges_y
|
|
3148
|
+
}, opts);
|
|
3149
|
+
if (result.dx === 0 && result.dy === 0) return current;
|
|
3150
|
+
return {
|
|
3151
|
+
x: current.x + result.dx / zoom,
|
|
3152
|
+
y: current.y + result.dy / zoom
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
sync_cursor() {
|
|
3156
|
+
if (this.text_edit) {
|
|
3157
|
+
this.container.style.cursor = "default";
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
if (this.current_tool.type === "insert") {
|
|
3161
|
+
this.container.style.cursor = "crosshair";
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
if (this.current_tool.type === "insert-text") {
|
|
3165
|
+
this.container.style.cursor = "text";
|
|
3166
|
+
return;
|
|
3167
|
+
}
|
|
3168
|
+
this.container.style.cursor = this.hud.cursorCss();
|
|
3169
|
+
}
|
|
3170
|
+
on_keydown(e) {
|
|
3171
|
+
if (this.text_edit) return;
|
|
3172
|
+
if (e.code !== "Escape" && !this.attention.is_attended()) return;
|
|
3173
|
+
if (e.code === "Escape") {
|
|
3174
|
+
const canceled = this.cancel_in_flight();
|
|
3175
|
+
if (!this.attention.is_attended()) return;
|
|
3176
|
+
if (!canceled && this.vector_edit) this.exit_vector_edit();
|
|
3177
|
+
}
|
|
3178
|
+
if ((this.active_preview || this.translate_orchestrator.has_active_session() || this.resize_orchestrator.has_active_session() || this.rotate_orchestrator.has_active_session() || this.pending_insert) && e.code !== "Escape") return;
|
|
3179
|
+
if (this.editor.keymap.claims(e)) e.preventDefault();
|
|
3180
|
+
this.editor.keymap.dispatch(e);
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
3183
|
+
* Re-express a HUD tap as an editor {@link PickEvent} and fan it out on the
|
|
3184
|
+
* editor's pick channel. The HUD already resolved everything that matters —
|
|
3185
|
+
* the pointer-down point, the hit node, and click-vs-drag — so this is a
|
|
3186
|
+
* pure translation (HUD `[x, y]` tuple → editor `{ x, y }` doc-space point)
|
|
3187
|
+
* with NO re-hit-testing. Taking the hit from the HUD (not a fresh
|
|
3188
|
+
* `node_at_point`) guarantees the pick and the selection it accompanies can
|
|
3189
|
+
* never disagree. Observe-only: this mutates no editor state.
|
|
3190
|
+
*/
|
|
3191
|
+
handle_tap(tap) {
|
|
3192
|
+
this.editor._internal.push_pick({
|
|
3193
|
+
point: {
|
|
3194
|
+
x: tap.point[0],
|
|
3195
|
+
y: tap.point[1]
|
|
3196
|
+
},
|
|
3197
|
+
node_id: tap.hit,
|
|
3198
|
+
button: tap.button,
|
|
3199
|
+
mods: tap.mods
|
|
3200
|
+
});
|
|
3201
|
+
}
|
|
3202
|
+
commit_intent(intent) {
|
|
3203
|
+
switch (intent.kind) {
|
|
3204
|
+
case "select":
|
|
3205
|
+
this.editor.commands.select(intent.ids, { mode: intent.mode });
|
|
3206
|
+
return;
|
|
3207
|
+
case "deselect_all":
|
|
3208
|
+
this.editor.commands.deselect();
|
|
3209
|
+
return;
|
|
3210
|
+
case "clear_vector_selection": {
|
|
3211
|
+
if (!this.vector_edit) return;
|
|
3212
|
+
const before = this.vector_edit.snapshot_selection();
|
|
3213
|
+
this.vector_edit.clear_selection();
|
|
3214
|
+
this.sync_selection_mirror();
|
|
3215
|
+
this.redraw();
|
|
3216
|
+
this.record_vector_selection_change(before, "clear vector selection");
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
case "translate":
|
|
3220
|
+
this.handle_translate(intent);
|
|
3221
|
+
return;
|
|
3222
|
+
case "resize":
|
|
3223
|
+
this.handle_resize(intent);
|
|
3224
|
+
return;
|
|
3225
|
+
case "rotate":
|
|
3226
|
+
this.handle_rotate(intent);
|
|
3227
|
+
return;
|
|
3228
|
+
case "marquee_select":
|
|
3229
|
+
this.handle_marquee(intent);
|
|
3230
|
+
return;
|
|
3231
|
+
case "lasso_select":
|
|
3232
|
+
this.handle_lasso_select(intent);
|
|
3233
|
+
return;
|
|
3234
|
+
case "set_endpoint":
|
|
3235
|
+
this.handle_set_endpoint(intent);
|
|
3236
|
+
return;
|
|
3237
|
+
case "enter_content_edit":
|
|
3238
|
+
this.editor.commands.select(intent.id);
|
|
3239
|
+
this.editor.enter_content_edit(intent.id);
|
|
3240
|
+
return;
|
|
3241
|
+
case "exit_content_edit":
|
|
3242
|
+
this.exit_vector_edit();
|
|
3243
|
+
return;
|
|
3244
|
+
case "select_vertex":
|
|
3245
|
+
this.handle_select_vertex(intent);
|
|
3246
|
+
return;
|
|
3247
|
+
case "translate_vertices":
|
|
3248
|
+
this.handle_translate_vertices(intent);
|
|
3249
|
+
return;
|
|
3250
|
+
case "translate_vector_selection":
|
|
3251
|
+
this.handle_translate_vector_selection(intent);
|
|
3252
|
+
return;
|
|
3253
|
+
case "select_segment":
|
|
3254
|
+
this.handle_select_segment(intent);
|
|
3255
|
+
return;
|
|
3256
|
+
case "select_tangent":
|
|
3257
|
+
this.handle_select_tangent(intent);
|
|
3258
|
+
return;
|
|
3259
|
+
case "set_tangent":
|
|
3260
|
+
this.handle_set_tangent_intent(intent);
|
|
3261
|
+
return;
|
|
3262
|
+
case "split_segment":
|
|
3263
|
+
this.handle_split_segment(intent);
|
|
3264
|
+
return;
|
|
3265
|
+
case "bend_segment":
|
|
3266
|
+
this.handle_bend_segment(intent);
|
|
3267
|
+
return;
|
|
3268
|
+
case "cancel_gesture":
|
|
3269
|
+
this.cancel_in_flight();
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
handle_translate(intent) {
|
|
3274
|
+
if (intent.ids.length === 0) return;
|
|
3275
|
+
const zoom = this.camera.zoom || 1;
|
|
3276
|
+
const dx_world = intent.dx / zoom;
|
|
3277
|
+
const dy_world = intent.dy / zoom;
|
|
3278
|
+
this.translate_orchestrator.drive({
|
|
3279
|
+
ids: intent.ids,
|
|
3280
|
+
movement: [dx_world, dy_world]
|
|
3281
|
+
}, this.current_translate_modifiers(), {
|
|
3282
|
+
phase: intent.phase,
|
|
3283
|
+
policy: "engine",
|
|
3284
|
+
snap: true
|
|
3285
|
+
});
|
|
3286
|
+
if (intent.phase === "commit") this.request_redraw();
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* Shift axis-lock for point-level drags (gridaco/grida#848) — the
|
|
3290
|
+
* vertex / endpoint counterpart of whole-object translate's
|
|
3291
|
+
* `axis_lock: "by_dominance"` (see `current_translate_modifiers`).
|
|
3292
|
+
* Collapses the lesser axis of a local-frame delta when Shift is held,
|
|
3293
|
+
* via the same cmath rule the translate pipeline's `axis_lock` stage
|
|
3294
|
+
* uses; identity when Shift is up. Pull-at-consume from the HUD modifier
|
|
3295
|
+
* store so a mid-drag Shift press/release reflects on the next frame.
|
|
3296
|
+
*/
|
|
3297
|
+
axis_lock_point_delta(dx, dy) {
|
|
3298
|
+
if (!this.hud.modifiers().shift) return [dx, dy];
|
|
3299
|
+
const locked = cmath.ext.movement.axisLockedByDominance([dx, dy]);
|
|
3300
|
+
return cmath.ext.movement.normalize(locked);
|
|
3301
|
+
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Point-level snap (gridaco/grida#844) — the vertex / endpoint counterpart
|
|
3304
|
+
* of whole-object translate's edge/center snap. Snaps a dragged point's
|
|
3305
|
+
* delta so it aligns with (or lands on) a sibling point: a path's other
|
|
3306
|
+
* vertices, or a `<line>`'s opposite endpoint.
|
|
3307
|
+
*
|
|
3308
|
+
* Runs entirely in the element's **local frame**: agents (the moving
|
|
3309
|
+
* point[s]) and neighbors (the static sibling points) come straight from
|
|
3310
|
+
* the path's vector network / line attributes — no projection, no separate
|
|
3311
|
+
* neighbor source. The corrected delta is returned in that same local frame
|
|
3312
|
+
* so the caller applies it exactly where it already applies the local drag
|
|
3313
|
+
* delta. Only the snap GUIDE is projected to screen (via the element CTM)
|
|
3314
|
+
* for HUD rendering.
|
|
3315
|
+
*
|
|
3316
|
+
* Honors the global snap toggle (`style.snap_enabled`) and threshold
|
|
3317
|
+
* (`style.snap_threshold_px`, converted to local units by the CTM scale) —
|
|
3318
|
+
* snap off ⇒ free point dragging, per the issue. Bypassed when
|
|
3319
|
+
* `suppress_point_snap` is set (keyboard nudge). Identity when there are no
|
|
3320
|
+
* neighbors / agents.
|
|
3321
|
+
*
|
|
3322
|
+
* The shared `SnapSession` drops 0-area rects (its empty-`<g>` "jerk to
|
|
3323
|
+
* origin" defense), so each point is modeled as a sub-pixel square centered
|
|
3324
|
+
* on it. Symmetric inflation cancels in the corrected delta (the engine's
|
|
3325
|
+
* matched same-edge offset carries the same ±eps on both sides), so eps
|
|
3326
|
+
* magnitude is immaterial as long as it stays far below the threshold.
|
|
3327
|
+
*/
|
|
3328
|
+
snap_local_point_delta(raw_dx, raw_dy, agents_local, neighbors_local, ctm) {
|
|
3329
|
+
this.point_snap_guide = void 0;
|
|
3330
|
+
const style = this.editor.style;
|
|
3331
|
+
if (this.suppress_point_snap || !style.snap_enabled || agents_local.length === 0 || neighbors_local.length === 0 || raw_dx === 0 && raw_dy === 0) return [raw_dx, raw_dy];
|
|
3332
|
+
const det = ctm.a * ctm.d - ctm.c * ctm.b;
|
|
3333
|
+
const scale = Math.sqrt(Math.abs(det)) || 1;
|
|
3334
|
+
const threshold_local = style.snap_threshold_px / scale;
|
|
3335
|
+
const eps = threshold_local * 1e-6 || 1e-9;
|
|
3336
|
+
const to_rect = (p) => ({
|
|
3337
|
+
x: p[0] - eps / 2,
|
|
3338
|
+
y: p[1] - eps / 2,
|
|
3339
|
+
width: eps,
|
|
3340
|
+
height: eps
|
|
3341
|
+
});
|
|
3342
|
+
const session = new SnapSession({
|
|
3343
|
+
agents: agents_local.map(to_rect),
|
|
3344
|
+
neighbors: neighbors_local.map(to_rect)
|
|
3345
|
+
});
|
|
3346
|
+
const { delta, guide } = session.snap({
|
|
3347
|
+
x: raw_dx,
|
|
3348
|
+
y: raw_dy
|
|
3349
|
+
}, {
|
|
3350
|
+
enabled: true,
|
|
3351
|
+
threshold_px: threshold_local
|
|
3352
|
+
});
|
|
3353
|
+
session.dispose();
|
|
3354
|
+
if (guide) this.point_snap_guide = this.project_local_guide_to_screen(guide, ctm, this.container_offset());
|
|
3355
|
+
return [delta.x, delta.y];
|
|
3356
|
+
}
|
|
3357
|
+
/** Split a path's baseline vertices into the snap agents (the moving
|
|
3358
|
+
* sub-selection) and neighbors (everything else) for
|
|
3359
|
+
* {@link snap_local_point_delta}. Both in path-local space. */
|
|
3360
|
+
vertex_snap_points(model, moving) {
|
|
3361
|
+
const verts = model.snapshot().vertices;
|
|
3362
|
+
const moving_set = new Set(moving);
|
|
3363
|
+
const agents = [];
|
|
3364
|
+
const neighbors = [];
|
|
3365
|
+
for (let i = 0; i < verts.length; i++) (moving_set.has(i) ? agents : neighbors).push(verts[i]);
|
|
3366
|
+
return {
|
|
3367
|
+
agents,
|
|
3368
|
+
neighbors
|
|
3369
|
+
};
|
|
3370
|
+
}
|
|
3371
|
+
/** Project a point-snap guide from an element's local frame to screen
|
|
3372
|
+
* CSS-px (the HUD canvas's identity coordinate system), via the element
|
|
3373
|
+
* CTM + container offset — the same projection `vector_of` uses for
|
|
3374
|
+
* vertex chrome. The local-frame analog of `project_guide_to_screen`
|
|
3375
|
+
* (which projects world-space orchestrator guides via the camera). */
|
|
3376
|
+
project_local_guide_to_screen(g, ctm, container_offset) {
|
|
3377
|
+
return {
|
|
3378
|
+
lines: g.lines.map((l) => {
|
|
3379
|
+
const [x1, y1] = project_point_through_ctm(l.x1, l.y1, ctm, container_offset);
|
|
3380
|
+
const [x2, y2] = project_point_through_ctm(l.x2, l.y2, ctm, container_offset);
|
|
3381
|
+
return {
|
|
3382
|
+
...l,
|
|
3383
|
+
x1,
|
|
3384
|
+
y1,
|
|
3385
|
+
x2,
|
|
3386
|
+
y2
|
|
3387
|
+
};
|
|
3388
|
+
}),
|
|
3389
|
+
points: g.points.map(([x, y]) => project_point_through_ctm(x, y, ctm, container_offset)),
|
|
3390
|
+
rules: g.rules.map(([axis, value]) => {
|
|
3391
|
+
const [px, py] = project_point_through_ctm(axis === "x" ? value : 0, axis === "x" ? 0 : value, ctm, container_offset);
|
|
3392
|
+
return [axis, axis === "x" ? px : py];
|
|
3393
|
+
})
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
/** Translation from screen/page CSS-px to the HUD's container-identity
|
|
3397
|
+
* space: subtract the container's page offset, add its scroll. Used at
|
|
3398
|
+
* every `getScreenCTM` projection boundary (chrome, marquee, point snap)
|
|
3399
|
+
* so they share one definition of "where the container's origin is". */
|
|
3400
|
+
container_offset() {
|
|
3401
|
+
const cr = this.container.getBoundingClientRect();
|
|
3402
|
+
return [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
|
|
3403
|
+
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Local-frame drag delta for a vertex sub-selection, from the HUD's
|
|
3406
|
+
* container-space `dx/dy`. Inverse-projects through the element CTM (so a
|
|
3407
|
+
* path under a scaled `<g>` / nested viewport tracks the cursor 1:1) then
|
|
3408
|
+
* point-snaps to the path's other vertices (#844) — before axis-lock, so
|
|
3409
|
+
* the lock keeps final say on a constrained axis. Identity when the element
|
|
3410
|
+
* has no usable CTM. A tangent-only drag (empty `indices`) yields no snap
|
|
3411
|
+
* agents, so snap is a no-op. Shared by the two vertex-translate handlers;
|
|
3412
|
+
* the caller applies axis-lock and feeds the result to `translateVertices`.
|
|
3413
|
+
*/
|
|
3414
|
+
vertex_drag_local_delta(node_id, baseline_model, indices, dx, dy) {
|
|
3415
|
+
const el = this.element_index.get(node_id);
|
|
3416
|
+
const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
|
|
3417
|
+
if (!ctm) return [dx, dy];
|
|
3418
|
+
let local_dx = dx;
|
|
3419
|
+
let local_dy = dy;
|
|
3420
|
+
if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(dx, dy, ctm);
|
|
3421
|
+
const { agents, neighbors } = this.vertex_snap_points(baseline_model, indices);
|
|
3422
|
+
return this.snap_local_point_delta(local_dx, local_dy, agents, neighbors, ctm);
|
|
3423
|
+
}
|
|
3424
|
+
/** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
|
|
3425
|
+
* Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
|
|
3426
|
+
* read live so mid-drag Shift press/release reflects on the next pass. */
|
|
3427
|
+
current_translate_modifiers() {
|
|
3428
|
+
const mods = this.hud.modifiers();
|
|
3429
|
+
return {
|
|
3430
|
+
axis_lock: mods.shift ? "by_dominance" : "off",
|
|
3431
|
+
force_disable_snap: false,
|
|
3432
|
+
clone: mods.alt
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
/** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
|
|
3436
|
+
* pull-at-consume discipline as `current_translate_modifiers`. */
|
|
3437
|
+
current_resize_modifiers() {
|
|
3438
|
+
return {
|
|
3439
|
+
aspect_lock: this.hud.modifiers().shift ? "uniform" : "off",
|
|
3440
|
+
force_disable_snap: false
|
|
3441
|
+
};
|
|
3442
|
+
}
|
|
3443
|
+
handle_resize(intent) {
|
|
3444
|
+
if (intent.ids.length === 0) return;
|
|
3445
|
+
for (const id of intent.ids) if (!resize_pipeline.intent.is_resizable_node(this.editor.document, id)) return;
|
|
3446
|
+
const dir = intent.anchor;
|
|
3447
|
+
let target_width;
|
|
3448
|
+
let target_height;
|
|
3449
|
+
if (intent.shape && intent.shape.kind === "transformed") {
|
|
3450
|
+
target_width = intent.shape.local.width;
|
|
3451
|
+
target_height = intent.shape.local.height;
|
|
3452
|
+
} else {
|
|
3453
|
+
const zoom = this.camera.zoom || 1;
|
|
3454
|
+
target_width = intent.rect.width / zoom;
|
|
3455
|
+
target_height = intent.rect.height / zoom;
|
|
3456
|
+
}
|
|
3457
|
+
this.resize_orchestrator.drive({
|
|
3458
|
+
ids: intent.ids,
|
|
3459
|
+
direction: dir,
|
|
3460
|
+
target_width,
|
|
3461
|
+
target_height
|
|
3462
|
+
}, this.current_resize_modifiers(), {
|
|
3463
|
+
phase: intent.phase === "commit" ? "commit" : "preview",
|
|
3464
|
+
snap: true
|
|
3465
|
+
});
|
|
3466
|
+
if (intent.phase === "commit") this.request_redraw();
|
|
3467
|
+
}
|
|
3468
|
+
/** Snapshot of HUD modifier state mapped to `RotateModifiers`. Same
|
|
3469
|
+
* pull-at-consume discipline as the translate / resize equivalents. */
|
|
3470
|
+
current_rotate_modifiers() {
|
|
3471
|
+
return {
|
|
3472
|
+
angle_snap: this.hud.modifiers().shift ? "step" : "off",
|
|
3473
|
+
force_disable_snap: false
|
|
3474
|
+
};
|
|
3475
|
+
}
|
|
3476
|
+
handle_rotate(intent) {
|
|
3477
|
+
if (intent.ids.length === 0) return;
|
|
3478
|
+
const result = this.rotate_orchestrator.drive({
|
|
3479
|
+
ids: intent.ids,
|
|
3480
|
+
angle_radians: intent.angle
|
|
3481
|
+
}, this.current_rotate_modifiers(), { phase: intent.phase === "commit" ? "commit" : "preview" });
|
|
3482
|
+
if (result && result.outcome && result.outcome.kind === "refused") this.emit_rotate_refusal(result.outcome.verdicts);
|
|
3483
|
+
if (intent.phase === "commit") this.request_redraw();
|
|
3484
|
+
}
|
|
3485
|
+
/** Map each refusal verdict to a user-facing chip message. v1 fires
|
|
3486
|
+
* one toast for the first refusal encountered — the user can address
|
|
3487
|
+
* it and try again. Refusal verdicts come straight from
|
|
3488
|
+
* `is_rotatable`. */
|
|
3489
|
+
emit_rotate_refusal(verdicts) {
|
|
3490
|
+
for (const v of verdicts.values()) {
|
|
3491
|
+
if (v.kind !== "refuse") continue;
|
|
3492
|
+
const message = v.reason === "non-trivial-transform" ? "Cannot rotate cleanly — element has a composite transform. Use Flatten Transform first." : v.reason === "text-with-glyph-rotate" ? "Cannot rotate — text has per-glyph rotation. Edit `rotate=` or remove it first." : v.reason === "css-property-transform" ? "Cannot rotate — transform is set via CSS. Move the declaration to the `transform` attribute first." : "Cannot rotate — element has an animated transform. Remove `<animateTransform>` first.";
|
|
3493
|
+
console.warn(`[svg-editor] ${message}`);
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
/**
|
|
3498
|
+
* Apply a `set_endpoint` intent — moving one endpoint of a vector
|
|
3499
|
+
* `<line>` to a new container-space position. Unprojects to the element's
|
|
3500
|
+
* own (SVG) coord space and updates the corresponding attribute.
|
|
3501
|
+
*/
|
|
3502
|
+
handle_set_endpoint(intent) {
|
|
3503
|
+
const id = intent.id;
|
|
3504
|
+
if (this.tag_of(id) !== "line") return;
|
|
3505
|
+
const internal = this.editor_internal();
|
|
3506
|
+
const doc = internal.doc;
|
|
3507
|
+
const emit = internal.emit;
|
|
3508
|
+
if (!this.active_preview || this.active_preview.kind !== "endpoint" || this.active_preview.id !== id || this.active_preview.endpoint !== intent.endpoint) {
|
|
3509
|
+
if (this.active_preview) this.active_preview.session.discard();
|
|
3510
|
+
const initial = {
|
|
3511
|
+
x1: numAttr(doc, id, "x1"),
|
|
3512
|
+
y1: numAttr(doc, id, "y1"),
|
|
3513
|
+
x2: numAttr(doc, id, "x2"),
|
|
3514
|
+
y2: numAttr(doc, id, "y2")
|
|
3515
|
+
};
|
|
3516
|
+
this.active_preview = {
|
|
3517
|
+
kind: "endpoint",
|
|
3518
|
+
id,
|
|
3519
|
+
endpoint: intent.endpoint,
|
|
3520
|
+
initial,
|
|
3521
|
+
session: internal.history.preview("set-endpoint")
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
const initial = this.active_preview.initial;
|
|
3525
|
+
const endpoint = this.active_preview.endpoint;
|
|
3526
|
+
const pos_own = this.container_point_in_own_frame(id, intent.pos[0], intent.pos[1]);
|
|
3527
|
+
if (!pos_own) return;
|
|
3528
|
+
const target_x = pos_own.x;
|
|
3529
|
+
const target_y = pos_own.y;
|
|
3530
|
+
const base_x = endpoint === "p1" ? initial.x1 : initial.x2;
|
|
3531
|
+
const base_y = endpoint === "p1" ? initial.y1 : initial.y2;
|
|
3532
|
+
let dx = target_x - base_x;
|
|
3533
|
+
let dy = target_y - base_y;
|
|
3534
|
+
const el = this.element_index.get(id);
|
|
3535
|
+
const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
|
|
3536
|
+
if (ctm) {
|
|
3537
|
+
const other = endpoint === "p1" ? [initial.x2, initial.y2] : [initial.x1, initial.y1];
|
|
3538
|
+
[dx, dy] = this.snap_local_point_delta(dx, dy, [[base_x, base_y]], [other], ctm);
|
|
3539
|
+
}
|
|
3540
|
+
const [locked_dx, locked_dy] = this.axis_lock_point_delta(dx, dy);
|
|
3541
|
+
const final_x = base_x + locked_dx;
|
|
3542
|
+
const final_y = base_y + locked_dy;
|
|
3543
|
+
const apply = () => {
|
|
3544
|
+
if (endpoint === "p1") {
|
|
3545
|
+
doc.set_attr(id, "x1", String(final_x));
|
|
3546
|
+
doc.set_attr(id, "y1", String(final_y));
|
|
3547
|
+
} else {
|
|
3548
|
+
doc.set_attr(id, "x2", String(final_x));
|
|
3549
|
+
doc.set_attr(id, "y2", String(final_y));
|
|
3550
|
+
}
|
|
3551
|
+
emit();
|
|
3552
|
+
};
|
|
3553
|
+
const revert = () => {
|
|
3554
|
+
doc.set_attr(id, "x1", String(initial.x1));
|
|
3555
|
+
doc.set_attr(id, "y1", String(initial.y1));
|
|
3556
|
+
doc.set_attr(id, "x2", String(initial.x2));
|
|
3557
|
+
doc.set_attr(id, "y2", String(initial.y2));
|
|
3558
|
+
emit();
|
|
3559
|
+
};
|
|
3560
|
+
this.active_preview.session.set({
|
|
3561
|
+
providerId: "svg-editor",
|
|
3562
|
+
apply,
|
|
3563
|
+
revert
|
|
3564
|
+
});
|
|
3565
|
+
if (intent.phase === "commit") {
|
|
3566
|
+
this.point_snap_guide = void 0;
|
|
3567
|
+
this.active_preview.session.commit();
|
|
3568
|
+
this.active_preview = null;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
/**
|
|
3572
|
+
* Convert a container-space point to the element's own SVG coord space.
|
|
3573
|
+
* Inverse of `line_endpoints_in_container`'s projection.
|
|
3574
|
+
*/
|
|
3575
|
+
container_point_in_own_frame(id, cx, cy) {
|
|
3576
|
+
const el = this.element_index.get(id);
|
|
3577
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
3578
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
3579
|
+
const ctm = el.getScreenCTM();
|
|
3580
|
+
if (!ctm || !this.svg_root) return null;
|
|
3581
|
+
const cr = this.container.getBoundingClientRect();
|
|
3582
|
+
const inv = ctm.inverse();
|
|
3583
|
+
const p = this.svg_root.createSVGPoint();
|
|
3584
|
+
p.x = cx + cr.left - this.container.scrollLeft;
|
|
3585
|
+
p.y = cy + cr.top - this.container.scrollTop;
|
|
3586
|
+
const t = p.matrixTransform(inv);
|
|
3587
|
+
return {
|
|
3588
|
+
x: t.x,
|
|
3589
|
+
y: t.y
|
|
3590
|
+
};
|
|
3591
|
+
}
|
|
3592
|
+
/**
|
|
3593
|
+
* Capture the vector-edit sub-selection on the first region-gesture
|
|
3594
|
+
* preview, and reuse it for every subsequent preview + the commit.
|
|
3595
|
+
* Anchors additive merging and tangent-candidate eligibility to the
|
|
3596
|
+
* gesture-start state so they don't shift mid-drag. Shared by marquee
|
|
3597
|
+
* and lasso — both gestures emit a preview-per-move, both consume the
|
|
3598
|
+
* same baseline. See `vector_edit_region_baseline` doc-comment for the
|
|
3599
|
+
* full rationale. Caller must have a non-null `this.vector_edit`.
|
|
3600
|
+
*/
|
|
3601
|
+
ensure_region_baseline() {
|
|
3602
|
+
if (!this.vector_edit_region_baseline) this.vector_edit_region_baseline = {
|
|
3603
|
+
vertices: this.vector_edit.selected_vertices.slice(),
|
|
3604
|
+
segments: this.vector_edit.selected_segments.slice(),
|
|
3605
|
+
tangents: this.vector_edit.selected_tangents.map((t) => [t[0], t[1]])
|
|
3606
|
+
};
|
|
3607
|
+
return this.vector_edit_region_baseline;
|
|
3608
|
+
}
|
|
3609
|
+
handle_marquee(intent) {
|
|
3610
|
+
if (this.vector_edit) {
|
|
3611
|
+
this.handle_marquee_vectors(intent);
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
if (!this.scene_marquee_baseline) {
|
|
3615
|
+
const cr = this.container.getBoundingClientRect();
|
|
3616
|
+
const root = this.editor.tree().root;
|
|
3617
|
+
const boxes = [];
|
|
3618
|
+
for (const id of this._ensure_z_order(false, root)) {
|
|
3619
|
+
const box = this.container_box(id, cr);
|
|
3620
|
+
if (box) boxes.push([id, box]);
|
|
3621
|
+
}
|
|
3622
|
+
this.scene_marquee_baseline = {
|
|
3623
|
+
boxes,
|
|
3624
|
+
selection: this.editor.state.selection
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
const next = this.resolve_scene_marquee(intent.rect, intent.additive);
|
|
3628
|
+
this.editor.commands.select(next, { mode: "replace" });
|
|
3629
|
+
if (intent.phase === "commit") this.scene_marquee_baseline = null;
|
|
3630
|
+
}
|
|
3631
|
+
/** Resolve the scene marquee selection for one frame from the frozen,
|
|
3632
|
+
* paint-ordered box snapshot. The rule (shadow + additive) lives in the
|
|
3633
|
+
* headless `marquee_selection` policy (`src/selection/marquee.ts`, spec
|
|
3634
|
+
* `docs/marquee-selection.md`); the surface only supplies the boxes and
|
|
3635
|
+
* the gesture-start baseline. Selection is deterministic in (marquee rect,
|
|
3636
|
+
* gesture-start selection, shift) — meta is a gesture-routing modifier
|
|
3637
|
+
* only (it decides that a drag IS a marquee), not a resolution input. */
|
|
3638
|
+
resolve_scene_marquee(rect, additive) {
|
|
3639
|
+
const { boxes, selection } = this.scene_marquee_baseline;
|
|
3640
|
+
return marquee_selection.resolve(boxes, rect, selection, { additive });
|
|
3641
|
+
}
|
|
3642
|
+
/**
|
|
3643
|
+
* Vector marquee predicate — applies the **vertex-priority precedence
|
|
3644
|
+
* rule** ported from the main editor:
|
|
3645
|
+
*
|
|
3646
|
+
* 1. Vertices: keep those whose container-space position falls inside
|
|
3647
|
+
* the marquee rect.
|
|
3648
|
+
* 2. Tangents: keep only those at the (new) selection's neighbouring
|
|
3649
|
+
* vertices whose control point falls inside the rect.
|
|
3650
|
+
* 3. Segments: keep only segments **fully contained** in the rect AND
|
|
3651
|
+
* whose endpoints are NOT among the selected vertices. (If a vertex
|
|
3652
|
+
* is selected, the segments meeting it would double-credit the
|
|
3653
|
+
* drag, so they're dropped — "vertex priority.")
|
|
3654
|
+
*
|
|
3655
|
+
* The rect comes in container CSS-px (HUD's frame). We project it back
|
|
3656
|
+
* to path-local by applying the inverse-CTM linear part to its size and
|
|
3657
|
+
* shifting its position via `project_delta_inverse_ctm` against the
|
|
3658
|
+
* top-left in doc-space — but a cleaner approach is to project each
|
|
3659
|
+
* vertex/tangent/segment-sample into doc-space and test against the rect
|
|
3660
|
+
* directly. For consistency with how `vector_of` projects, we do the
|
|
3661
|
+
* "project geometry to doc-space, test against doc-space rect" approach.
|
|
3662
|
+
*/
|
|
3663
|
+
handle_marquee_vectors(intent) {
|
|
3664
|
+
if (!this.vector_edit) return;
|
|
3665
|
+
const node_id = this.vector_edit.node_id;
|
|
3666
|
+
const el = this.element_index.get(node_id);
|
|
3667
|
+
if (!(el instanceof SVGGraphicsElement)) return;
|
|
3668
|
+
if (typeof el.getScreenCTM !== "function") return;
|
|
3669
|
+
const ctm = el.getScreenCTM();
|
|
3670
|
+
if (!ctm) return;
|
|
3671
|
+
const offset = this.container_offset();
|
|
3672
|
+
const model = this.session_model();
|
|
3673
|
+
if (!model) return;
|
|
3674
|
+
const rect = intent.rect;
|
|
3675
|
+
const baseline = this.ensure_region_baseline();
|
|
3676
|
+
const pre_selection = {
|
|
3677
|
+
vertices: baseline.vertices,
|
|
3678
|
+
segments: baseline.segments,
|
|
3679
|
+
tangents: baseline.tangents
|
|
3680
|
+
};
|
|
3681
|
+
const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
|
|
3682
|
+
const vertex_hits = marquee.points_in_rect(candidates.vertices, rect);
|
|
3683
|
+
const tangent_hits = marquee.points_in_rect(candidates.tangents, rect);
|
|
3684
|
+
const rect_local = inverse_project_rect(rect, ctm, offset);
|
|
3685
|
+
const vertex_hit_set = new Set(vertex_hits);
|
|
3686
|
+
const segment_hits = [];
|
|
3687
|
+
if (rect_local) {
|
|
3688
|
+
const segs = model.snapshot().segments;
|
|
3689
|
+
for (const sid of candidates.segments) {
|
|
3690
|
+
const s = segs[sid];
|
|
3691
|
+
if (vertex_hit_set.has(s.a) || vertex_hit_set.has(s.b)) continue;
|
|
3692
|
+
if (model.segmentContainedByRect(sid, rect_local)) segment_hits.push(sid);
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
const merged = marquee.merge_subpath_hits(pre_selection, {
|
|
3696
|
+
vertices: vertex_hits,
|
|
3697
|
+
segments: segment_hits,
|
|
3698
|
+
tangents: tangent_hits
|
|
3699
|
+
}, intent.additive);
|
|
3700
|
+
this.vector_edit.set_selection(merged);
|
|
3701
|
+
this.sync_selection_mirror();
|
|
3702
|
+
if (intent.phase === "commit") {
|
|
3703
|
+
const baseline_snapshot = Object.freeze({
|
|
3704
|
+
vertices: baseline.vertices,
|
|
3705
|
+
segments: baseline.segments,
|
|
3706
|
+
tangents: baseline.tangents
|
|
3707
|
+
});
|
|
3708
|
+
this.vector_edit_region_baseline = null;
|
|
3709
|
+
this.record_vector_selection_change(baseline_snapshot, "marquee select");
|
|
3710
|
+
}
|
|
3711
|
+
this.redraw();
|
|
3712
|
+
}
|
|
3713
|
+
/**
|
|
3714
|
+
* Lasso (freeform polygon) sub-selection — vector analogue of
|
|
3715
|
+
* `handle_marquee`. Per the main editor's decision
|
|
3716
|
+
* (editor/grida-canvas/reducers/methods/vector.ts:163–291 +
|
|
3717
|
+
* event-target.reducer.ts:629–641) lasso targets **vertices and
|
|
3718
|
+
* tangents only** — segments are NOT tested against the polygon. The
|
|
3719
|
+
* segment-vs-region test is rect-only and lives in the marquee path.
|
|
3720
|
+
*
|
|
3721
|
+
* Lifecycle / baseline behaviour matches marquee: snapshot on first
|
|
3722
|
+
* preview, reuse for additive merge and tangent-eligibility, clear on
|
|
3723
|
+
* commit and on vector-edit exit. Scene (non-vector-edit) lasso is a
|
|
3724
|
+
* follow-up.
|
|
3725
|
+
*/
|
|
3726
|
+
handle_lasso_select(intent) {
|
|
3727
|
+
if (!this.vector_edit) return;
|
|
3728
|
+
const node_id = this.vector_edit.node_id;
|
|
3729
|
+
const el = this.element_index.get(node_id);
|
|
3730
|
+
if (!(el instanceof SVGGraphicsElement)) return;
|
|
3731
|
+
if (typeof el.getScreenCTM !== "function") return;
|
|
3732
|
+
const ctm = el.getScreenCTM();
|
|
3733
|
+
if (!ctm) return;
|
|
3734
|
+
const offset = this.container_offset();
|
|
3735
|
+
const model = this.session_model();
|
|
3736
|
+
if (!model) return;
|
|
3737
|
+
const polygon = intent.polygon;
|
|
3738
|
+
if (polygon.length < 3) return;
|
|
3739
|
+
const baseline = this.ensure_region_baseline();
|
|
3740
|
+
const pre_selection = {
|
|
3741
|
+
vertices: baseline.vertices,
|
|
3742
|
+
segments: baseline.segments,
|
|
3743
|
+
tangents: baseline.tangents
|
|
3744
|
+
};
|
|
3745
|
+
const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
|
|
3746
|
+
const vertex_hits = marquee.points_in_polygon(candidates.vertices, polygon);
|
|
3747
|
+
const tangent_hits = marquee.points_in_polygon(candidates.tangents, polygon);
|
|
3748
|
+
const merged = marquee.merge_subpath_hits(pre_selection, {
|
|
3749
|
+
vertices: vertex_hits,
|
|
3750
|
+
segments: [],
|
|
3751
|
+
tangents: tangent_hits
|
|
3752
|
+
}, intent.additive);
|
|
3753
|
+
this.vector_edit.set_selection(merged);
|
|
3754
|
+
this.sync_selection_mirror();
|
|
3755
|
+
if (intent.phase === "commit") {
|
|
3756
|
+
const baseline_snapshot = Object.freeze({
|
|
3757
|
+
vertices: baseline.vertices,
|
|
3758
|
+
segments: baseline.segments,
|
|
3759
|
+
tangents: baseline.tangents
|
|
3760
|
+
});
|
|
3761
|
+
this.vector_edit_region_baseline = null;
|
|
3762
|
+
this.record_vector_selection_change(baseline_snapshot, "lasso select");
|
|
3763
|
+
}
|
|
3764
|
+
this.redraw();
|
|
3765
|
+
}
|
|
3766
|
+
/**
|
|
3767
|
+
* Dispatched by the editor when `enter_content_edit(id)` is called. The
|
|
3768
|
+
* editor has already gated on text-OR-path eligibility; this method
|
|
3769
|
+
* routes on the actual tag.
|
|
3770
|
+
*/
|
|
3771
|
+
enter_content_edit(id) {
|
|
3772
|
+
if (this.text_edit || this.vector_edit) return false;
|
|
3773
|
+
const tag = this.tag_of(id);
|
|
3774
|
+
if (tag === "text" || tag === "tspan") return this.enter_text_edit(id);
|
|
3775
|
+
if (this.editor_internal().doc.is_vector_edit_target(id) !== null) return this.enter_vector_edit(id);
|
|
3776
|
+
return false;
|
|
3777
|
+
}
|
|
3778
|
+
/**
|
|
3779
|
+
* Place a new single-line `<text>` at `world` and immediately enter
|
|
3780
|
+
* content-edit on it. Creation + first edit are bracketed in one history
|
|
3781
|
+
* preview (via `insert_text_preview`): committing with content is one
|
|
3782
|
+
* undo step, exiting empty discards the node entirely (empty-equals-
|
|
3783
|
+
* delete). The bracket is finalized in {@link enter_text_edit}'s
|
|
3784
|
+
* commit/cancel callbacks via `this.pending_text_insert`.
|
|
3785
|
+
*
|
|
3786
|
+
* Default font appearance lives in `core/insertions.ts`
|
|
3787
|
+
* (`default_text_attrs`), alongside the shape insertion defaults — not
|
|
3788
|
+
* hard-coded here — so the per-element insert semantics stay in core (P3).
|
|
3789
|
+
*/
|
|
3790
|
+
begin_text_insert(world) {
|
|
3791
|
+
if (this.text_edit || this.vector_edit) return;
|
|
3792
|
+
const session = this.editor_internal().insert_text_preview(insertions.default_text_attrs(world));
|
|
3793
|
+
this.pending_text_insert = {
|
|
3794
|
+
id: session.id,
|
|
3795
|
+
session
|
|
3796
|
+
};
|
|
3797
|
+
this.editor.enter_content_edit(session.id);
|
|
3798
|
+
if (this.text_edit_target !== session.id) {
|
|
3799
|
+
this.pending_text_insert = null;
|
|
3800
|
+
session.discard();
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
enter_text_edit(id) {
|
|
3804
|
+
if (this.text_edit) return false;
|
|
3805
|
+
const el = this.element_index.get(id);
|
|
3806
|
+
if (!(el instanceof SVGElement)) return false;
|
|
3807
|
+
const doc = this.editor._internal;
|
|
3808
|
+
if (!(el instanceof SVGTextContentElement)) return false;
|
|
3809
|
+
this.text_edit_target = id;
|
|
3810
|
+
this.text_edit_original = doc.doc.text_of(id);
|
|
3811
|
+
this.text_edit = TEXT_EDIT_PENDING;
|
|
3812
|
+
this.editor.commands.set_mode("edit-content");
|
|
3813
|
+
this.sync_surface_selection();
|
|
3814
|
+
this.sync_cursor();
|
|
3815
|
+
this.redraw();
|
|
3816
|
+
const text_surface = new SvgTextSurface(this.element_index.get(id) ?? el);
|
|
3817
|
+
const is_mac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
|
3818
|
+
let settled = false;
|
|
3819
|
+
this.text_edit = createTextEditor({
|
|
3820
|
+
container: this.container,
|
|
3821
|
+
initialText: this.text_edit_original,
|
|
3822
|
+
layout: text_surface,
|
|
3823
|
+
surface: text_surface,
|
|
3824
|
+
isMac: is_mac,
|
|
3825
|
+
ariaLabel: "edit svg text",
|
|
3826
|
+
requiresMutationsForCommit: (text) => /\s{2,}|^\s|\s$/.test(text),
|
|
3827
|
+
callbacks: {
|
|
3828
|
+
onChange: (text) => {
|
|
3829
|
+
doc.doc.set_text(id, text);
|
|
3830
|
+
},
|
|
3831
|
+
onCommit: (final_text) => {
|
|
3832
|
+
if (settled) return;
|
|
3833
|
+
settled = true;
|
|
3834
|
+
this.finalize_text_exit(id, final_text);
|
|
3835
|
+
},
|
|
3836
|
+
onCancel: () => {
|
|
3837
|
+
if (settled) return;
|
|
3838
|
+
settled = true;
|
|
3839
|
+
this.finalize_text_exit(id, this.text_edit_original);
|
|
3840
|
+
},
|
|
3841
|
+
onUndoFallthrough: () => {
|
|
3842
|
+
this.text_edit?.commit();
|
|
3843
|
+
this.editor.commands.undo();
|
|
3844
|
+
},
|
|
3845
|
+
onRedoFallthrough: () => {
|
|
3846
|
+
this.text_edit?.commit();
|
|
3847
|
+
this.editor.commands.redo();
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
});
|
|
3851
|
+
return true;
|
|
3852
|
+
}
|
|
3853
|
+
/**
|
|
3854
|
+
* Exit inline text-edit back to select mode. P4 — observers should see a
|
|
3855
|
+
* consistent post-edit state, so do all observable mutations + surface
|
|
3856
|
+
* syncs first, then clear the text-edit handles last (so anything polling
|
|
3857
|
+
* "is text-edit active?" still says yes until the world is settled).
|
|
3858
|
+
*/
|
|
3859
|
+
cleanup_text_edit() {
|
|
3860
|
+
this.editor.commands.set_mode("select");
|
|
3861
|
+
this.render();
|
|
3862
|
+
this.sync_surface_selection();
|
|
3863
|
+
this.sync_cursor();
|
|
3864
|
+
this.redraw();
|
|
3865
|
+
this.text_edit = null;
|
|
3866
|
+
this.text_edit_target = null;
|
|
3867
|
+
}
|
|
3868
|
+
/**
|
|
3869
|
+
* Realize the result of a text content-edit session and exit. `result`
|
|
3870
|
+
* is the text that should remain — the typed text on commit, the original
|
|
3871
|
+
* on cancel. Implements the empty-equals-delete rule (design:
|
|
3872
|
+
* docs/wg/feat-svg-editor/text-tool.md): an empty result removes the node.
|
|
3873
|
+
* (see test/svg-editor-text-empty-delete.md)
|
|
3874
|
+
*
|
|
3875
|
+
* The empty-equals-delete decision is pure and lives in
|
|
3876
|
+
* {@link resolve_text_exit} (`core/text-edit.ts`) — tested headlessly.
|
|
3877
|
+
* This method is the thin dispatcher that realizes each action against
|
|
3878
|
+
* the surface + editor.
|
|
3879
|
+
*/
|
|
3880
|
+
finalize_text_exit(id, result) {
|
|
3881
|
+
const internal = this.editor_internal();
|
|
3882
|
+
const insert = this.pending_text_insert;
|
|
3883
|
+
this.pending_text_insert = null;
|
|
3884
|
+
const action = resolve_text_exit({
|
|
3885
|
+
origin: insert && insert.id === id ? "fresh" : "existing",
|
|
3886
|
+
result,
|
|
3887
|
+
original: this.text_edit_original
|
|
3888
|
+
});
|
|
3889
|
+
if (action.kind === "commit_insert" || action.kind === "discard_insert") {
|
|
3890
|
+
if (!insert) return;
|
|
3891
|
+
if (action.kind === "discard_insert") {
|
|
3892
|
+
this.cleanup_text_edit();
|
|
3893
|
+
insert.session.discard();
|
|
3894
|
+
} else {
|
|
3895
|
+
this.cleanup_text_edit();
|
|
3896
|
+
insert.session.commit();
|
|
3897
|
+
}
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
internal.doc.set_text(id, this.text_edit_original);
|
|
3901
|
+
this.cleanup_text_edit();
|
|
3902
|
+
switch (action.kind) {
|
|
3903
|
+
case "remove":
|
|
3904
|
+
this.editor.commands.select(id);
|
|
3905
|
+
this.editor.commands.remove();
|
|
3906
|
+
break;
|
|
3907
|
+
case "set_text":
|
|
3908
|
+
this.editor.commands.set_text(action.value);
|
|
3909
|
+
break;
|
|
3910
|
+
case "noop":
|
|
3911
|
+
internal.emit();
|
|
3912
|
+
break;
|
|
3913
|
+
default:
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
/**
|
|
3917
|
+
* Enter path-vertex-edit mode. Mirrors `enter_text_edit` shape: capture
|
|
3918
|
+
* the original `d`, flip the editor mode, push a vector-selection mirror
|
|
3919
|
+
* to the HUD, and start serving `vectorOf` from the live session.
|
|
3920
|
+
*
|
|
3921
|
+
* Exit happens via `exit_vector_edit` (Esc / `set_mode("select")` /
|
|
3922
|
+
* dblclick away). On exit, any in-flight preview is discarded; committed
|
|
3923
|
+
* intermediate states stay (each gesture's commit was its own history
|
|
3924
|
+
* entry, per the gesture-bracketed history doctrine).
|
|
3925
|
+
*
|
|
3926
|
+
* The enter is itself a history step (tagged `vector-mode-enter`) so
|
|
3927
|
+
* undo from inside vector-edit reverts the entry — symmetric to the
|
|
3928
|
+
* `vector-mode-exit` push in {@link exit_vector_edit}. Both deltas
|
|
3929
|
+
* delegate to the unchecked {@link _do_enter_vector_edit} /
|
|
3930
|
+
* {@link _do_exit_vector_edit} helpers; the public wrappers below own
|
|
3931
|
+
* the history side, the unchecked helpers own the state mutation.
|
|
3932
|
+
*/
|
|
3933
|
+
enter_vector_edit(id) {
|
|
3934
|
+
if (this.vector_edit) return false;
|
|
3935
|
+
if (!this._do_enter_vector_edit(id)) return false;
|
|
3936
|
+
const internal = this.editor_internal();
|
|
3937
|
+
const node_id = id;
|
|
3938
|
+
const preview = internal.history.preview("enter vector edit");
|
|
3939
|
+
preview.set({
|
|
3940
|
+
providerId: "svg-editor",
|
|
3941
|
+
descriptor: { kind: "vector-mode-enter" },
|
|
3942
|
+
apply: () => {
|
|
3943
|
+
this._do_enter_vector_edit(node_id);
|
|
3944
|
+
},
|
|
3945
|
+
revert: () => {
|
|
3946
|
+
this._do_exit_vector_edit();
|
|
3947
|
+
}
|
|
3948
|
+
});
|
|
3949
|
+
preview.commit();
|
|
3950
|
+
return true;
|
|
3951
|
+
}
|
|
3952
|
+
/**
|
|
3953
|
+
* Discard any in-flight preview, clear the vector-edit session, and return
|
|
3954
|
+
* the editor to select mode. Safe to call when no vector-edit is active
|
|
3955
|
+
* (no-op). Idempotent.
|
|
3956
|
+
*
|
|
3957
|
+
* Pushes a `vector-mode-exit` history step that closes over the
|
|
3958
|
+
* session's `node_id` and its final sub-selection, so undo re-enters
|
|
3959
|
+
* the same path and restores the selection the user was about to
|
|
3960
|
+
* leave. Pairs with {@link enter_vector_edit}.
|
|
3961
|
+
*/
|
|
3962
|
+
exit_vector_edit() {
|
|
3963
|
+
if (!this.vector_edit) return;
|
|
3964
|
+
const node_id = this.vector_edit.node_id;
|
|
3965
|
+
const final_selection = this.vector_edit.snapshot_selection();
|
|
3966
|
+
this._do_exit_vector_edit();
|
|
3967
|
+
const preview = this.editor_internal().history.preview("exit vector edit");
|
|
3968
|
+
preview.set({
|
|
3969
|
+
providerId: "svg-editor",
|
|
3970
|
+
descriptor: { kind: "vector-mode-exit" },
|
|
3971
|
+
apply: () => {
|
|
3972
|
+
this._do_exit_vector_edit();
|
|
3973
|
+
},
|
|
3974
|
+
revert: () => {
|
|
3975
|
+
if (this._do_enter_vector_edit(node_id)) {
|
|
3976
|
+
this.vector_edit?.restore_selection(final_selection);
|
|
3977
|
+
this.sync_selection_mirror();
|
|
3978
|
+
this.redraw();
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
});
|
|
3982
|
+
preview.commit();
|
|
3983
|
+
}
|
|
3984
|
+
/**
|
|
3985
|
+
* Unchecked enter — performs the mode flip + HUD wiring without
|
|
3986
|
+
* pushing to history. Called by {@link enter_vector_edit} (user-facing,
|
|
3987
|
+
* which pushes the delta) and by the exit-delta's revert (history-
|
|
3988
|
+
* driven re-entry on undo). Returns `false` if the node has no usable
|
|
3989
|
+
* `d` attribute, leaving editor state untouched.
|
|
3990
|
+
*/
|
|
3991
|
+
_do_enter_vector_edit(id) {
|
|
3992
|
+
if (this.vector_edit) return false;
|
|
3993
|
+
const source = this.editor_internal().doc.is_vector_edit_target(id);
|
|
3994
|
+
if (source === null) return false;
|
|
3995
|
+
const session_d = source_to_session_d(source);
|
|
3996
|
+
this.vector_edit = new VectorEditSession(id, source, session_d);
|
|
3997
|
+
this.editor.commands.set_mode("edit-content");
|
|
3998
|
+
this.sync_selection_mirror();
|
|
3999
|
+
this.sync_surface_selection();
|
|
4000
|
+
this.sync_cursor();
|
|
4001
|
+
this.redraw();
|
|
4002
|
+
return true;
|
|
4003
|
+
}
|
|
4004
|
+
/**
|
|
4005
|
+
* Unchecked exit — counterpart to {@link _do_enter_vector_edit}. No
|
|
4006
|
+
* history push. Safe to call when no session is active.
|
|
4007
|
+
*/
|
|
4008
|
+
_do_exit_vector_edit() {
|
|
4009
|
+
if (!this.vector_edit) return;
|
|
4010
|
+
if (this.active_preview && (this.active_preview.kind === "vector_vertex_translate" || this.active_preview.kind === "vector_set_tangent" || this.active_preview.kind === "vector_bend_segment" || this.active_preview.kind === "vector_translate_selection")) {
|
|
4011
|
+
this.active_preview.session.discard();
|
|
4012
|
+
this.active_preview = null;
|
|
4013
|
+
}
|
|
4014
|
+
this.vector_edit = null;
|
|
4015
|
+
this.vector_edit_region_baseline = null;
|
|
4016
|
+
this.hud.setVectorSelection(null);
|
|
4017
|
+
if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
|
|
4018
|
+
this.editor.commands.set_mode("select");
|
|
4019
|
+
this.sync_surface_selection();
|
|
4020
|
+
this.sync_cursor();
|
|
4021
|
+
this.redraw();
|
|
4022
|
+
}
|
|
4023
|
+
/**
|
|
4024
|
+
* `vectorOf` provider for the HUD. Returns the live PathSnapshot for the
|
|
4025
|
+
* named node when a vector-edit session is active for it; otherwise null.
|
|
4026
|
+
* HUD calls this each frame; cheap enough to recompute (snapshot just
|
|
4027
|
+
* copies the underlying network's arrays via the model's getter).
|
|
4028
|
+
*/
|
|
4029
|
+
vector_of(id) {
|
|
4030
|
+
if (!this.vector_edit || this.vector_edit.node_id !== id) return null;
|
|
4031
|
+
const model = this.active_preview_model_for(id) ?? this.session_model();
|
|
4032
|
+
if (!model) return null;
|
|
4033
|
+
const snap = model.snapshot();
|
|
4034
|
+
const el = this.element_index.get(id);
|
|
4035
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
4036
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
4037
|
+
const ctm = el.getScreenCTM();
|
|
4038
|
+
if (!ctm) return null;
|
|
4039
|
+
const offset = this.container_offset();
|
|
4040
|
+
return {
|
|
4041
|
+
vertices: Array.from({ length: snap.vertices.length }, (_, i) => {
|
|
4042
|
+
const v = snap.vertices[i];
|
|
4043
|
+
return project_point_through_ctm(v[0], v[1], ctm, offset);
|
|
4044
|
+
}),
|
|
4045
|
+
segments: snap.segments.map((s) => {
|
|
4046
|
+
const va = snap.vertices[s.a];
|
|
4047
|
+
const vb = snap.vertices[s.b];
|
|
4048
|
+
const a_ctrl_local = [va[0] + s.ta[0], va[1] + s.ta[1]];
|
|
4049
|
+
const b_ctrl_local = [vb[0] + s.tb[0], vb[1] + s.tb[1]];
|
|
4050
|
+
return {
|
|
4051
|
+
a: s.a,
|
|
4052
|
+
b: s.b,
|
|
4053
|
+
a_control: project_point_through_ctm(a_ctrl_local[0], a_ctrl_local[1], ctm, offset),
|
|
4054
|
+
b_control: project_point_through_ctm(b_ctrl_local[0], b_ctrl_local[1], ctm, offset)
|
|
4055
|
+
};
|
|
4056
|
+
}),
|
|
4057
|
+
neighbours: model.neighbouringVertices({
|
|
4058
|
+
vertices: this.vector_edit.selected_vertices,
|
|
4059
|
+
segments: this.vector_edit.selected_segments,
|
|
4060
|
+
tangents: this.vector_edit.selected_tangents
|
|
4061
|
+
}),
|
|
4062
|
+
origin: [0, 0]
|
|
4063
|
+
};
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* The session's in-memory PathModel-form `d`. For `<path>` sources
|
|
4067
|
+
* this stays in lock-step with `doc.get_attr(id, "d")`; for
|
|
4068
|
+
* `<line>` / `<polyline>` / `<polygon>` sources the document holds
|
|
4069
|
+
* native attrs and the session-d is the lingua-franca view that
|
|
4070
|
+
* gesture handlers parse from and write back to via
|
|
4071
|
+
* {@link apply_session_d}. Returns `null` if no session is active.
|
|
4072
|
+
*/
|
|
4073
|
+
read_session_d() {
|
|
4074
|
+
if (!this.vector_edit) return null;
|
|
4075
|
+
return this.vector_edit.current_d;
|
|
4076
|
+
}
|
|
4077
|
+
/**
|
|
4078
|
+
* Derive a fresh `PathModel` from the current `d` for the path under
|
|
4079
|
+
* edit. Computed on read — there is no cached copy held on the session.
|
|
4080
|
+
* See `vector-edit/session.ts` for the doctrine ("d is the live store").
|
|
4081
|
+
*/
|
|
4082
|
+
session_model() {
|
|
4083
|
+
const d = this.read_session_d();
|
|
4084
|
+
if (d === null) return null;
|
|
4085
|
+
return PathModel.fromSvgPathD(d);
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Republish the vector-edit sub-selection to the HUD. No-op when no
|
|
4089
|
+
* session is open. Every selection-changing handler ends with this so
|
|
4090
|
+
* the surface mirror stays in lock-step with `this.vector_edit` — the
|
|
4091
|
+
* inline `setVectorSelection({ node_id, vertices, segments, tangents })`
|
|
4092
|
+
* block was repeated at 6+ sites before this collapse.
|
|
4093
|
+
*/
|
|
4094
|
+
sync_selection_mirror() {
|
|
4095
|
+
if (!this.vector_edit) return;
|
|
4096
|
+
this.hud.setVectorSelection({
|
|
4097
|
+
node_id: this.vector_edit.node_id,
|
|
4098
|
+
vertices: this.vector_edit.selected_vertices,
|
|
4099
|
+
segments: this.vector_edit.selected_segments,
|
|
4100
|
+
tangents: this.vector_edit.selected_tangents
|
|
4101
|
+
});
|
|
4102
|
+
}
|
|
4103
|
+
/**
|
|
4104
|
+
* Replay the session-side effects of a committed vector-edit delta onto
|
|
4105
|
+
* the LIVE `this.vector_edit` session — but only if it is still aimed at
|
|
4106
|
+
* the same node. Geometry restoration is the closure's `commit(d)` job
|
|
4107
|
+
* (document-level, always safe); this method handles the bits the
|
|
4108
|
+
* geometry write cannot reach on its own: advancing `last_seen_d` so
|
|
4109
|
+
* the external-mutation watcher doesn't pounce, and re-installing the
|
|
4110
|
+
* captured sub-selection.
|
|
4111
|
+
*
|
|
4112
|
+
* Closures used to call `session.mark_seen(...)` / `session.restore_selection(...)`
|
|
4113
|
+
* on a `const session = this.vector_edit` captured at gesture start.
|
|
4114
|
+
* After exit + undo-exit + undo-geometry, that capture pointed at the
|
|
4115
|
+
* disposed session while the live session was a fresh one — geometry
|
|
4116
|
+
* still restored correctly (via `commit`), but sub-selection didn't,
|
|
4117
|
+
* and the live session's stale `last_seen_d` would then cause the
|
|
4118
|
+
* watcher to clear the new session's selection on its next tick.
|
|
4119
|
+
*
|
|
4120
|
+
* Pass `d = null` for selection-only deltas (no geometry change → no
|
|
4121
|
+
* watermark advance).
|
|
4122
|
+
*/
|
|
4123
|
+
replay_vector_session_state(target_node_id, d, selection) {
|
|
4124
|
+
const cur = this.vector_edit;
|
|
4125
|
+
if (!cur || cur.node_id !== target_node_id) return;
|
|
4126
|
+
if (d !== null) {
|
|
4127
|
+
cur.mark_seen(d);
|
|
4128
|
+
const live_source = this.editor_internal().doc.is_vector_edit_target(cur.node_id);
|
|
4129
|
+
if (live_source) cur.sync_source(live_source);
|
|
4130
|
+
}
|
|
4131
|
+
cur.restore_selection(selection);
|
|
4132
|
+
this.sync_selection_mirror();
|
|
4133
|
+
}
|
|
4134
|
+
/**
|
|
4135
|
+
* Build the `{ apply, revert }` history step for a vector-edit geometry
|
|
4136
|
+
* delta — the single chokepoint that routes the write through
|
|
4137
|
+
* {@link vector_apply} / {@link vector_revert} so promote-to-path of a
|
|
4138
|
+
* primitive source (rect / circle / ellipse) is handled in one place
|
|
4139
|
+
* rather than per gesture.
|
|
4140
|
+
*
|
|
4141
|
+
* `promo` is the per-gesture token holder (shared by reference across an
|
|
4142
|
+
* `active_preview`'s preview frames and its committed step) so the
|
|
4143
|
+
* promotion that fires on the first frame is the one undo reverses —
|
|
4144
|
+
* promotion + first edit collapse into a single undo step. On redo after
|
|
4145
|
+
* an undo-demote, `apply` re-promotes and refreshes the token.
|
|
4146
|
+
*
|
|
4147
|
+
* `after_selection` / `before_selection` drive sub-selection replay;
|
|
4148
|
+
* pass `null` (preview frames) to skip it.
|
|
4149
|
+
*/
|
|
4150
|
+
vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection) {
|
|
4151
|
+
const internal = this.editor_internal();
|
|
4152
|
+
const doc = internal.doc;
|
|
4153
|
+
const emit = internal.emit;
|
|
4154
|
+
const session = this.vector_edit;
|
|
4155
|
+
return {
|
|
4156
|
+
providerId: "svg-editor",
|
|
4157
|
+
apply: () => {
|
|
4158
|
+
if (session) {
|
|
4159
|
+
const tok = vector_apply(doc, session, target_d);
|
|
4160
|
+
if (tok) promo.token = tok;
|
|
4161
|
+
}
|
|
4162
|
+
if (after_selection) this.replay_vector_session_state(node_id, target_d, after_selection);
|
|
4163
|
+
emit();
|
|
4164
|
+
},
|
|
4165
|
+
revert: () => {
|
|
4166
|
+
if (session) vector_revert(doc, session, baseline_d, promo.token);
|
|
4167
|
+
promo.token = null;
|
|
4168
|
+
if (before_selection) this.replay_vector_session_state(node_id, baseline_d, before_selection);
|
|
4169
|
+
emit();
|
|
4170
|
+
}
|
|
4171
|
+
};
|
|
4172
|
+
}
|
|
4173
|
+
/**
|
|
4174
|
+
* Push a standalone vector sub-selection change as one history entry.
|
|
4175
|
+
*
|
|
4176
|
+
* Called by selection-only handlers (vertex / segment / tangent click,
|
|
4177
|
+
* marquee / lasso commit, clear-vector-selection) AFTER the
|
|
4178
|
+
* `VectorEditSession` has been mutated to the new state. `before` is the
|
|
4179
|
+
* snapshot captured before the mutation; the current session state is
|
|
4180
|
+
* captured here as `after`.
|
|
4181
|
+
*
|
|
4182
|
+
* Tagged with `descriptor: { kind: "vector-selection" }` so hosts that
|
|
4183
|
+
* want Figma-style "skip selection on undo" can filter on the
|
|
4184
|
+
* descriptor without inspecting closure internals. Default behavior:
|
|
4185
|
+
* standalone vector-selection IS undoable.
|
|
4186
|
+
*
|
|
4187
|
+
* No-op when the snapshot is unchanged — avoids spamming the stack
|
|
4188
|
+
* with entries from clicks that resolve to the same state (e.g.
|
|
4189
|
+
* clicking an already-selected vertex in `replace` mode).
|
|
4190
|
+
*/
|
|
4191
|
+
record_vector_selection_change(before, label) {
|
|
4192
|
+
if (!this.vector_edit) return;
|
|
4193
|
+
const after = this.vector_edit.snapshot_selection();
|
|
4194
|
+
if (sub_selection_equal(before, after)) return;
|
|
4195
|
+
const target_node_id = this.vector_edit.node_id;
|
|
4196
|
+
const preview = this.editor_internal().history.preview(label);
|
|
4197
|
+
preview.set({
|
|
4198
|
+
providerId: "svg-editor",
|
|
4199
|
+
descriptor: { kind: "vector-selection" },
|
|
4200
|
+
apply: () => {
|
|
4201
|
+
this.replay_vector_session_state(target_node_id, null, after);
|
|
4202
|
+
this.redraw();
|
|
4203
|
+
},
|
|
4204
|
+
revert: () => {
|
|
4205
|
+
this.replay_vector_session_state(target_node_id, null, before);
|
|
4206
|
+
this.redraw();
|
|
4207
|
+
}
|
|
4208
|
+
});
|
|
4209
|
+
preview.commit();
|
|
4210
|
+
}
|
|
4211
|
+
/** Resolve the in-flight `PathModel` for the named node id when a
|
|
4212
|
+
* vector-preview is active; null otherwise. */
|
|
4213
|
+
active_preview_model_for(id) {
|
|
4214
|
+
const ap = this.active_preview;
|
|
4215
|
+
if (!ap) return null;
|
|
4216
|
+
switch (ap.kind) {
|
|
4217
|
+
case "vector_vertex_translate": return ap.node_id === id ? ap.preview_model : null;
|
|
4218
|
+
case "vector_set_tangent": return ap.node_id === id ? ap.preview_model : null;
|
|
4219
|
+
case "vector_bend_segment": return ap.node_id === id ? ap.preview_model : null;
|
|
4220
|
+
case "vector_translate_selection": return ap.node_id === id ? ap.preview_model : null;
|
|
4221
|
+
default: return null;
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
/**
|
|
4225
|
+
* Apply a `select_vertex` intent. Updates the vector-edit session's sub-
|
|
4226
|
+
* selection and pushes a fresh mirror to the HUD.
|
|
4227
|
+
*/
|
|
4228
|
+
handle_select_vertex(intent) {
|
|
4229
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4230
|
+
const before = this.vector_edit.snapshot_selection();
|
|
4231
|
+
this.vector_edit.select_vertex(intent.index, intent.mode);
|
|
4232
|
+
this.sync_selection_mirror();
|
|
4233
|
+
this.redraw();
|
|
4234
|
+
this.record_vector_selection_change(before, "select vertex");
|
|
4235
|
+
}
|
|
4236
|
+
/**
|
|
4237
|
+
* Apply a `select_segment` intent. Mirrors {@link handle_select_vertex}.
|
|
4238
|
+
* Fired when the user clicks a segment OFF the ghost insertion knob —
|
|
4239
|
+
* clicking the ghost itself fires `split_segment` instead.
|
|
4240
|
+
*/
|
|
4241
|
+
handle_select_segment(intent) {
|
|
4242
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4243
|
+
const before = this.vector_edit.snapshot_selection();
|
|
4244
|
+
this.vector_edit.select_segment(intent.segment, intent.mode);
|
|
4245
|
+
this.sync_selection_mirror();
|
|
4246
|
+
this.redraw();
|
|
4247
|
+
this.record_vector_selection_change(before, "select segment");
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Apply a `translate_vertices` intent. Mirrors the `set_endpoint` flow:
|
|
4251
|
+
* - First frame opens a `history.preview` session capturing the original `d`.
|
|
4252
|
+
* - Each subsequent preview frame applies a fresh translation FROM the
|
|
4253
|
+
* original baseline (so the cumulative delta on the intent stays correct
|
|
4254
|
+
* and we don't accumulate drift across frames).
|
|
4255
|
+
* - The commit frame finalizes the preview; the session keeps its updated
|
|
4256
|
+
* model for the next gesture.
|
|
4257
|
+
*
|
|
4258
|
+
* The vector-edit session's `model` is updated to reflect the committed
|
|
4259
|
+
* state (preview model is computed on the fly each frame from the
|
|
4260
|
+
* baseline; only commit writes back into session.model).
|
|
4261
|
+
*/
|
|
4262
|
+
handle_translate_vertices(intent) {
|
|
4263
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4264
|
+
const internal = this.editor_internal();
|
|
4265
|
+
const node_id = intent.node_id;
|
|
4266
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_vertex_translate" || this.active_preview.node_id !== node_id || !array_shallow_equal(this.active_preview.indices, intent.indices)) {
|
|
4267
|
+
if (this.active_preview) {
|
|
4268
|
+
if ("session" in this.active_preview) this.active_preview.session.discard();
|
|
4269
|
+
}
|
|
4270
|
+
const initial_d = this.read_session_d();
|
|
4271
|
+
if (initial_d === null) return;
|
|
4272
|
+
const baseline_model = PathModel.fromSvgPathD(initial_d);
|
|
4273
|
+
this.active_preview = {
|
|
4274
|
+
kind: "vector_vertex_translate",
|
|
4275
|
+
node_id,
|
|
4276
|
+
promo: { token: null },
|
|
4277
|
+
indices: [...intent.indices],
|
|
4278
|
+
initial_d,
|
|
4279
|
+
baseline_model,
|
|
4280
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
4281
|
+
preview_model: baseline_model,
|
|
4282
|
+
session: internal.history.preview("vector/translate-vertex")
|
|
4283
|
+
};
|
|
4284
|
+
}
|
|
4285
|
+
const baseline_d = this.active_preview.initial_d;
|
|
4286
|
+
const indices = this.active_preview.indices;
|
|
4287
|
+
let [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
|
|
4288
|
+
[local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
|
|
4289
|
+
const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
|
|
4290
|
+
const target_d = preview_model.toSvgPathD();
|
|
4291
|
+
this.active_preview.preview_model = preview_model;
|
|
4292
|
+
if (intent.phase === "commit") {
|
|
4293
|
+
this.point_snap_guide = void 0;
|
|
4294
|
+
const before_selection = this.active_preview.before_selection;
|
|
4295
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
4296
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
4297
|
+
this.active_preview.session.commit();
|
|
4298
|
+
this.active_preview = null;
|
|
4299
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
4300
|
+
}
|
|
4301
|
+
/**
|
|
4302
|
+
* `translate_vector_selection` — the sub-selection-aware delta-translate.
|
|
4303
|
+
*
|
|
4304
|
+
* Mirrors main editor's `translate-vector-controls`
|
|
4305
|
+
* (`editor/grida-canvas/reducers/tools/event-target.cem-vector.reducer.ts:667-675`).
|
|
4306
|
+
* Translates the union of:
|
|
4307
|
+
*
|
|
4308
|
+
* - selected vertices (authoritative sub-selection)
|
|
4309
|
+
* - endpoints of selected segments (segment selection implies
|
|
4310
|
+
* its two endpoints translate)
|
|
4311
|
+
* - intent.additional_vertex_indices (carried by segment drag so
|
|
4312
|
+
* endpoints translate even
|
|
4313
|
+
* when the segment isn't yet
|
|
4314
|
+
* in sub-selection — the
|
|
4315
|
+
* deferred select_segment was
|
|
4316
|
+
* canceled by drag promotion)
|
|
4317
|
+
*
|
|
4318
|
+
* AND delta-translates selected tangents, EXCLUDING tangents whose parent
|
|
4319
|
+
* vertex is already in the translated set (mirrors `vector.ts:39-42`'s
|
|
4320
|
+
* `getUXNeighbouringVertices`-style exclusion: the vertex move already
|
|
4321
|
+
* carries its tangent controls, so double-applying would shift them
|
|
4322
|
+
* twice). Mirror policy pinned to `"none"` during multi-translate; mirror
|
|
4323
|
+
* behavior is reserved for the singleton-tangent curve gesture.
|
|
4324
|
+
*
|
|
4325
|
+
* Opens a dedicated `vector_translate_selection` preview so the
|
|
4326
|
+
* vertex translate AND tangent delta apply atomically per frame.
|
|
4327
|
+
*/
|
|
4328
|
+
handle_translate_vector_selection(intent) {
|
|
4329
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4330
|
+
const internal = this.editor_internal();
|
|
4331
|
+
const node_id = intent.node_id;
|
|
4332
|
+
const ses = this.vector_edit;
|
|
4333
|
+
const current_d = this.read_session_d();
|
|
4334
|
+
if (current_d === null) return;
|
|
4335
|
+
const resolved_model = PathModel.fromSvgPathD(current_d);
|
|
4336
|
+
const vertex_count = resolved_model.vertexCount();
|
|
4337
|
+
const segment_snapshot = resolved_model.snapshot().segments;
|
|
4338
|
+
const indices_set = /* @__PURE__ */ new Set();
|
|
4339
|
+
const add_if_valid = (i) => {
|
|
4340
|
+
if (i >= 0 && i < vertex_count) indices_set.add(i);
|
|
4341
|
+
};
|
|
4342
|
+
for (const v of ses.selected_vertices) add_if_valid(v);
|
|
4343
|
+
for (const s of ses.selected_segments) {
|
|
4344
|
+
const seg = segment_snapshot[s];
|
|
4345
|
+
if (!seg) continue;
|
|
4346
|
+
add_if_valid(seg.a);
|
|
4347
|
+
add_if_valid(seg.b);
|
|
4348
|
+
}
|
|
4349
|
+
for (const v of intent.additional_vertex_indices) add_if_valid(v);
|
|
4350
|
+
const tangent_refs = [];
|
|
4351
|
+
for (const ref of ses.selected_tangents) {
|
|
4352
|
+
if (indices_set.has(ref[0])) continue;
|
|
4353
|
+
tangent_refs.push(ref);
|
|
4354
|
+
}
|
|
4355
|
+
if (indices_set.size === 0 && tangent_refs.length === 0) return;
|
|
4356
|
+
const indices = Array.from(indices_set).sort((a, b) => a - b);
|
|
4357
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_translate_selection" || this.active_preview.node_id !== node_id || !array_shallow_equal(this.active_preview.indices, indices) || !sameTangentRefs(this.active_preview.tangent_refs, tangent_refs)) {
|
|
4358
|
+
if (this.active_preview) {
|
|
4359
|
+
if ("session" in this.active_preview) this.active_preview.session.discard();
|
|
4360
|
+
}
|
|
4361
|
+
const initial_d = this.read_session_d();
|
|
4362
|
+
if (initial_d === null) return;
|
|
4363
|
+
const baseline_model = PathModel.fromSvgPathD(initial_d);
|
|
4364
|
+
const baseline_tangent_abs = tangent_refs.map((ref) => baseline_model.tangentAbsolute(ref, [0, 0]));
|
|
4365
|
+
this.active_preview = {
|
|
4366
|
+
kind: "vector_translate_selection",
|
|
4367
|
+
node_id,
|
|
4368
|
+
promo: { token: null },
|
|
4369
|
+
indices: [...indices],
|
|
4370
|
+
tangent_refs: [...tangent_refs],
|
|
4371
|
+
initial_d,
|
|
4372
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
4373
|
+
preview_model: baseline_model,
|
|
4374
|
+
baseline_model,
|
|
4375
|
+
baseline_tangent_abs,
|
|
4376
|
+
session: internal.history.preview("vector/translate-selection")
|
|
4377
|
+
};
|
|
4378
|
+
}
|
|
4379
|
+
const baseline_d = this.active_preview.initial_d;
|
|
4380
|
+
let [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
|
|
4381
|
+
[local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
|
|
4382
|
+
const baseline_model = this.active_preview.baseline_model;
|
|
4383
|
+
const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
|
|
4384
|
+
let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
|
|
4385
|
+
for (let i = 0; i < tangent_refs.length; i++) {
|
|
4386
|
+
const baseline_abs = baseline_tangent_abs[i];
|
|
4387
|
+
if (baseline_abs === null) continue;
|
|
4388
|
+
preview_model = preview_model.setTangent(tangent_refs[i], [baseline_abs[0] + local_dx, baseline_abs[1] + local_dy], "none");
|
|
4389
|
+
}
|
|
4390
|
+
const target_d = preview_model.toSvgPathD();
|
|
4391
|
+
this.active_preview.preview_model = preview_model;
|
|
4392
|
+
if (intent.phase === "commit") {
|
|
4393
|
+
this.point_snap_guide = void 0;
|
|
4394
|
+
const before_selection = this.active_preview.before_selection;
|
|
4395
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
4396
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
4397
|
+
this.active_preview.session.commit();
|
|
4398
|
+
this.active_preview = null;
|
|
4399
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
4400
|
+
}
|
|
4401
|
+
/**
|
|
4402
|
+
* Surface-aware `transform.nudge` handler — registered over the headless
|
|
4403
|
+
* default on attach (gridaco/grida#849). In vector content-edit, arrow
|
|
4404
|
+
* keys nudge the path sub-selection (the keyboard counterpart of
|
|
4405
|
+
* dragging vertices / segments / tangents) rather than leaking to a
|
|
4406
|
+
* whole-element move (the bug). This mirrors text-edit, where the inline
|
|
4407
|
+
* editor owns the arrow keys. Outside content-edit it delegates to the
|
|
4408
|
+
* headless `default_nudge_handler` (whole-element nudge) — the same
|
|
4409
|
+
* handler the registry restores on detach, so the override and the
|
|
4410
|
+
* default can never drift.
|
|
4411
|
+
*
|
|
4412
|
+
* While a vector session is open the arrows are OWNED by it: an empty
|
|
4413
|
+
* sub-selection is a consumed no-op, never a whole-element nudge — same
|
|
4414
|
+
* "content-edit captures arrows" contract as text-edit.
|
|
4415
|
+
*/
|
|
4416
|
+
handle_nudge_command(args) {
|
|
4417
|
+
const { dx, dy } = args;
|
|
4418
|
+
if (this.editor.state.mode === "edit-content" && this.vector_edit) {
|
|
4419
|
+
this.nudge_vector_selection(dx, dy);
|
|
4420
|
+
return true;
|
|
4421
|
+
}
|
|
4422
|
+
return default_nudge_handler(this.editor)(args) === true;
|
|
4423
|
+
}
|
|
4424
|
+
/**
|
|
4425
|
+
* Discrete keyboard nudge of the vector sub-selection. Reuses the drag
|
|
4426
|
+
* path ({@link handle_translate_vector_selection}) by synthesizing a
|
|
4427
|
+
* single commit-phase intent, so a nudge is byte-identical to a one-step
|
|
4428
|
+
* drag of the same points: same union resolution (selected vertices ∪
|
|
4429
|
+
* selected-segment endpoints ∪ tangents), same parent-vertex tangent
|
|
4430
|
+
* exclusion, same history bracket + sub-selection capture.
|
|
4431
|
+
*
|
|
4432
|
+
* `dx`/`dy` arrive in world units (1px, or 10px with Shift — already
|
|
4433
|
+
* resolved by the keymap binding). `handle_translate_vector_selection`
|
|
4434
|
+
* expects container CSS-px (it projects back through the inverse
|
|
4435
|
+
* screen-CTM), so scale by the camera zoom on the way in; the projection
|
|
4436
|
+
* recovers the world delta in the path's local frame. A no-sub-selection
|
|
4437
|
+
* session resolves to a no-op inside the handler.
|
|
4438
|
+
*/
|
|
4439
|
+
nudge_vector_selection(dx, dy) {
|
|
4440
|
+
const ses = this.vector_edit;
|
|
4441
|
+
if (!ses) return;
|
|
4442
|
+
const zoom = this.camera.zoom || 1;
|
|
4443
|
+
this.suppress_point_snap = true;
|
|
4444
|
+
try {
|
|
4445
|
+
this.handle_translate_vector_selection({
|
|
4446
|
+
kind: "translate_vector_selection",
|
|
4447
|
+
node_id: ses.node_id,
|
|
4448
|
+
additional_vertex_indices: [],
|
|
4449
|
+
dx: dx * zoom,
|
|
4450
|
+
dy: dy * zoom,
|
|
4451
|
+
phase: "commit"
|
|
4452
|
+
});
|
|
4453
|
+
} finally {
|
|
4454
|
+
this.suppress_point_snap = false;
|
|
4455
|
+
}
|
|
4456
|
+
this.request_redraw();
|
|
4457
|
+
}
|
|
4458
|
+
/** Mirror handler for `select_tangent` (analogous to `select_vertex`). */
|
|
4459
|
+
handle_select_tangent(intent) {
|
|
4460
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4461
|
+
const before = this.vector_edit.snapshot_selection();
|
|
4462
|
+
this.vector_edit.select_tangent(intent.tangent, intent.mode);
|
|
4463
|
+
this.sync_selection_mirror();
|
|
4464
|
+
this.redraw();
|
|
4465
|
+
this.record_vector_selection_change(before, "select tangent");
|
|
4466
|
+
}
|
|
4467
|
+
/**
|
|
4468
|
+
* Tangent drag handler. Mirrors `handle_translate_vertices`:
|
|
4469
|
+
*
|
|
4470
|
+
* - First frame opens a `history.preview` session, captures `original_d`.
|
|
4471
|
+
* - Each preview frame replays setTangent from the baseline model so
|
|
4472
|
+
* cumulative drift never accumulates.
|
|
4473
|
+
* - Commit finalizes the preview and reseeds the session.
|
|
4474
|
+
*
|
|
4475
|
+
* `intent.pos` arrives in container CSS-px (HUD's doc-space). We project
|
|
4476
|
+
* it back through the inverse-CTM to path-local before calling
|
|
4477
|
+
* `PathModel.setTangent`.
|
|
4478
|
+
*/
|
|
4479
|
+
handle_set_tangent_intent(intent) {
|
|
4480
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4481
|
+
const internal = this.editor_internal();
|
|
4482
|
+
const node_id = intent.node_id;
|
|
4483
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_set_tangent" || this.active_preview.node_id !== node_id || this.active_preview.tangent[0] !== intent.tangent[0] || this.active_preview.tangent[1] !== intent.tangent[1]) {
|
|
4484
|
+
if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
|
|
4485
|
+
const initial_d = this.read_session_d();
|
|
4486
|
+
if (initial_d === null) return;
|
|
4487
|
+
const baseline_model = PathModel.fromSvgPathD(initial_d);
|
|
4488
|
+
this.active_preview = {
|
|
4489
|
+
kind: "vector_set_tangent",
|
|
4490
|
+
node_id,
|
|
4491
|
+
promo: { token: null },
|
|
4492
|
+
tangent: [intent.tangent[0], intent.tangent[1]],
|
|
4493
|
+
initial_d,
|
|
4494
|
+
baseline_model,
|
|
4495
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
4496
|
+
preview_model: baseline_model,
|
|
4497
|
+
session: internal.history.preview("vector/set-tangent")
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
const baseline_d = this.active_preview.initial_d;
|
|
4501
|
+
const local_pos = this.project_doc_point_to_local(node_id, intent.pos);
|
|
4502
|
+
if (!local_pos) return;
|
|
4503
|
+
const preview_model = this.active_preview.baseline_model.setTangent(intent.tangent, local_pos, intent.mirror);
|
|
4504
|
+
const target_d = preview_model.toSvgPathD();
|
|
4505
|
+
this.active_preview.preview_model = preview_model;
|
|
4506
|
+
if (intent.phase === "commit") {
|
|
4507
|
+
const before_selection = this.active_preview.before_selection;
|
|
4508
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
4509
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
4510
|
+
this.active_preview.session.commit();
|
|
4511
|
+
this.active_preview = null;
|
|
4512
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
4513
|
+
}
|
|
4514
|
+
/**
|
|
4515
|
+
* Split a segment at parametric position `t`. One-shot atomic edit; no
|
|
4516
|
+
* preview phase. After the split, the new vertex is auto-selected — this
|
|
4517
|
+
* matches Figma's add-anchor behavior and prepares the user to immediately
|
|
4518
|
+
* drag the newly inserted anchor.
|
|
4519
|
+
*/
|
|
4520
|
+
handle_split_segment(intent) {
|
|
4521
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4522
|
+
const node_id = intent.node_id;
|
|
4523
|
+
const baseline_d = this.read_session_d();
|
|
4524
|
+
if (baseline_d === null) return;
|
|
4525
|
+
const { model: next_model, new_vertex } = PathModel.fromSvgPathD(baseline_d).splitSegment(intent.segment, intent.t);
|
|
4526
|
+
const target_d = next_model.toSvgPathD();
|
|
4527
|
+
const internal = this.editor_internal();
|
|
4528
|
+
const before_selection = this.vector_edit.snapshot_selection();
|
|
4529
|
+
const after_selection = Object.freeze({
|
|
4530
|
+
vertices: Object.freeze([new_vertex]),
|
|
4531
|
+
segments: Object.freeze([]),
|
|
4532
|
+
tangents: Object.freeze([])
|
|
4533
|
+
});
|
|
4534
|
+
const promo = { token: null };
|
|
4535
|
+
const split_session = internal.history.preview("vector/split-segment");
|
|
4536
|
+
split_session.set(this.vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection));
|
|
4537
|
+
split_session.commit();
|
|
4538
|
+
this.redraw();
|
|
4539
|
+
}
|
|
4540
|
+
/**
|
|
4541
|
+
* Bend a segment by dragging an interior point. Mirrors set_tangent —
|
|
4542
|
+
* preview session opened on first frame, replays from baseline each
|
|
4543
|
+
* frame, commits / reseeds on phase===commit.
|
|
4544
|
+
*
|
|
4545
|
+
* `frozen` is captured ONCE at session open from the baseline model;
|
|
4546
|
+
* `vne.bendSegment` solves for new tangents against this snapshot, so
|
|
4547
|
+
* the cumulative drag delta is correct without per-frame drift.
|
|
4548
|
+
*/
|
|
4549
|
+
handle_bend_segment(intent) {
|
|
4550
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4551
|
+
const internal = this.editor_internal();
|
|
4552
|
+
const node_id = intent.node_id;
|
|
4553
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_bend_segment" || this.active_preview.node_id !== node_id || this.active_preview.segment !== intent.segment || this.active_preview.ca !== intent.ca) {
|
|
4554
|
+
if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
|
|
4555
|
+
const initial_d = this.read_session_d();
|
|
4556
|
+
if (initial_d === null) return;
|
|
4557
|
+
const baseline_model = PathModel.fromSvgPathD(initial_d);
|
|
4558
|
+
const snap = baseline_model.snapshot();
|
|
4559
|
+
const s = snap.segments[intent.segment];
|
|
4560
|
+
if (!s) return;
|
|
4561
|
+
const va = snap.vertices[s.a];
|
|
4562
|
+
const vb = snap.vertices[s.b];
|
|
4563
|
+
this.active_preview = {
|
|
4564
|
+
kind: "vector_bend_segment",
|
|
4565
|
+
node_id,
|
|
4566
|
+
promo: { token: null },
|
|
4567
|
+
segment: intent.segment,
|
|
4568
|
+
ca: intent.ca,
|
|
4569
|
+
frozen: {
|
|
4570
|
+
a: [va[0], va[1]],
|
|
4571
|
+
b: [vb[0], vb[1]],
|
|
4572
|
+
ta: [s.ta[0], s.ta[1]],
|
|
4573
|
+
tb: [s.tb[0], s.tb[1]]
|
|
4574
|
+
},
|
|
4575
|
+
initial_d,
|
|
4576
|
+
baseline_model,
|
|
4577
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
4578
|
+
preview_model: baseline_model,
|
|
4579
|
+
session: internal.history.preview("vector/bend-segment")
|
|
4580
|
+
};
|
|
4581
|
+
}
|
|
4582
|
+
const baseline_d = this.active_preview.initial_d;
|
|
4583
|
+
const frozen = this.active_preview.frozen;
|
|
4584
|
+
const local_cb = this.project_doc_point_to_local(node_id, intent.cb);
|
|
4585
|
+
if (!local_cb) return;
|
|
4586
|
+
const preview_model = this.active_preview.baseline_model.bendSegment(intent.segment, intent.ca, local_cb, frozen);
|
|
4587
|
+
const target_d = preview_model.toSvgPathD();
|
|
4588
|
+
this.active_preview.preview_model = preview_model;
|
|
4589
|
+
if (intent.phase === "commit") {
|
|
4590
|
+
const before_selection = this.active_preview.before_selection;
|
|
4591
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
4592
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
4593
|
+
this.active_preview.session.commit();
|
|
4594
|
+
this.active_preview = null;
|
|
4595
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
4596
|
+
}
|
|
4597
|
+
/**
|
|
4598
|
+
* Project a doc-space point (HUD's container CSS-px frame) back to the
|
|
4599
|
+
* named element's local frame via inverse-CTM. Returns null if no CTM
|
|
4600
|
+
* is available or the CTM is singular.
|
|
4601
|
+
*/
|
|
4602
|
+
project_doc_point_to_local(id, p) {
|
|
4603
|
+
const el = this.element_index.get(id);
|
|
4604
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
4605
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
4606
|
+
const ctm = el.getScreenCTM();
|
|
4607
|
+
if (!ctm) return null;
|
|
4608
|
+
const cr = this.container.getBoundingClientRect();
|
|
4609
|
+
const offset_x = -cr.left + this.container.scrollLeft;
|
|
4610
|
+
const offset_y = -cr.top + this.container.scrollTop;
|
|
4611
|
+
const det = ctm.a * ctm.d - ctm.c * ctm.b;
|
|
4612
|
+
if (det === 0) return null;
|
|
4613
|
+
const px = p[0] - offset_x;
|
|
4614
|
+
const py = p[1] - offset_y;
|
|
4615
|
+
return [(ctm.d * (px - ctm.e) - ctm.c * (py - ctm.f)) / det, (-ctm.b * (px - ctm.e) + ctm.a * (py - ctm.f)) / det];
|
|
4616
|
+
}
|
|
4617
|
+
tag_of(id) {
|
|
4618
|
+
return this.editor.tree().nodes.get(id)?.tag ?? "";
|
|
4619
|
+
}
|
|
4620
|
+
bbox_local(id) {
|
|
4621
|
+
const el = this.element_index.get(id);
|
|
4622
|
+
if (!el) return null;
|
|
4623
|
+
const ge = el;
|
|
4624
|
+
if (typeof ge.getBBox !== "function") return null;
|
|
4625
|
+
try {
|
|
4626
|
+
const b = ge.getBBox();
|
|
4627
|
+
return {
|
|
4628
|
+
x: b.x,
|
|
4629
|
+
y: b.y,
|
|
4630
|
+
width: b.width,
|
|
4631
|
+
height: b.height
|
|
4632
|
+
};
|
|
4633
|
+
} catch {
|
|
4634
|
+
return null;
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
/** Doc-space AABB of `id`'s rendered geometry — local box projected
|
|
4638
|
+
* through the element's own `transform=`. This is the rect snap,
|
|
4639
|
+
* resize-baseline, and rotate-pivot consumers want: where the user
|
|
4640
|
+
* actually sees the element in the root SVG's user-coordinate system.
|
|
4641
|
+
*
|
|
4642
|
+
* Flat-doc design target: ancestor transforms (`<g transform=...>`)
|
|
4643
|
+
* are out of scope; only the element's own transform is projected.
|
|
4644
|
+
* `getBBox` (called via `bbox_local`) ignores the element's transform
|
|
4645
|
+
* per SVG 2 §4.6.4, so the projection here is what bridges the gap. */
|
|
4646
|
+
bbox_world(id) {
|
|
4647
|
+
const local = this.bbox_local(id);
|
|
4648
|
+
if (!local) return null;
|
|
4649
|
+
const transform_str = this.editor.document.get_attr(id, "transform");
|
|
4650
|
+
return transform.project(local, transform_str);
|
|
4651
|
+
}
|
|
4652
|
+
editor_internal() {
|
|
4653
|
+
return this.editor._internal;
|
|
4654
|
+
}
|
|
4655
|
+
};
|
|
4656
|
+
function numAttr(doc, id, name) {
|
|
4657
|
+
return svg_parse.parse_number(doc.get_attr(id, name));
|
|
4658
|
+
}
|
|
4659
|
+
/** Order-sensitive shallow equality for tangent-ref arrays. */
|
|
4660
|
+
function sameTangentRefs(a, b) {
|
|
4661
|
+
if (a.length !== b.length) return false;
|
|
4662
|
+
for (let i = 0; i < a.length; i++) if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
|
|
4663
|
+
return true;
|
|
4664
|
+
}
|
|
4665
|
+
/**
|
|
4666
|
+
* Affine projection of a point through a 2×3 CTM, then offset by a
|
|
4667
|
+
* container origin (in page CSS-px). Mirrors how `line_endpoints_in_container`
|
|
4668
|
+
* and `shape_of` (transformed branch) bridge from local SVG coords to the
|
|
4669
|
+
* HUD's container-CSS-px space (HUD keeps its own transform at identity;
|
|
4670
|
+
* the SVG carries the camera as a CSS transform, which getScreenCTM
|
|
4671
|
+
* folds in).
|
|
4672
|
+
*
|
|
4673
|
+
* Exported for headless test coverage — pure function, no DOM types.
|
|
4674
|
+
*/
|
|
4675
|
+
function project_point_through_ctm(px, py, ctm, container_offset) {
|
|
4676
|
+
const [sx, sy] = cmath.vector2.transform([px, py], [[
|
|
4677
|
+
ctm.a,
|
|
4678
|
+
ctm.c,
|
|
4679
|
+
ctm.e
|
|
4680
|
+
], [
|
|
4681
|
+
ctm.b,
|
|
4682
|
+
ctm.d,
|
|
4683
|
+
ctm.f
|
|
4684
|
+
]]);
|
|
4685
|
+
return [sx + container_offset[0], sy + container_offset[1]];
|
|
4686
|
+
}
|
|
4687
|
+
/**
|
|
4688
|
+
* Inverse of the CTM's linear part applied to a delta vector. Drops
|
|
4689
|
+
* translation. Used to convert a HUD-reported container-space drag delta
|
|
4690
|
+
* back to the path's local coord space for `PathModel.translateVertices`.
|
|
4691
|
+
*
|
|
4692
|
+
* Throws on a degenerate (det = 0) matrix — the caller is expected to
|
|
4693
|
+
* have a non-singular CTM for any visible element.
|
|
4694
|
+
*/
|
|
4695
|
+
function project_delta_inverse_ctm(dx, dy, ctm) {
|
|
4696
|
+
if (ctm.a * ctm.d - ctm.c * ctm.b === 0) throw new Error("project_delta_inverse_ctm: singular CTM linear part");
|
|
4697
|
+
const inv = cmath.transform.invert([[
|
|
4698
|
+
ctm.a,
|
|
4699
|
+
ctm.c,
|
|
4700
|
+
0
|
|
4701
|
+
], [
|
|
4702
|
+
ctm.b,
|
|
4703
|
+
ctm.d,
|
|
4704
|
+
0
|
|
4705
|
+
]]);
|
|
4706
|
+
return cmath.vector2.transform([dx, dy], inv);
|
|
4707
|
+
}
|
|
4708
|
+
/**
|
|
4709
|
+
* Inverse-project a doc-space rect through a CTM + container offset back
|
|
4710
|
+
* into the element's local frame. The output is the AABB of the four
|
|
4711
|
+
* inverse-projected corners — when the CTM has a rotation component the
|
|
4712
|
+
* AABB is an approximation, but it matches what the user visually
|
|
4713
|
+
* expects from a screen-aligned marquee drag.
|
|
4714
|
+
*
|
|
4715
|
+
* Returns `null` when the CTM's linear part is singular (degenerate
|
|
4716
|
+
* camera) — the caller should skip any test that needs the local rect.
|
|
4717
|
+
*/
|
|
4718
|
+
function inverse_project_rect(rect, ctm, offset) {
|
|
4719
|
+
if (ctm.a * ctm.d - ctm.c * ctm.b === 0) return null;
|
|
4720
|
+
const inv = cmath.transform.invert([[
|
|
4721
|
+
ctm.a,
|
|
4722
|
+
ctm.c,
|
|
4723
|
+
ctm.e
|
|
4724
|
+
], [
|
|
4725
|
+
ctm.b,
|
|
4726
|
+
ctm.d,
|
|
4727
|
+
ctm.f
|
|
4728
|
+
]]);
|
|
4729
|
+
const to_local = (px, py) => cmath.vector2.transform([px - offset[0], py - offset[1]], inv);
|
|
4730
|
+
const corners = [
|
|
4731
|
+
to_local(rect.x, rect.y),
|
|
4732
|
+
to_local(rect.x + rect.width, rect.y),
|
|
4733
|
+
to_local(rect.x, rect.y + rect.height),
|
|
4734
|
+
to_local(rect.x + rect.width, rect.y + rect.height)
|
|
4735
|
+
];
|
|
4736
|
+
return cmath.rect.fromPoints(corners);
|
|
4737
|
+
}
|
|
4738
|
+
/** World-space viewport rect of an `<svg>` element. Prefers `viewBox`
|
|
4739
|
+
* (the declared user-space rect — what the user perceives as canvas),
|
|
4740
|
+
* falls back to `width`/`height` at (0,0). For nested `<svg>` with a
|
|
4741
|
+
* positional `x`/`y`, the declared viewBox/(0,0) is in the nested
|
|
4742
|
+
* element's OWN user space; callers are responsible for CTM
|
|
4743
|
+
* projection if a different frame is desired. v1 nested-svg story is
|
|
4744
|
+
* documented in ../docs/geometry.md as out of scope. */
|
|
4745
|
+
function svg_viewport_bounds(el) {
|
|
4746
|
+
const vb = el.getAttribute("viewBox");
|
|
4747
|
+
if (vb) {
|
|
4748
|
+
const parts = vb.trim().split(/[\s,]+/).map(Number);
|
|
4749
|
+
if (parts.length === 4 && parts.every(Number.isFinite)) return {
|
|
4750
|
+
x: parts[0],
|
|
4751
|
+
y: parts[1],
|
|
4752
|
+
width: parts[2],
|
|
4753
|
+
height: parts[3]
|
|
4754
|
+
};
|
|
4755
|
+
}
|
|
4756
|
+
const w = parseFloat(el.getAttribute("width") ?? "");
|
|
4757
|
+
const h = parseFloat(el.getAttribute("height") ?? "");
|
|
4758
|
+
if (Number.isFinite(w) && Number.isFinite(h)) return {
|
|
4759
|
+
x: 0,
|
|
4760
|
+
y: 0,
|
|
4761
|
+
width: w,
|
|
4762
|
+
height: h
|
|
4763
|
+
};
|
|
4764
|
+
return null;
|
|
4765
|
+
}
|
|
4766
|
+
/** Index of the next side ≥ `start` (in [top, right, bottom, left] order)
|
|
4767
|
+
* where both measurements have a positive distance — i.e. the next side
|
|
4768
|
+
* that `measurementToHUDDraw` would emit a labelled line for. -1 if
|
|
4769
|
+
* none. */
|
|
4770
|
+
/** Among the edges of a point sequence, pick the one whose outward
|
|
4771
|
+
* normal points most downward — i.e. which edge visually FORMS the
|
|
4772
|
+
* bottom of the shape. For a CW-wound polygon (TL/TR/BR/BL, in y-down
|
|
4773
|
+
* screen space) the outward normal of an edge `(p1, p2)` is the edge
|
|
4774
|
+
* direction rotated 90° CW: `(dy, -dx)`, so the normal's Y component
|
|
4775
|
+
* equals `-dx / |edge|`. Ranking by *normalized* outward-Y, not by
|
|
4776
|
+
* midpoint-Y, makes a long-thin rotated rect anchor the label on its
|
|
4777
|
+
* long bottom edge (correct) instead of the short bottom corner
|
|
4778
|
+
* (which happens to have a lower midpoint but doesn't read as "the
|
|
4779
|
+
* bottom side"). Returns the midpoint as `anchor` and the edge's
|
|
4780
|
+
* direction angle normalized to `(-π/2, π/2]` — so a HUDLine label
|
|
4781
|
+
* drawn at the anchor with `labelAngle = angle` reads right-side-up
|
|
4782
|
+
* and its perpendicular offset points further down.
|
|
4783
|
+
*
|
|
4784
|
+
* - `closed = true`: pts are polygon vertices, all consecutive pairs
|
|
4785
|
+
* (incl. last→first) are edges. Use for rects (4 corners → 4 edges).
|
|
4786
|
+
* - `closed = false`: only consecutive pairs in order are edges. Use
|
|
4787
|
+
* for lines (2 endpoints → 1 edge). With a single edge there's no
|
|
4788
|
+
* "outward" to rank — the function just returns the midpoint and
|
|
4789
|
+
* normalized angle. */
|
|
4790
|
+
function pick_lowest_side_anchor(pts, closed) {
|
|
4791
|
+
const edge_count = closed ? pts.length : pts.length - 1;
|
|
4792
|
+
let best_score = -Infinity;
|
|
4793
|
+
let best = null;
|
|
4794
|
+
for (let i = 0; i < edge_count; i++) {
|
|
4795
|
+
const p1 = pts[i];
|
|
4796
|
+
const p2 = pts[(i + 1) % pts.length];
|
|
4797
|
+
const dx = p2[0] - p1[0];
|
|
4798
|
+
const dy = p2[1] - p1[1];
|
|
4799
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
4800
|
+
const score = -dx / len;
|
|
4801
|
+
if (score > best_score) {
|
|
4802
|
+
best_score = score;
|
|
4803
|
+
let theta = Math.atan2(dy, dx);
|
|
4804
|
+
if (theta > Math.PI / 2) theta -= Math.PI;
|
|
4805
|
+
else if (theta <= -Math.PI / 2) theta += Math.PI;
|
|
4806
|
+
best = {
|
|
4807
|
+
anchor: [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2],
|
|
4808
|
+
angle: theta
|
|
4809
|
+
};
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
return best;
|
|
4813
|
+
}
|
|
4814
|
+
function next_labellable_side(start, a, b) {
|
|
4815
|
+
for (let i = start; i < 4; i++) if (a.distance[i] > 0 && b.distance[i] > 0) return i;
|
|
4816
|
+
return -1;
|
|
4817
|
+
}
|
|
4818
|
+
/** Concatenate the primitive arrays of N `HUDDraw`s. `undefined` inputs
|
|
4819
|
+
* collapse cleanly so callers can pass per-feature builders without
|
|
4820
|
+
* null-guarding each one. */
|
|
4821
|
+
function merge_hud_draws(...draws) {
|
|
4822
|
+
const present = draws.filter((d) => d !== void 0);
|
|
4823
|
+
if (present.length === 0) return void 0;
|
|
4824
|
+
if (present.length === 1) return present[0];
|
|
4825
|
+
const out = {};
|
|
4826
|
+
for (const d of present) {
|
|
4827
|
+
if (d.lines) (out.lines ??= []).push(...d.lines);
|
|
4828
|
+
if (d.rects) (out.rects ??= []).push(...d.rects);
|
|
4829
|
+
if (d.rules) (out.rules ??= []).push(...d.rules);
|
|
4830
|
+
if (d.points) (out.points ??= []).push(...d.points);
|
|
4831
|
+
if (d.polylines) (out.polylines ??= []).push(...d.polylines);
|
|
4832
|
+
if (d.screenRects) (out.screenRects ??= []).push(...d.screenRects);
|
|
4833
|
+
}
|
|
4834
|
+
return out;
|
|
4835
|
+
}
|
|
4836
|
+
var SvgGeometryDriver = class {
|
|
4837
|
+
constructor(accessors) {
|
|
4838
|
+
this.accessors = accessors;
|
|
4839
|
+
}
|
|
4840
|
+
bounds_of(id) {
|
|
4841
|
+
this.accessors.flush();
|
|
4842
|
+
const el = this.accessors.element_for(id);
|
|
4843
|
+
if (!el) return null;
|
|
4844
|
+
if (el instanceof SVGSVGElement) return svg_viewport_bounds(el);
|
|
4845
|
+
const ge = el;
|
|
4846
|
+
if (typeof ge.getBBox !== "function" || typeof ge.getCTM !== "function") return null;
|
|
4847
|
+
let bbox;
|
|
4848
|
+
try {
|
|
4849
|
+
const b = ge.getBBox();
|
|
4850
|
+
bbox = {
|
|
4851
|
+
x: b.x,
|
|
4852
|
+
y: b.y,
|
|
4853
|
+
width: b.width,
|
|
4854
|
+
height: b.height
|
|
4855
|
+
};
|
|
4856
|
+
} catch {
|
|
4857
|
+
return null;
|
|
4858
|
+
}
|
|
4859
|
+
const ctm = ge.getCTM();
|
|
4860
|
+
if (!ctm) return bbox;
|
|
4861
|
+
const project = (px, py) => ({
|
|
4862
|
+
x: ctm.a * px + ctm.c * py + ctm.e,
|
|
4863
|
+
y: ctm.b * px + ctm.d * py + ctm.f
|
|
4864
|
+
});
|
|
4865
|
+
const corners = [
|
|
4866
|
+
project(bbox.x, bbox.y),
|
|
4867
|
+
project(bbox.x + bbox.width, bbox.y),
|
|
4868
|
+
project(bbox.x + bbox.width, bbox.y + bbox.height),
|
|
4869
|
+
project(bbox.x, bbox.y + bbox.height)
|
|
4870
|
+
];
|
|
4871
|
+
const xs = corners.map((c) => c.x);
|
|
4872
|
+
const ys = corners.map((c) => c.y);
|
|
4873
|
+
const left = Math.min(...xs);
|
|
4874
|
+
const top = Math.min(...ys);
|
|
4875
|
+
const right = Math.max(...xs);
|
|
4876
|
+
const bottom = Math.max(...ys);
|
|
4877
|
+
return {
|
|
4878
|
+
x: left,
|
|
4879
|
+
y: top,
|
|
4880
|
+
width: right - left,
|
|
4881
|
+
height: bottom - top
|
|
4882
|
+
};
|
|
4883
|
+
}
|
|
4884
|
+
bounds_of_many(ids) {
|
|
4885
|
+
const out = /* @__PURE__ */ new Map();
|
|
4886
|
+
for (const id of ids) {
|
|
4887
|
+
const r = this.bounds_of(id);
|
|
4888
|
+
if (r) out.set(id, r);
|
|
4889
|
+
}
|
|
4890
|
+
return out;
|
|
4891
|
+
}
|
|
4892
|
+
nodes_in_rect(rect) {
|
|
4893
|
+
this.accessors.flush();
|
|
4894
|
+
const root = this.accessors.root();
|
|
4895
|
+
if (!root) return [];
|
|
4896
|
+
const hits = [];
|
|
4897
|
+
root.querySelectorAll(`[${ID_ATTR}]`).forEach((el) => {
|
|
4898
|
+
const id = el.getAttribute(ID_ATTR);
|
|
4899
|
+
if (!id) return;
|
|
4900
|
+
const b = this.bounds_of(id);
|
|
4901
|
+
if (b && cmath.rect.intersects(b, rect)) hits.push(id);
|
|
4902
|
+
});
|
|
4903
|
+
return hits;
|
|
4904
|
+
}
|
|
4905
|
+
node_at_point(p) {
|
|
4906
|
+
this.accessors.flush();
|
|
4907
|
+
return this.accessors.pick_at_world(p, true);
|
|
4908
|
+
}
|
|
4909
|
+
/** World→local delta projection. The frame an element's position is
|
|
4910
|
+
* written in is its PARENT user-space: a `<rect>`'s `x`/`y` and the
|
|
4911
|
+
* leading `translate(...)` composed onto a `<g>`/transformed node are
|
|
4912
|
+
* both interpreted there. We take the parent element's frame (not the
|
|
4913
|
+
* element's own) so that translating a node whose OWN transform has a
|
|
4914
|
+
* scale/rotation is not double-counted.
|
|
4915
|
+
*
|
|
4916
|
+
* Camera-free: `inv(root.getScreenCTM) ∘ parent.getScreenCTM` maps
|
|
4917
|
+
* parent user-space → root world-space, cancelling the shared CSS /
|
|
4918
|
+
* camera transform. Inverting its linear part turns a world delta into
|
|
4919
|
+
* the local delta. Identity (→ delta unchanged) for flat frames,
|
|
4920
|
+
* top-level nodes, and any degenerate / unavailable matrix. */
|
|
4921
|
+
world_delta_to_local(id, delta) {
|
|
4922
|
+
this.accessors.flush();
|
|
4923
|
+
const parent = this.accessors.element_for(id)?.parentNode;
|
|
4924
|
+
const root = this.accessors.root();
|
|
4925
|
+
if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
|
|
4926
|
+
if (parent === root) return delta;
|
|
4927
|
+
if (typeof parent.getScreenCTM !== "function" || typeof root.getScreenCTM !== "function") return delta;
|
|
4928
|
+
const parent_ctm = parent.getScreenCTM();
|
|
4929
|
+
const root_ctm = root.getScreenCTM();
|
|
4930
|
+
if (!parent_ctm || !root_ctm) return delta;
|
|
4931
|
+
const m = root_ctm.inverse().multiply(parent_ctm);
|
|
4932
|
+
const det = m.a * m.d - m.c * m.b;
|
|
4933
|
+
if (!Number.isFinite(det) || det === 0) return delta;
|
|
4934
|
+
const [x, y] = project_delta_inverse_ctm(delta.x, delta.y, m);
|
|
4935
|
+
return {
|
|
4936
|
+
x,
|
|
4937
|
+
y
|
|
4938
|
+
};
|
|
4939
|
+
}
|
|
4940
|
+
};
|
|
4941
|
+
var SvgHitShapeDriver = class {
|
|
4942
|
+
constructor(accessors) {
|
|
4943
|
+
this.accessors = accessors;
|
|
4944
|
+
}
|
|
4945
|
+
hit_shape_of(id) {
|
|
4946
|
+
const doc = this.accessors.doc();
|
|
4947
|
+
if (!doc) return null;
|
|
4948
|
+
const intrinsic = hit_shape_svg.of_doc(doc, id);
|
|
4949
|
+
if (intrinsic) return intrinsic;
|
|
4950
|
+
if (hit_shape_svg.is_transparent_tag(doc.tag_of(id))) return null;
|
|
4951
|
+
const bounds = this.accessors.bounds_of(id);
|
|
4952
|
+
if (!bounds) return null;
|
|
4953
|
+
return {
|
|
4954
|
+
kind: "rect",
|
|
4955
|
+
x: bounds.x,
|
|
4956
|
+
y: bounds.y,
|
|
4957
|
+
width: bounds.width,
|
|
4958
|
+
height: bounds.height
|
|
4959
|
+
};
|
|
4960
|
+
}
|
|
4961
|
+
};
|
|
4962
|
+
//#endregion
|
|
4963
|
+
export { project_point_through_ctm as a, MemoizedGeometryProvider as c, project_delta_inverse_ctm as i, Camera as l, install_font_load_geometry_bump as n, Gestures as o, inverse_project_rect as r, DEFAULT_SNAP_OPTIONS as s, attach_dom_surface as t };
|