@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.
@@ -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 };