@grida/svg-editor 1.0.0-alpha.1 → 1.0.0-alpha.12

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,3515 @@
1
+ import { A as is_text_input_focused, C as hit_shape_of_doc, O as STRUCTURAL_GRAPHICS_SET, S as is_resizable_node, T as project_local_bbox, b as compute_resize_factors, d as NudgeDwellWatcher, f as TranslateOrchestrator, h as apply_resize, i as initial_attrs, l as RotateOrchestrator, n as default_attrs, o as TOOL_CURSOR, s as parse_paint, t as compute_drag_attrs, v as capture_resize_baseline, w as is_transparent_tag } from "./insertions-Okcuo-Ck.mjs";
2
+ import cmath from "@grida/cmath";
3
+ import { svg_parse } from "@grida/svg/parse";
4
+ import { createTextEditor } from "@grida/text-editor/dom";
5
+ import { NO_MODS, Surface, measurementToHUDDraw, snapGuideToHUDDraw } from "@grida/hud";
6
+ import { cursors } from "@grida/hud/cursors";
7
+ import { measure } from "@grida/cmath/_measurement";
8
+ import { guide, snapToCanvasGeometry } from "@grida/cmath/_snap";
9
+ //#region src/core/camera.ts
10
+ /**
11
+ * Surface-scoped pan/zoom state.
12
+ *
13
+ * The public shape leads with the peer convention (`center` / `zoom` /
14
+ * `bounds`) and keeps the matrix as an advanced read. Methods mirror
15
+ * Figma/Penpot where they overlap.
16
+ */
17
+ var Camera = class {
18
+ constructor(opts) {
19
+ this.viewport_w = 0;
20
+ this.viewport_h = 0;
21
+ this.listeners = /* @__PURE__ */ new Set();
22
+ this._constraints = null;
23
+ this._transform = opts.initial ?? cmath.transform.identity;
24
+ this.resolve_bounds = opts.resolve_bounds;
25
+ }
26
+ /**
27
+ * Current viewport constraint, or `null` for free pan/zoom. Set with
28
+ * `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
29
+ * to clamp zoom + pan; assign `null` to clear.
30
+ *
31
+ * Constraints are applied synchronously inside `set_transform` (and
32
+ * `_set_viewport_size`), so every public mutation respects them
33
+ * automatically — the host never needs to subscribe-and-clamp itself.
34
+ */
35
+ get constraints() {
36
+ return this._constraints;
37
+ }
38
+ set constraints(c) {
39
+ this._constraints = c;
40
+ if (c) this.reenforce();
41
+ }
42
+ /** Underlying 2D affine. World→screen. */
43
+ get transform() {
44
+ return this._transform;
45
+ }
46
+ /** Uniform scale factor. 1 = 100 %. */
47
+ get zoom() {
48
+ return this._transform[0][0];
49
+ }
50
+ /** World-space point currently at viewport center. */
51
+ get center() {
52
+ return this.screen_to_world({
53
+ x: this.viewport_w / 2,
54
+ y: this.viewport_h / 2
55
+ });
56
+ }
57
+ /** World-space rectangle visible in the viewport. */
58
+ get bounds() {
59
+ const tl = this.screen_to_world({
60
+ x: 0,
61
+ y: 0
62
+ });
63
+ const br = this.screen_to_world({
64
+ x: this.viewport_w,
65
+ y: this.viewport_h
66
+ });
67
+ return {
68
+ x: tl.x,
69
+ y: tl.y,
70
+ width: br.x - tl.x,
71
+ height: br.y - tl.y
72
+ };
73
+ }
74
+ /** Translate the camera by a screen-space delta. */
75
+ pan(delta_screen) {
76
+ const t = this._transform;
77
+ this.set_transform([[
78
+ t[0][0],
79
+ t[0][1],
80
+ t[0][2] + delta_screen.x
81
+ ], [
82
+ t[1][0],
83
+ t[1][1],
84
+ t[1][2] + delta_screen.y
85
+ ]]);
86
+ }
87
+ /**
88
+ * Multiply zoom by `factor` keeping `origin_screen` fixed in world space.
89
+ * Used by wheel-zoom-at-cursor and pinch-zoom.
90
+ */
91
+ zoom_at(factor, origin_screen) {
92
+ const t = this._transform;
93
+ const s2 = t[0][0] * factor;
94
+ const tx2 = origin_screen.x * (1 - factor) + factor * t[0][2];
95
+ const ty2 = origin_screen.y * (1 - factor) + factor * t[1][2];
96
+ this.set_transform([[
97
+ s2,
98
+ 0,
99
+ tx2
100
+ ], [
101
+ 0,
102
+ s2,
103
+ ty2
104
+ ]]);
105
+ }
106
+ /** Pan so `c` lands at the viewport center. Zoom unchanged. */
107
+ set_center(c) {
108
+ const s = this._transform[0][0];
109
+ const tx = this.viewport_w / 2 - s * c.x;
110
+ const ty = this.viewport_h / 2 - s * c.y;
111
+ this.set_transform([[
112
+ s,
113
+ 0,
114
+ tx
115
+ ], [
116
+ 0,
117
+ s,
118
+ ty
119
+ ]]);
120
+ }
121
+ /** Set zoom directly; pivot defaults to viewport center. */
122
+ set_zoom(z, pivot_screen) {
123
+ const current = this._transform[0][0];
124
+ if (current === 0) return;
125
+ const factor = z / current;
126
+ const pivot = pivot_screen ?? {
127
+ x: this.viewport_w / 2,
128
+ y: this.viewport_h / 2
129
+ };
130
+ this.zoom_at(factor, pivot);
131
+ }
132
+ /**
133
+ * Replace the entire transform.
134
+ *
135
+ * Idempotent: when the new transform is element-wise equal to the current
136
+ * one, this is a no-op (no notification fires). This is the seam that
137
+ * makes external constraint loops (e.g. "subscribe → compute clamped →
138
+ * set_transform") terminate: the clamp re-emits the same transform on
139
+ * the second pass, set_transform short-circuits, no recursion.
140
+ *
141
+ * When `camera.constraints` is non-null, the input transform is clamped
142
+ * synchronously before being stored — every public mutation respects the
143
+ * constraint automatically.
144
+ */
145
+ set_transform(t) {
146
+ const next = this.apply_constraints(t);
147
+ if (transform_equal(this._transform, next)) return;
148
+ this._transform = next;
149
+ this.notify();
150
+ }
151
+ /** Viewport size in screen pixels. Read by host code computing constraints. */
152
+ get viewport_size() {
153
+ return {
154
+ width: this.viewport_w,
155
+ height: this.viewport_h
156
+ };
157
+ }
158
+ /**
159
+ * Fit a target into the viewport.
160
+ *
161
+ * - `"<root>"` — the document root's content bounds (host-resolved).
162
+ * - `"<selection>"` — current editor.state.selection's union bounds.
163
+ * - `NodeId` — that node's content bounds.
164
+ * - `Rect` — an explicit world-space rectangle.
165
+ *
166
+ * No-ops if the target resolves to `null` (e.g. empty selection) or if
167
+ * the viewport size is 0 (no container).
168
+ */
169
+ fit(target, opts) {
170
+ if (this.viewport_w <= 0 || this.viewport_h <= 0) return;
171
+ const rect = typeof target === "string" ? this.resolve_bounds(target) : target;
172
+ if (!rect || rect.width <= 0 || rect.height <= 0) return;
173
+ const margin = opts?.margin ?? 64;
174
+ const viewport = {
175
+ x: 0,
176
+ y: 0,
177
+ width: this.viewport_w,
178
+ height: this.viewport_h
179
+ };
180
+ this.set_transform(cmath.ext.viewport.transformToFit(viewport, rect, margin));
181
+ }
182
+ /** Snap back to identity. */
183
+ reset() {
184
+ this.set_transform(cmath.transform.identity);
185
+ }
186
+ /**
187
+ * Subscribe to camera changes. Fires on every mutation. Cheap channel —
188
+ * does NOT bump `editor.state.version`. Same pattern as
189
+ * `editor.subscribe_surface_hover`.
190
+ */
191
+ subscribe(cb) {
192
+ this.listeners.add(cb);
193
+ return () => {
194
+ this.listeners.delete(cb);
195
+ };
196
+ }
197
+ /** @internal Surface drives this on container resize. */
198
+ _set_viewport_size(w, h) {
199
+ if (w === this.viewport_w && h === this.viewport_h) return;
200
+ this.viewport_w = w;
201
+ this.viewport_h = h;
202
+ if (this._constraints) {
203
+ const next = this.apply_constraints(this._transform);
204
+ if (!transform_equal(this._transform, next)) this._transform = next;
205
+ }
206
+ this.notify();
207
+ }
208
+ /** Convert a screen-space point to world-space. */
209
+ screen_to_world(p) {
210
+ const inv = cmath.transform.invert(this._transform);
211
+ const [wx, wy] = cmath.vector2.transform([p.x, p.y], inv);
212
+ return {
213
+ x: wx,
214
+ y: wy
215
+ };
216
+ }
217
+ /** Convert a world-space point to screen-space. */
218
+ world_to_screen(p) {
219
+ const [sx, sy] = cmath.vector2.transform([p.x, p.y], this._transform);
220
+ return {
221
+ x: sx,
222
+ y: sy
223
+ };
224
+ }
225
+ /**
226
+ * Apply the current constraint (if any) to a candidate transform.
227
+ * Pure: returns the clamped result, never mutates state. Returns the
228
+ * input unchanged when constraints are null / bounds are unresolvable /
229
+ * viewport is 0.
230
+ */
231
+ apply_constraints(t) {
232
+ if (!this._constraints) return t;
233
+ if (this.viewport_w <= 0 || this.viewport_h <= 0) return t;
234
+ switch (this._constraints.type) {
235
+ case "cover": return clamp_cover(t, this._constraints, this.viewport_w, this.viewport_h, this.resolve_bounds);
236
+ }
237
+ }
238
+ /**
239
+ * Re-clamp the stored transform against the current constraint. Called
240
+ * from the `constraints` setter; `_set_viewport_size` has its own
241
+ * notify-inclusive path.
242
+ */
243
+ reenforce() {
244
+ if (!this._constraints) return;
245
+ const next = this.apply_constraints(this._transform);
246
+ if (transform_equal(this._transform, next)) return;
247
+ this._transform = next;
248
+ this.notify();
249
+ }
250
+ notify() {
251
+ for (const cb of this.listeners) cb();
252
+ }
253
+ };
254
+ /**
255
+ * Clamp a transform under a `'cover'` constraint:
256
+ * - Zoom lower-bounded at fit-with-padding (the slide always fills the
257
+ * viewport edge-to-edge).
258
+ * - When at min-zoom the slide is locked centered (bounds smaller than
259
+ * viewport on the constrained axis is impossible above min_zoom; below
260
+ * is impossible because zoom is clamped up).
261
+ * - When zoomed in, pan is clamped so the slide always covers the viewport
262
+ * (no black bars).
263
+ *
264
+ * Returns the input transform unchanged when bounds can't be resolved or
265
+ * are degenerate.
266
+ */
267
+ function clamp_cover(t, c, vp_w, vp_h, resolve) {
268
+ const bounds = typeof c.bounds === "string" ? resolve(c.bounds) : c.bounds;
269
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return t;
270
+ const padding = c.padding ?? 0;
271
+ const eff_w = vp_w - 2 * padding;
272
+ const eff_h = vp_h - 2 * padding;
273
+ if (eff_w <= 0 || eff_h <= 0) return t;
274
+ const min_zoom = Math.min(eff_w / bounds.width, eff_h / bounds.height);
275
+ const s = Math.max(t[0][0], min_zoom);
276
+ const sw = s * bounds.width;
277
+ const sh = s * bounds.height;
278
+ const o = Math.max(0, c.pan_overshoot ?? 0);
279
+ 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;
280
+ 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;
281
+ return [[
282
+ s,
283
+ 0,
284
+ tx
285
+ ], [
286
+ 0,
287
+ s,
288
+ ty
289
+ ]];
290
+ }
291
+ function transform_equal(a, b) {
292
+ 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];
293
+ }
294
+ //#endregion
295
+ //#region src/core/geometry.ts
296
+ /**
297
+ * Caches `bounds_of` results keyed on `NodeId`; full-clears on either
298
+ * `structure_version` or `geometry_version` bump. See docs/wg/feat-svg-editor/geometry.md for
299
+ * why the cache is load-bearing under the surface's per-tick re-render.
300
+ */
301
+ var MemoizedGeometryProvider = class {
302
+ constructor(driver, signals) {
303
+ this.unsubscribers = [];
304
+ this.cache = /* @__PURE__ */ new Map();
305
+ this.driver = driver;
306
+ const invalidate = () => this.cache.clear();
307
+ this.unsubscribers.push(signals.subscribe_structure(invalidate));
308
+ this.unsubscribers.push(signals.subscribe_geometry(invalidate));
309
+ }
310
+ bounds_of(id) {
311
+ if (this.cache.has(id)) return this.cache.get(id) ?? null;
312
+ const r = this.driver.bounds_of(id);
313
+ this.cache.set(id, r);
314
+ return r;
315
+ }
316
+ bounds_of_many(ids) {
317
+ const out = /* @__PURE__ */ new Map();
318
+ const missing = [];
319
+ for (const id of ids) if (this.cache.has(id)) {
320
+ const r = this.cache.get(id);
321
+ if (r) out.set(id, r);
322
+ } else missing.push(id);
323
+ if (missing.length > 0) {
324
+ const fresh = this.driver.bounds_of_many(missing);
325
+ for (const id of missing) {
326
+ const r = fresh.get(id) ?? null;
327
+ this.cache.set(id, r);
328
+ if (r) out.set(id, r);
329
+ }
330
+ }
331
+ return out;
332
+ }
333
+ /**
334
+ * Pass-through. These are less hot than `bounds_of` (called once per
335
+ * gesture frame at most) and their result is sensitive to current
336
+ * viewport state, so caching them would be a footgun.
337
+ */
338
+ nodes_in_rect(rect) {
339
+ return this.driver.nodes_in_rect(rect);
340
+ }
341
+ node_at_point(p) {
342
+ return this.driver.node_at_point(p);
343
+ }
344
+ /** Unsubscribe from both signals. Call on surface detach. */
345
+ dispose() {
346
+ for (const unsub of this.unsubscribers) unsub();
347
+ this.unsubscribers.length = 0;
348
+ this.cache.clear();
349
+ }
350
+ };
351
+ //#endregion
352
+ //#region src/core/hit-shape.ts
353
+ /**
354
+ * Shortest distance from world-space `p` to `shape`. Returns `0` for
355
+ * points strictly inside a filled region (rect / ellipse / polygon
356
+ * interior); the picker treats this as "inside the fill" — an exact
357
+ * hit, no tolerance required.
358
+ *
359
+ * For `path`, this is *edge* distance (no interior test). v1 doesn't
360
+ * model winding-rule fill regions for paths; see docs/wg/feat-svg-editor/hit-test.md.
361
+ */
362
+ function point_distance_to_shape(p, shape) {
363
+ const pv = [p.x, p.y];
364
+ switch (shape.kind) {
365
+ case "rect": {
366
+ const r = {
367
+ x: shape.x,
368
+ y: shape.y,
369
+ width: shape.width,
370
+ height: shape.height
371
+ };
372
+ if (cmath.rect.containsPoint(r, pv)) return 0;
373
+ const [dx, dy] = cmath.rect.offset(r, pv);
374
+ return Math.hypot(dx, dy);
375
+ }
376
+ case "ellipse": {
377
+ if (shape.rx <= 0 || shape.ry <= 0) return Math.hypot(p.x - shape.cx, p.y - shape.cy);
378
+ const nx = (p.x - shape.cx) / shape.rx;
379
+ const ny = (p.y - shape.cy) / shape.ry;
380
+ const r_norm = Math.hypot(nx, ny);
381
+ if (r_norm <= 1) return 0;
382
+ return (r_norm - 1) * Math.min(shape.rx, shape.ry);
383
+ }
384
+ case "segment": return cmath.segment.point_distance(pv, [shape.a.x, shape.a.y], [shape.b.x, shape.b.y]);
385
+ case "polyline": return cmath.polyline.point_distance(pv, shape.pts.map((q) => [q.x, q.y]), false);
386
+ case "polygon": {
387
+ const ring = shape.pts.map((q) => [q.x, q.y]);
388
+ if (ring.length >= 3 && cmath.polygon.pointInPolygon(pv, ring)) return 0;
389
+ return cmath.polyline.point_distance(pv, ring, true);
390
+ }
391
+ case "path": return cmath.polyline.point_distance(pv, shape.pts.map((q) => [q.x, q.y]), shape.closed);
392
+ }
393
+ }
394
+ /**
395
+ * Topmost node within `tolerance_world` of `p`. Returns `null` if no
396
+ * candidate qualifies.
397
+ *
398
+ * Algorithm:
399
+ * 1. Walk `ordered_ids` topmost-first.
400
+ * 2. AABB pre-filter via `cmath.rect.pad(bounds_of(id), tolerance)`.
401
+ * 3. Distance check via `point_distance_to_shape`.
402
+ * 4. First candidate with `dist <= tolerance` wins.
403
+ *
404
+ * Candidates whose `hit_shape_of` returns `null` are skipped. Candidates
405
+ * whose `bounds_of` returns `null` are skipped.
406
+ */
407
+ function pick_at_world(p, opts) {
408
+ const { tolerance_world, ordered_ids, bounds_of, hit_shape_of } = opts;
409
+ const pv = [p.x, p.y];
410
+ for (let i = ordered_ids.length - 1; i >= 0; i--) {
411
+ const id = ordered_ids[i];
412
+ const bounds = bounds_of(id);
413
+ if (!bounds) continue;
414
+ const inflated = cmath.rect.pad(bounds, tolerance_world);
415
+ if (!cmath.rect.containsPoint(inflated, pv)) continue;
416
+ const shape = hit_shape_of(id);
417
+ if (!shape) continue;
418
+ if (point_distance_to_shape(p, shape) <= tolerance_world) return id;
419
+ }
420
+ return null;
421
+ }
422
+ /** Caches `hit_shape_of` results keyed on NodeId; full-clears on either
423
+ * `structure_version` or `geometry_version` bump. Mirror of
424
+ * `MemoizedGeometryProvider` in `core/geometry.ts`. */
425
+ var MemoizedHitShapeProvider = class {
426
+ constructor(driver, signals) {
427
+ this.unsubscribers = [];
428
+ this.cache = /* @__PURE__ */ new Map();
429
+ this.driver = driver;
430
+ const invalidate = () => this.cache.clear();
431
+ this.unsubscribers.push(signals.subscribe_structure(invalidate));
432
+ this.unsubscribers.push(signals.subscribe_geometry(invalidate));
433
+ }
434
+ hit_shape_of(id) {
435
+ if (this.cache.has(id)) return this.cache.get(id) ?? null;
436
+ const s = this.driver.hit_shape_of(id);
437
+ this.cache.set(id, s);
438
+ return s;
439
+ }
440
+ dispose() {
441
+ for (const unsub of this.unsubscribers) unsub();
442
+ this.unsubscribers.length = 0;
443
+ this.cache.clear();
444
+ }
445
+ };
446
+ //#endregion
447
+ //#region src/core/snap/session.ts
448
+ /**
449
+ * Sub-pixel tolerance used as the effective threshold for `"aligned"`
450
+ * policy. cmath's `snap1D` only fires when |delta| ≤ threshold, so
451
+ * setting the threshold near zero means the engine only matches
452
+ * alignments that are already exact (within rounding noise). We can't
453
+ * use a post-hoc `correction == 0` check instead — `snap1D` locks in
454
+ * the FIRST signed delta it finds (lexicographic over the 9-point
455
+ * order), so a touching pair where `agent.left↔anchor.left` needs +10
456
+ * while `agent.right↔anchor.left` needs 0 will return correction = 10,
457
+ * not 0. The tiny-threshold trick lets only the 0-delta matches pass.
458
+ */
459
+ const ALIGNED_THRESHOLD = .5;
460
+ /**
461
+ * Gesture-scoped snap state. Constructor freezes agent + neighbor rects
462
+ * once; `snap()` runs the cmath engine per frame against the frozen
463
+ * inputs. Rects are in whatever space the caller chose (svg-editor uses
464
+ * HUD-container CSS px); the engine is space-agnostic as long as agents,
465
+ * neighbors, delta, and threshold all share that space.
466
+ */
467
+ var SnapSession = class {
468
+ constructor(opts) {
469
+ this._last_guide = void 0;
470
+ const live_agents = opts.agents.filter((r) => r.width > 0 || r.height > 0);
471
+ const live_neighbors = opts.neighbors.filter((r) => r.width > 0 || r.height > 0);
472
+ this.agents = live_agents;
473
+ this.anchors = live_neighbors;
474
+ const xs = [];
475
+ const ys = [];
476
+ for (const a of live_neighbors) {
477
+ xs.push(a.x, a.x + a.width / 2, a.x + a.width);
478
+ ys.push(a.y, a.y + a.height / 2, a.y + a.height);
479
+ }
480
+ this.anchor_xs = xs;
481
+ this.anchor_ys = ys;
482
+ this.baseline_union = live_agents.length > 0 ? cmath.rect.union([...live_agents]) : null;
483
+ }
484
+ /** Read-only snapshot of the most recent guide. Host code consumes
485
+ * this from `compute_snap_extra()` style helpers. */
486
+ get last_guide() {
487
+ return this._last_guide;
488
+ }
489
+ /** Read-only snapshot of the pre-translation agent-union rect.
490
+ * Consumed by the translate pipeline's `stage_pixel_grid` to anchor
491
+ * the integer-grid quantization on the gesture's starting origin
492
+ * (so a rect at `x=0.5` settles to `x=1` after first nudge, not on
493
+ * every fractional drag). Returns `null` when no agents were frozen. */
494
+ get baseline_union_readonly() {
495
+ return this.baseline_union;
496
+ }
497
+ /**
498
+ * Run snap for a candidate cumulative delta.
499
+ *
500
+ * Returns the corrected delta (== input when no snap fires or snap
501
+ * is disabled) and a `SnapGuide` for HUD rendering (`undefined` when
502
+ * no guide should be drawn). Guide emission is governed by `policy`
503
+ * — see `SnapGuidePolicy`.
504
+ *
505
+ * The same guide is also stashed on `last_guide`.
506
+ */
507
+ snap(delta, opts, policy = "engine") {
508
+ const baseline = this.baseline_union;
509
+ if (!opts.enabled || !baseline || !this.agents || !this.anchors || this.anchors.length === 0) {
510
+ this._last_guide = void 0;
511
+ return {
512
+ delta,
513
+ guide: void 0
514
+ };
515
+ }
516
+ const dx = delta.x;
517
+ const dy = delta.y;
518
+ const translated = [];
519
+ for (const r of this.agents) translated.push({
520
+ x: r.x + dx,
521
+ y: r.y + dy,
522
+ width: r.width,
523
+ height: r.height
524
+ });
525
+ const agent_rect = cmath.rect.union(translated);
526
+ const padX = opts.threshold_px + agent_rect.width;
527
+ const padY = opts.threshold_px + agent_rect.height;
528
+ const minX = agent_rect.x - padX;
529
+ const maxX = agent_rect.x + agent_rect.width + padX;
530
+ const minY = agent_rect.y - padY;
531
+ const maxY = agent_rect.y + agent_rect.height + padY;
532
+ const nearby = [];
533
+ for (const r of this.anchors) {
534
+ const rMaxX = r.x + r.width;
535
+ const rMaxY = r.y + r.height;
536
+ const x_overlap = rMaxX >= minX && r.x <= maxX;
537
+ const y_overlap = rMaxY >= minY && r.y <= maxY;
538
+ if (x_overlap || y_overlap) nearby.push(r);
539
+ }
540
+ const result = snapToCanvasGeometry(agent_rect, { objects: nearby }, {
541
+ x: opts.threshold_px,
542
+ y: opts.threshold_px
543
+ });
544
+ const corrected = result.by_objects ? {
545
+ x: result.by_objects.translated.x - baseline.x,
546
+ y: result.by_objects.translated.y - baseline.y
547
+ } : {
548
+ x: dx,
549
+ y: dy
550
+ };
551
+ const guide_source = policy === "aligned" ? snapToCanvasGeometry(agent_rect, { objects: nearby }, {
552
+ x: ALIGNED_THRESHOLD,
553
+ y: ALIGNED_THRESHOLD
554
+ }) : result;
555
+ const sg = guide.plot(guide_source);
556
+ const guide$1 = sg.lines.length > 0 || sg.points.length > 0 || sg.rules.length > 0 ? sg : void 0;
557
+ this._last_guide = guide$1;
558
+ return {
559
+ delta: corrected,
560
+ guide: guide$1
561
+ };
562
+ }
563
+ /**
564
+ * Resize-snap pass.
565
+ *
566
+ * Snaps the *moving edges* of an effective rect (post per-element
567
+ * constraint) against the frozen neighbor anchors. The caller is
568
+ * responsible for computing the effective rect — see
569
+ * `effective_resize` in `../resize-capability.ts`. This entrypoint
570
+ * does NOT understand circle / text uniformity; it only sees the rect
571
+ * and the edge mask.
572
+ *
573
+ * Per-axis snap candidates are the neighbors' left / center-x / right
574
+ * (for x) and top / center-y / bottom (for y), exactly what
575
+ * translate-snap uses. Only the moving edge participates as the agent
576
+ * — the opposite edge is fixed by the resize anchor and must not
577
+ * contribute corrections.
578
+ *
579
+ * Returns signed corrections in the rect's space. Guide is a SnapGuide
580
+ * compatible with `snapGuideToHUDDraw`.
581
+ */
582
+ snap_resize(effective_rect, edges, opts) {
583
+ if (!opts.enabled || !this.anchor_xs || this.anchor_xs.length === 0 || edges.x === null && edges.y === null) {
584
+ this._last_guide = void 0;
585
+ return {
586
+ dx: 0,
587
+ dy: 0,
588
+ guide: void 0
589
+ };
590
+ }
591
+ const agent_x = edges.x === "left" ? effective_rect.x : edges.x === "right" ? effective_rect.x + effective_rect.width : null;
592
+ const agent_y = edges.y === "top" ? effective_rect.y : edges.y === "bottom" ? effective_rect.y + effective_rect.height : null;
593
+ let dx = 0;
594
+ let dy = 0;
595
+ let hit_x_offset = null;
596
+ let hit_y_offset = null;
597
+ if (agent_x !== null) {
598
+ const r = cmath.ext.snap.snap1D([agent_x], this.anchor_xs, opts.threshold_px);
599
+ if (Number.isFinite(r.distance)) {
600
+ dx = r.distance;
601
+ hit_x_offset = agent_x + r.distance;
602
+ }
603
+ }
604
+ if (agent_y !== null && this.anchor_ys) {
605
+ const r = cmath.ext.snap.snap1D([agent_y], this.anchor_ys, opts.threshold_px);
606
+ if (Number.isFinite(r.distance)) {
607
+ dy = r.distance;
608
+ hit_y_offset = agent_y + r.distance;
609
+ }
610
+ }
611
+ let guide = void 0;
612
+ if (hit_x_offset !== null || hit_y_offset !== null) {
613
+ const rules = [];
614
+ if (hit_x_offset !== null) rules.push(["x", hit_x_offset]);
615
+ if (hit_y_offset !== null) rules.push(["y", hit_y_offset]);
616
+ guide = {
617
+ lines: [],
618
+ points: [],
619
+ rules
620
+ };
621
+ }
622
+ this._last_guide = guide;
623
+ return {
624
+ dx,
625
+ dy,
626
+ guide
627
+ };
628
+ }
629
+ /** Release frozen refs. After dispose, `snap()` returns identity and
630
+ * `last_guide` is undefined. */
631
+ dispose() {
632
+ this.agents = null;
633
+ this.anchors = null;
634
+ this.anchor_xs = null;
635
+ this.anchor_ys = null;
636
+ this.baseline_union = null;
637
+ this._last_guide = void 0;
638
+ }
639
+ };
640
+ //#endregion
641
+ //#region src/core/snap/neighborhood.ts
642
+ /** Tags whose subtree is never rendered (resource containers). Children
643
+ * inside these are referenced via `<use>` or `clip-path`, not drawn
644
+ * directly, so they must not surface as snap targets. */
645
+ const NON_RENDERED_CONTAINER_TAGS = new Set([
646
+ "defs",
647
+ "symbol",
648
+ "clipPath",
649
+ "mask",
650
+ "pattern",
651
+ "marker",
652
+ "filter",
653
+ "linearGradient",
654
+ "radialGradient"
655
+ ]);
656
+ /** Self-only render check. Does NOT walk ancestors — callers that descend
657
+ * from a known-rendered root already inherit ancestor-rendered as an
658
+ * invariant. Returns `false` for `display="none"`, `visibility="hidden"`,
659
+ * and for resource-container tags whose contents are never drawn. */
660
+ function is_self_rendered(doc, id) {
661
+ const tag = doc.tag_of(id);
662
+ if (NON_RENDERED_CONTAINER_TAGS.has(tag)) return false;
663
+ if (doc.get_attr(id, "display") === "none") return false;
664
+ if (doc.get_attr(id, "visibility") === "hidden") return false;
665
+ return true;
666
+ }
667
+ /** Recursive descent into a `<g>`. Pushes every rendered structural
668
+ * descendant — including nested `<g>` ids themselves — into `out`.
669
+ * Caller is responsible for adding the root id. */
670
+ function collect_rendered_subtree(doc, parent, out) {
671
+ for (const child of doc.element_children_of(parent)) {
672
+ if (!is_self_rendered(doc, child)) continue;
673
+ if (!STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(child))) continue;
674
+ out.add(child);
675
+ if (doc.tag_of(child) === "g") collect_rendered_subtree(doc, child, out);
676
+ }
677
+ }
678
+ /**
679
+ * Expand a single id into its snap-candidate id set.
680
+ *
681
+ * - Non-`<g>`: returns `[id]`.
682
+ * - `<g>`: returns `[id, ...rendered structural descendants]` — the
683
+ * group's own bbox stays in the set (preserving group-to-group
684
+ * alignment) AND every rendered leaf inside it becomes its own
685
+ * candidate. Nested `<g>` ids are included as bboxes; their
686
+ * children flatten in too.
687
+ *
688
+ * Editor-agnostic w.r.t. rect resolution — this returns ids only.
689
+ * Callers feed the ids to the geometry provider to get rects.
690
+ */
691
+ function snap_descent(doc, id) {
692
+ if (doc.tag_of(id) !== "g") return [id];
693
+ if (!is_self_rendered(doc, id)) return [];
694
+ const out = new Set([id]);
695
+ collect_rendered_subtree(doc, id, out);
696
+ return [...out];
697
+ }
698
+ function compute_neighborhood(doc, dragged) {
699
+ if (dragged.length === 0) return [];
700
+ const excluded = /* @__PURE__ */ new Set();
701
+ for (const id of dragged) {
702
+ excluded.add(id);
703
+ if (doc.tag_of(id) === "g") collect_rendered_subtree(doc, id, excluded);
704
+ }
705
+ const out = /* @__PURE__ */ new Set();
706
+ for (const id of dragged) {
707
+ const parent = doc.parent_of(id);
708
+ if (parent === null) continue;
709
+ if (!excluded.has(parent) && STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
710
+ for (const sib of doc.element_children_of(parent)) {
711
+ if (excluded.has(sib)) continue;
712
+ if (!STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(sib))) continue;
713
+ if (!is_self_rendered(doc, sib)) continue;
714
+ for (const inner of snap_descent(doc, sib)) {
715
+ if (excluded.has(inner)) continue;
716
+ out.add(inner);
717
+ }
718
+ }
719
+ }
720
+ return [...out];
721
+ }
722
+ //#endregion
723
+ //#region src/core/snap/options.ts
724
+ const DEFAULT_SNAP_OPTIONS = {
725
+ enabled: true,
726
+ threshold_px: 6
727
+ };
728
+ //#endregion
729
+ //#region src/core/resize-pipeline/pipeline.ts
730
+ /** The funnel. Threads `plan` through `stages` in order; aggregates guide
731
+ * emissions. Pure: same inputs → same outputs. */
732
+ function run_resize_pipeline(init, stages, ctx) {
733
+ let plan = init;
734
+ const guides = [];
735
+ for (const stage of stages) {
736
+ const out = stage.run(plan, ctx);
737
+ plan = out.plan;
738
+ if (out.emit?.guide) guides.push(out.emit.guide);
739
+ }
740
+ return {
741
+ plan,
742
+ guides
743
+ };
744
+ }
745
+ //#endregion
746
+ //#region src/core/resize-capability.ts
747
+ function direction_mask(dir) {
748
+ const has_n = dir === "n" || dir === "ne" || dir === "nw";
749
+ const has_s = dir === "s" || dir === "se" || dir === "sw";
750
+ const has_e = dir === "e" || dir === "ne" || dir === "se";
751
+ const has_w = dir === "w" || dir === "nw" || dir === "sw";
752
+ return {
753
+ affects_x: has_e || has_w,
754
+ affects_y: has_n || has_s,
755
+ x_edge: has_e ? "right" : has_w ? "left" : null,
756
+ y_edge: has_n ? "top" : has_s ? "bottom" : null
757
+ };
758
+ }
759
+ /** Is this direction one of the four corners (vs. an edge handle)? */
760
+ function is_corner_direction(dir) {
761
+ return dir === "nw" || dir === "ne" || dir === "se" || dir === "sw";
762
+ }
763
+ /**
764
+ * Apply per-element resize constraints to a gesture's proposed `(sx, sy)`.
765
+ *
766
+ * The constraint mirrors `apply_resize` exactly so that the *effective
767
+ * rect* the caller computes from `(sx, sy)` matches what attribute writes
768
+ * actually produce. This is the keystone of resize snap: snapping on the
769
+ * gesture-proposed rect would lie about where the geometry lands when an
770
+ * element-type constraint kicks in.
771
+ *
772
+ * Constraints:
773
+ * - `rect` / `image` / `use` / `ellipse` / `line` / `polyline` /
774
+ * `polygon` / `path`: free per-axis. Identity.
775
+ * - `circle`: uniform. `s = min(sx, sy)`.
776
+ * - `text`: uniform on corner drags; no-op on edge drags. Mirrors the
777
+ * `isCorner = sx !== 1 && sy !== 1` check in `apply_resize`.
778
+ * - `unsupported`: no-op.
779
+ */
780
+ function resize_constraint(baseline, dir, sx_gesture, sy_gesture) {
781
+ switch (baseline.attrs.kind) {
782
+ case "rect":
783
+ case "image":
784
+ case "use":
785
+ case "ellipse":
786
+ case "line":
787
+ case "polyline":
788
+ case "polygon":
789
+ case "path": return {
790
+ sx: sx_gesture,
791
+ sy: sy_gesture,
792
+ no_op: false,
793
+ uniform: false
794
+ };
795
+ case "circle": {
796
+ const s = Math.min(sx_gesture, sy_gesture);
797
+ return {
798
+ sx: s,
799
+ sy: s,
800
+ no_op: false,
801
+ uniform: true
802
+ };
803
+ }
804
+ case "text": {
805
+ if (!is_corner_direction(dir)) return {
806
+ sx: 1,
807
+ sy: 1,
808
+ no_op: true,
809
+ uniform: true
810
+ };
811
+ const s = Math.min(sx_gesture, sy_gesture);
812
+ return {
813
+ sx: s,
814
+ sy: s,
815
+ no_op: false,
816
+ uniform: true
817
+ };
818
+ }
819
+ case "unsupported": return {
820
+ sx: 1,
821
+ sy: 1,
822
+ no_op: true,
823
+ uniform: false
824
+ };
825
+ }
826
+ }
827
+ /** Position of a bbox corner / edge midpoint by direction. */
828
+ function corner_of_rect(r, dir) {
829
+ switch (dir) {
830
+ case "nw": return {
831
+ x: r.x,
832
+ y: r.y
833
+ };
834
+ case "n": return {
835
+ x: r.x + r.width / 2,
836
+ y: r.y
837
+ };
838
+ case "ne": return {
839
+ x: r.x + r.width,
840
+ y: r.y
841
+ };
842
+ case "e": return {
843
+ x: r.x + r.width,
844
+ y: r.y + r.height / 2
845
+ };
846
+ case "se": return {
847
+ x: r.x + r.width,
848
+ y: r.y + r.height
849
+ };
850
+ case "s": return {
851
+ x: r.x + r.width / 2,
852
+ y: r.y + r.height
853
+ };
854
+ case "sw": return {
855
+ x: r.x,
856
+ y: r.y + r.height
857
+ };
858
+ case "w": return {
859
+ x: r.x,
860
+ y: r.y + r.height / 2
861
+ };
862
+ }
863
+ }
864
+ /** The fixed-origin corner for a drag (opposite the moving corner). */
865
+ function origin_of_direction(r, dir) {
866
+ switch (dir) {
867
+ case "nw": return {
868
+ x: r.x + r.width,
869
+ y: r.y + r.height
870
+ };
871
+ case "n": return {
872
+ x: r.x + r.width / 2,
873
+ y: r.y + r.height
874
+ };
875
+ case "ne": return {
876
+ x: r.x,
877
+ y: r.y + r.height
878
+ };
879
+ case "e": return {
880
+ x: r.x,
881
+ y: r.y + r.height / 2
882
+ };
883
+ case "se": return {
884
+ x: r.x,
885
+ y: r.y
886
+ };
887
+ case "s": return {
888
+ x: r.x + r.width / 2,
889
+ y: r.y
890
+ };
891
+ case "sw": return {
892
+ x: r.x + r.width,
893
+ y: r.y
894
+ };
895
+ case "w": return {
896
+ x: r.x + r.width,
897
+ y: r.y + r.height / 2
898
+ };
899
+ }
900
+ }
901
+ /**
902
+ * Compute the effective rect that `apply_resize` would write for the
903
+ * given gesture. This is what snap operates on.
904
+ *
905
+ * The rect is computed by scaling `baseline.bbox` around `origin` by the
906
+ * constrained `(sx, sy)`. For free elements this matches the gesture
907
+ * exactly; for circle / text-on-corner it collapses to a uniform-scale
908
+ * rect; for text-on-edge it returns the baseline rect unchanged.
909
+ */
910
+ function effective_resize(baseline, dir, sx_gesture, sy_gesture) {
911
+ const bbox = baseline.bbox;
912
+ const origin = origin_of_direction(bbox, dir);
913
+ const c = resize_constraint(baseline, dir, sx_gesture, sy_gesture);
914
+ const mask = direction_mask(dir);
915
+ const rect = {
916
+ x: origin.x + (bbox.x - origin.x) * c.sx,
917
+ y: origin.y + (bbox.y - origin.y) * c.sy,
918
+ width: bbox.width * c.sx,
919
+ height: bbox.height * c.sy
920
+ };
921
+ const moving_corner = corner_of_rect(rect, dir);
922
+ return {
923
+ rect,
924
+ sx: c.sx,
925
+ sy: c.sy,
926
+ moving_corner,
927
+ origin,
928
+ no_op: c.no_op,
929
+ uniform: c.uniform,
930
+ mask
931
+ };
932
+ }
933
+ //#endregion
934
+ //#region src/core/resize-pipeline/stages.ts
935
+ function pipeline_baseline(plan) {
936
+ return plan.baseline;
937
+ }
938
+ /** Collapse `(dx, dy)` to a uniform scale when Shift-drag is active.
939
+ * Element-driven uniform (circle / text-on-corner) is enforced inside
940
+ * `resize_constraint`, not here — this stage is *only* the user-visible
941
+ * modifier. No-op on edge handles (uniform across one axis isn't a
942
+ * meaningful constraint). */
943
+ const stage_aspect_lock = {
944
+ name: "aspect_lock",
945
+ run(plan, ctx) {
946
+ if (ctx.modifiers.aspect_lock !== "uniform") return { plan };
947
+ if (!is_corner_direction(plan.direction)) return { plan };
948
+ const pbase = pipeline_baseline(plan);
949
+ const locked = compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, true);
950
+ const bbox = pbase.bbox;
951
+ const Hx_base = (() => {
952
+ switch (plan.direction) {
953
+ case "nw":
954
+ case "w":
955
+ case "sw": return bbox.x;
956
+ case "ne":
957
+ case "e":
958
+ case "se": return bbox.x + bbox.width;
959
+ case "n":
960
+ case "s": return bbox.x + bbox.width / 2;
961
+ }
962
+ })();
963
+ const Hy_base = (() => {
964
+ switch (plan.direction) {
965
+ case "nw":
966
+ case "n":
967
+ case "ne": return bbox.y;
968
+ case "sw":
969
+ case "s":
970
+ case "se": return bbox.y + bbox.height;
971
+ case "e":
972
+ case "w": return bbox.y + bbox.height / 2;
973
+ }
974
+ })();
975
+ const new_Hx = locked.origin.x + (Hx_base - locked.origin.x) * locked.sx;
976
+ const new_Hy = locked.origin.y + (Hy_base - locked.origin.y) * locked.sy;
977
+ return { plan: {
978
+ ...plan,
979
+ dx: new_Hx - Hx_base,
980
+ dy: new_Hy - Hy_base
981
+ } };
982
+ }
983
+ };
984
+ /** Consults `ctx.snap_session` for moving-edge alignment correction.
985
+ * Identity on `force_disable_snap`, missing session, or
986
+ * `snap_enabled === false`. */
987
+ const stage_snap = {
988
+ name: "snap",
989
+ run(plan, ctx) {
990
+ if (ctx.modifiers.force_disable_snap) return { plan };
991
+ if (!ctx.snap_session) return { plan };
992
+ if (!ctx.options.snap_enabled) return { plan };
993
+ const pbase = pipeline_baseline(plan);
994
+ const f = compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
995
+ const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
996
+ if (eff.no_op) return { plan };
997
+ const r = ctx.snap_session.snap_resize(eff.rect, {
998
+ x: eff.mask.x_edge,
999
+ y: eff.mask.y_edge
1000
+ }, {
1001
+ enabled: true,
1002
+ threshold_px: ctx.options.snap_threshold_px
1003
+ });
1004
+ if (r.dx === 0 && r.dy === 0) return {
1005
+ plan,
1006
+ emit: r.guide ? { guide: r.guide } : void 0
1007
+ };
1008
+ if (eff.uniform) {
1009
+ const bbox = pbase.bbox;
1010
+ const new_Hx = eff.moving_corner.x + r.dx;
1011
+ const new_Hy = eff.moving_corner.y + r.dy;
1012
+ const sx_from_x = eff.mask.x_edge !== null && r.dx !== 0 && bbox.width !== 0 ? (new_Hx - eff.origin.x) / (eff.moving_corner.x - eff.origin.x) * eff.sx : null;
1013
+ const sy_from_y = eff.mask.y_edge !== null && r.dy !== 0 && bbox.height !== 0 ? (new_Hy - eff.origin.y) / (eff.moving_corner.y - eff.origin.y) * eff.sy : null;
1014
+ let s = eff.sx;
1015
+ if (sx_from_x !== null && sy_from_y !== null) s = Math.min(sx_from_x, sy_from_y);
1016
+ else if (sx_from_x !== null) s = sx_from_x;
1017
+ else if (sy_from_y !== null) s = sy_from_y;
1018
+ const Hx_base = corner_x_of(bbox, plan.direction);
1019
+ const Hy_base = corner_y_of(bbox, plan.direction);
1020
+ const target_Hx = eff.origin.x + (Hx_base - eff.origin.x) * s;
1021
+ const target_Hy = eff.origin.y + (Hy_base - eff.origin.y) * s;
1022
+ return {
1023
+ plan: {
1024
+ ...plan,
1025
+ dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
1026
+ dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
1027
+ },
1028
+ emit: r.guide ? { guide: r.guide } : void 0
1029
+ };
1030
+ }
1031
+ return {
1032
+ plan: {
1033
+ ...plan,
1034
+ dx: eff.mask.affects_x ? plan.dx + r.dx : plan.dx,
1035
+ dy: eff.mask.affects_y ? plan.dy + r.dy : plan.dy
1036
+ },
1037
+ emit: r.guide ? { guide: r.guide } : void 0
1038
+ };
1039
+ }
1040
+ };
1041
+ /** Quantize the moving corner's *post-resize* position to integer
1042
+ * multiples of `pixel_grid_quantum` in own-frame space. Identity when
1043
+ * quantum is `null` / `<= 0` or the gesture is a no-op. */
1044
+ const stage_pixel_grid = {
1045
+ name: "pixel_grid",
1046
+ run(plan, ctx) {
1047
+ const q = ctx.options.pixel_grid_quantum;
1048
+ if (q === null || q <= 0) return { plan };
1049
+ const pbase = pipeline_baseline(plan);
1050
+ const f = compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
1051
+ const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
1052
+ if (eff.no_op) return { plan };
1053
+ const target_Hx = eff.mask.affects_x ? Math.round(eff.moving_corner.x / q) * q : eff.moving_corner.x;
1054
+ const target_Hy = eff.mask.affects_y ? Math.round(eff.moving_corner.y / q) * q : eff.moving_corner.y;
1055
+ const bbox = pbase.bbox;
1056
+ const Hx_base = corner_x_of(bbox, plan.direction);
1057
+ const Hy_base = corner_y_of(bbox, plan.direction);
1058
+ return { plan: {
1059
+ ...plan,
1060
+ dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
1061
+ dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
1062
+ } };
1063
+ }
1064
+ };
1065
+ function corner_x_of(r, dir) {
1066
+ switch (dir) {
1067
+ case "nw":
1068
+ case "w":
1069
+ case "sw": return r.x;
1070
+ case "ne":
1071
+ case "e":
1072
+ case "se": return r.x + r.width;
1073
+ case "n":
1074
+ case "s": return r.x + r.width / 2;
1075
+ }
1076
+ }
1077
+ function corner_y_of(r, dir) {
1078
+ switch (dir) {
1079
+ case "nw":
1080
+ case "n":
1081
+ case "ne": return r.y;
1082
+ case "sw":
1083
+ case "s":
1084
+ case "se": return r.y + r.height;
1085
+ case "e":
1086
+ case "w": return r.y + r.height / 2;
1087
+ }
1088
+ }
1089
+ /** Default stage list for HUD-driven resize gestures (drag).
1090
+ * No NUDGE / RPC analogs at v1 (no `commands.resize` per README). */
1091
+ const STAGES_DEFAULT = Object.freeze([
1092
+ stage_aspect_lock,
1093
+ stage_snap,
1094
+ stage_pixel_grid
1095
+ ]);
1096
+ //#endregion
1097
+ //#region src/core/resize-pipeline/apply.ts
1098
+ function applyResizePlan(doc, plan, phase = "commit") {
1099
+ const f = compute_resize_factors(plan.baseline, plan.direction, plan.dx, plan.dy, false);
1100
+ const members = plan.members ?? [{
1101
+ id: plan.id,
1102
+ baseline: plan.baseline
1103
+ }];
1104
+ for (const m of members) apply_resize(doc, m.id, m.baseline, f.sx, f.sy, f.origin, phase);
1105
+ }
1106
+ function revertResizePlan(doc, plan) {
1107
+ const f = compute_resize_factors(plan.baseline, plan.direction, 0, 0, false);
1108
+ const members = plan.members ?? [{
1109
+ id: plan.id,
1110
+ baseline: plan.baseline
1111
+ }];
1112
+ for (const m of members) apply_resize(doc, m.id, m.baseline, 1, 1, f.origin, "preview");
1113
+ }
1114
+ /**
1115
+ * Synthesize a "group" baseline over an arbitrary union rect. The attrs
1116
+ * carrier is `rect`-kind so the pipeline math (snap / pixel-grid)
1117
+ * treats the group as free per-axis — per-member constraints (circle
1118
+ * uniform, text edge no-op) kick in at apply time against each
1119
+ * member's own captured baseline.
1120
+ *
1121
+ * For single-member groups callers should pass the member's own
1122
+ * baseline directly so the per-element snap correction (`eff.uniform`
1123
+ * branch) fires correctly.
1124
+ */
1125
+ function synthesize_group_baseline(union) {
1126
+ return {
1127
+ bbox: {
1128
+ x: union.x,
1129
+ y: union.y,
1130
+ width: union.width,
1131
+ height: union.height
1132
+ },
1133
+ attrs: {
1134
+ kind: "rect",
1135
+ x: union.x,
1136
+ y: union.y,
1137
+ w: union.width,
1138
+ h: union.height
1139
+ }
1140
+ };
1141
+ }
1142
+ //#endregion
1143
+ //#region src/core/resize-pipeline/orchestrator.ts
1144
+ const PROVIDER_ID = "svg-editor";
1145
+ /** West/north-anchor flips invert the corresponding world delta so a
1146
+ * positive value always grows the moving edge outward — the convention
1147
+ * `compute_resize_factors` consumes. */
1148
+ function sign_adjust(dir, dx_world, dy_world) {
1149
+ return {
1150
+ dx: dir === "w" || dir === "nw" || dir === "sw" ? -dx_world : dx_world,
1151
+ dy: dir === "n" || dir === "ne" || dir === "nw" ? -dy_world : dy_world
1152
+ };
1153
+ }
1154
+ /** Stable, order-independent key for an id set — used by `is_active_for`
1155
+ * to decide whether the current session targets the same group. */
1156
+ function ids_key(ids) {
1157
+ return [...ids].sort().join("\0");
1158
+ }
1159
+ var ResizeOrchestrator = class {
1160
+ constructor(deps) {
1161
+ this.deps = deps;
1162
+ this.active = null;
1163
+ this._last_guides = [];
1164
+ }
1165
+ /** Guides emitted by the most recent pipeline run. Cleared on
1166
+ * cancel/dispose. */
1167
+ get last_guides() {
1168
+ return this._last_guides;
1169
+ }
1170
+ has_active_session() {
1171
+ return this.active !== null;
1172
+ }
1173
+ /** Is the gesture currently targeting `ids` with `direction`? Used by
1174
+ * the HUD dispatch to decide whether to reset the session on a new
1175
+ * handle / target. Order-independent. */
1176
+ is_active_for(ids, direction) {
1177
+ return this.active !== null && this.active.direction === direction && this.active.ids_key === ids_key(ids);
1178
+ }
1179
+ /** Per-frame drive. Opens a session lazily on the first call. The
1180
+ * HUD passes its gesture-target rect dimensions in **world space**;
1181
+ * the orchestrator derives the signed world-frame delta against its
1182
+ * captured `baseline.bbox`. The DOM adapter is responsible for the
1183
+ * CSS-px → world conversion at the intent boundary. */
1184
+ drive(input, modifiers, opts) {
1185
+ if (input.ids.length === 0) return null;
1186
+ const doc = this.deps.get_doc();
1187
+ for (const id of input.ids) if (!is_resizable_node(doc, id)) return null;
1188
+ const key = ids_key(input.ids);
1189
+ if (this.active && (this.active.ids_key !== key || this.active.direction !== input.direction)) {
1190
+ this.active.preview.discard();
1191
+ this.dispose_session();
1192
+ }
1193
+ if (this.active === null) this.active = this.open(input.ids, input.direction, opts.snap, opts.label ?? "resize");
1194
+ const session = this.active;
1195
+ const bbox = session.baseline.bbox;
1196
+ const dx_world = input.target_width - bbox.width;
1197
+ const dy_world = input.target_height - bbox.height;
1198
+ const d = sign_adjust(input.direction, dx_world, dy_world);
1199
+ const stages = opts.stages ?? STAGES_DEFAULT;
1200
+ const result = this.run_pass(session, d.dx, d.dy, modifiers, stages);
1201
+ session.last_dx = d.dx;
1202
+ session.last_dy = d.dy;
1203
+ session.last_stages = stages;
1204
+ this.write_preview(session, result.plan, opts.phase);
1205
+ if (opts.phase === "commit") {
1206
+ session.preview.commit();
1207
+ this.dispose_session();
1208
+ }
1209
+ return result;
1210
+ }
1211
+ /** Re-run the current preview frame with new modifiers. */
1212
+ redrive_modifiers(modifiers) {
1213
+ if (!this.active) return null;
1214
+ const session = this.active;
1215
+ const result = this.run_pass(session, session.last_dx, session.last_dy, modifiers, session.last_stages);
1216
+ this.write_preview(session, result.plan, "preview");
1217
+ return result;
1218
+ }
1219
+ cancel() {
1220
+ if (!this.active) return;
1221
+ this.active.preview.discard();
1222
+ this.dispose_session();
1223
+ }
1224
+ run_pass(session, dx, dy, modifiers, stages) {
1225
+ const result = run_resize_pipeline({
1226
+ id: session.primary_id,
1227
+ baseline: session.baseline,
1228
+ members: session.members,
1229
+ direction: session.direction,
1230
+ dx,
1231
+ dy
1232
+ }, stages, {
1233
+ input: {
1234
+ id: session.primary_id,
1235
+ direction: session.direction,
1236
+ dx,
1237
+ dy
1238
+ },
1239
+ modifiers,
1240
+ options: this.deps.options(),
1241
+ snap_session: session.snap
1242
+ });
1243
+ this._last_guides = result.guides;
1244
+ return result;
1245
+ }
1246
+ open(ids, direction, snap, label) {
1247
+ const doc = this.deps.get_doc();
1248
+ const members = ids.map((id) => ({
1249
+ id,
1250
+ baseline: capture_resize_baseline(doc, id, this.deps.bbox_world(id))
1251
+ }));
1252
+ const baseline = members.length === 1 ? members[0].baseline : synthesize_group_baseline(cmath.rect.union(members.map((m) => m.baseline.bbox)));
1253
+ return {
1254
+ ids_key: ids_key(ids),
1255
+ primary_id: members[0].id,
1256
+ direction,
1257
+ members,
1258
+ baseline,
1259
+ snap: snap ? this.deps.open_snap(ids) : null,
1260
+ preview: this.deps.open_preview(label),
1261
+ last_dx: 0,
1262
+ last_dy: 0,
1263
+ last_stages: STAGES_DEFAULT
1264
+ };
1265
+ }
1266
+ write_preview(session, plan, phase) {
1267
+ const doc = this.deps.get_doc();
1268
+ const emit = this.deps.emit;
1269
+ session.preview.set({
1270
+ providerId: PROVIDER_ID,
1271
+ apply: () => {
1272
+ applyResizePlan(doc, plan, phase);
1273
+ emit();
1274
+ },
1275
+ revert: () => {
1276
+ revertResizePlan(doc, plan);
1277
+ emit();
1278
+ }
1279
+ });
1280
+ }
1281
+ dispose_session() {
1282
+ if (!this.active) return;
1283
+ this.active.snap?.dispose();
1284
+ this.active = null;
1285
+ this._last_guides = [];
1286
+ }
1287
+ };
1288
+ //#endregion
1289
+ //#region src/gestures/gestures.ts
1290
+ /**
1291
+ * Sibling to `Keymap`. Owns a list of installed gesture bindings; each
1292
+ * binding's `install(ctx)` is called eagerly when bound and uninstalled
1293
+ * on `unbind` or surface detach.
1294
+ */
1295
+ var Gestures = class {
1296
+ constructor(ctx) {
1297
+ this.ctx = ctx;
1298
+ this.entries = [];
1299
+ }
1300
+ /**
1301
+ * Install a gesture binding. Returns an unbind function.
1302
+ * Re-binding the same `id` does NOT replace — both will be active.
1303
+ * Use `unbind({ id })` first if you want a clean swap.
1304
+ */
1305
+ bind(binding) {
1306
+ const uninstall = binding.install(this.ctx);
1307
+ const entry = {
1308
+ binding,
1309
+ uninstall
1310
+ };
1311
+ this.entries.push(entry);
1312
+ return () => {
1313
+ const i = this.entries.indexOf(entry);
1314
+ if (i < 0) return;
1315
+ this.entries.splice(i, 1);
1316
+ uninstall();
1317
+ };
1318
+ }
1319
+ /**
1320
+ * Remove bindings matching the spec. With `{ id }`, all bindings with
1321
+ * that id are uninstalled. With no spec, this is a no-op (use
1322
+ * `dispose()` to nuke everything).
1323
+ */
1324
+ unbind(spec) {
1325
+ if (spec.id === void 0) return;
1326
+ const remaining = [];
1327
+ for (const entry of this.entries) if (entry.binding.id === spec.id) entry.uninstall();
1328
+ else remaining.push(entry);
1329
+ this.entries = remaining;
1330
+ }
1331
+ /** All currently installed bindings. Order is registration order. */
1332
+ bindings() {
1333
+ return this.entries.map((e) => e.binding);
1334
+ }
1335
+ /** @internal Uninstall every binding. Surface calls on detach. */
1336
+ _dispose() {
1337
+ for (const entry of this.entries) entry.uninstall();
1338
+ this.entries = [];
1339
+ }
1340
+ };
1341
+ //#endregion
1342
+ //#region src/gestures/defaults.ts
1343
+ /** Default margin for `camera.fit` from keyboard shortcuts. */
1344
+ const KEYBOARD_FIT_MARGIN = 64;
1345
+ /** Default zoom step for `Cmd/Ctrl+=` / `Cmd/Ctrl+-`. */
1346
+ const ZOOM_STEP = 1.2;
1347
+ /** Per-wheel-unit zoom sensitivity for Cmd/Ctrl+wheel + pinch. */
1348
+ const WHEEL_ZOOM_SENSITIVITY = .01;
1349
+ /** Min/max zoom clamps. Generous; hosts that want tighter limits can
1350
+ * unbind these defaults and bind their own. */
1351
+ const MIN_ZOOM = .02;
1352
+ const MAX_ZOOM = 256;
1353
+ function clamp_zoom(z) {
1354
+ return cmath.clamp(z, MIN_ZOOM, MAX_ZOOM);
1355
+ }
1356
+ /** wheel-pan-zoom: plain wheel = pan, Cmd/Ctrl+wheel + pinch = zoom-at-cursor. */
1357
+ const WHEEL_PAN_ZOOM = {
1358
+ id: "wheel-pan-zoom",
1359
+ install({ container, camera }) {
1360
+ const on_wheel = (e) => {
1361
+ e.preventDefault();
1362
+ if (e.ctrlKey || e.metaKey) {
1363
+ const factor = 1 - e.deltaY * WHEEL_ZOOM_SENSITIVITY;
1364
+ const eff = clamp_zoom(camera.zoom * factor) / camera.zoom;
1365
+ if (eff === 1) return;
1366
+ const rect = container.getBoundingClientRect();
1367
+ camera.zoom_at(eff, {
1368
+ x: e.clientX - rect.left,
1369
+ y: e.clientY - rect.top
1370
+ });
1371
+ } else camera.pan({
1372
+ x: -e.deltaX,
1373
+ y: -e.deltaY
1374
+ });
1375
+ };
1376
+ container.addEventListener("wheel", on_wheel, { passive: false });
1377
+ return () => container.removeEventListener("wheel", on_wheel);
1378
+ }
1379
+ };
1380
+ /**
1381
+ * Begin a drag-pan from a pointerdown. Attaches `pointermove` / `pointerup`
1382
+ * listeners scoped to the gesture lifetime, then detaches them on release.
1383
+ * This is the d3-drag pattern: global listeners only exist while a drag is
1384
+ * in flight, not for the surface's whole lifetime.
1385
+ */
1386
+ function begin_drag_pan(e, container, camera, on_release) {
1387
+ let last_x = e.clientX;
1388
+ let last_y = e.clientY;
1389
+ try {
1390
+ container.setPointerCapture(e.pointerId);
1391
+ } catch {}
1392
+ e.preventDefault();
1393
+ e.stopPropagation();
1394
+ const win = container.ownerDocument.defaultView ?? window;
1395
+ const on_pointermove = (ev) => {
1396
+ const dx = ev.clientX - last_x;
1397
+ const dy = ev.clientY - last_y;
1398
+ last_x = ev.clientX;
1399
+ last_y = ev.clientY;
1400
+ camera.pan({
1401
+ x: dx,
1402
+ y: dy
1403
+ });
1404
+ ev.preventDefault();
1405
+ ev.stopPropagation();
1406
+ };
1407
+ const cleanup = () => {
1408
+ win.removeEventListener("pointermove", on_pointermove, true);
1409
+ win.removeEventListener("pointerup", on_pointerup, true);
1410
+ win.removeEventListener("pointercancel", on_pointerup, true);
1411
+ on_release?.();
1412
+ };
1413
+ const on_pointerup = () => cleanup();
1414
+ win.addEventListener("pointermove", on_pointermove, true);
1415
+ win.addEventListener("pointerup", on_pointerup, true);
1416
+ win.addEventListener("pointercancel", on_pointerup, true);
1417
+ }
1418
+ /** The data-driven default set. Order = install order. */
1419
+ const DEFAULT_GESTURE_BINDINGS = [
1420
+ WHEEL_PAN_ZOOM,
1421
+ {
1422
+ id: "space-drag-pan",
1423
+ install({ container, camera }) {
1424
+ let space_held = false;
1425
+ let prev_cursor = null;
1426
+ const set_cursor = (next) => {
1427
+ if (prev_cursor === null) prev_cursor = container.style.cursor;
1428
+ container.style.cursor = next ?? prev_cursor ?? "";
1429
+ if (next === null) prev_cursor = null;
1430
+ };
1431
+ const on_keydown = (e) => {
1432
+ if (e.code !== "Space" || e.repeat) return;
1433
+ if (is_text_input_focused()) return;
1434
+ space_held = true;
1435
+ set_cursor("grab");
1436
+ e.preventDefault();
1437
+ };
1438
+ const on_keyup = (e) => {
1439
+ if (e.code !== "Space") return;
1440
+ space_held = false;
1441
+ set_cursor(null);
1442
+ };
1443
+ const on_pointerdown = (e) => {
1444
+ if (!space_held || e.button !== 0) return;
1445
+ set_cursor("grabbing");
1446
+ begin_drag_pan(e, container, camera, () => set_cursor(space_held ? "grab" : null));
1447
+ };
1448
+ const on_blur = () => {
1449
+ space_held = false;
1450
+ set_cursor(null);
1451
+ };
1452
+ const win = container.ownerDocument.defaultView ?? window;
1453
+ win.addEventListener("keydown", on_keydown);
1454
+ win.addEventListener("keyup", on_keyup);
1455
+ container.addEventListener("pointerdown", on_pointerdown, true);
1456
+ win.addEventListener("blur", on_blur);
1457
+ return () => {
1458
+ win.removeEventListener("keydown", on_keydown);
1459
+ win.removeEventListener("keyup", on_keyup);
1460
+ container.removeEventListener("pointerdown", on_pointerdown, true);
1461
+ win.removeEventListener("blur", on_blur);
1462
+ if (prev_cursor !== null) container.style.cursor = prev_cursor;
1463
+ };
1464
+ }
1465
+ },
1466
+ {
1467
+ id: "middle-mouse-pan",
1468
+ install({ container, camera }) {
1469
+ const on_pointerdown = (e) => {
1470
+ if (e.button !== 1) return;
1471
+ begin_drag_pan(e, container, camera);
1472
+ };
1473
+ const on_auxclick = (e) => {
1474
+ if (e.button === 1) e.preventDefault();
1475
+ };
1476
+ container.addEventListener("pointerdown", on_pointerdown, true);
1477
+ container.addEventListener("auxclick", on_auxclick);
1478
+ return () => {
1479
+ container.removeEventListener("pointerdown", on_pointerdown, true);
1480
+ container.removeEventListener("auxclick", on_auxclick);
1481
+ };
1482
+ }
1483
+ },
1484
+ {
1485
+ id: "keyboard-zoom",
1486
+ install({ container, camera }) {
1487
+ const owner_doc = container.ownerDocument;
1488
+ const on_keydown = (e) => {
1489
+ const active = owner_doc.activeElement;
1490
+ if (active && active !== owner_doc.body && !container.contains(active)) return;
1491
+ if (is_text_input_focused()) return;
1492
+ const mod = e.metaKey || e.ctrlKey;
1493
+ if (e.shiftKey && !mod && (e.code === "Digit0" || e.code === "Numpad0")) {
1494
+ camera.reset();
1495
+ e.preventDefault();
1496
+ } else if (e.shiftKey && !mod && (e.code === "Digit1" || e.code === "Digit9" || e.code === "Numpad1" || e.code === "Numpad9")) {
1497
+ camera.fit("<root>", { margin: KEYBOARD_FIT_MARGIN });
1498
+ e.preventDefault();
1499
+ } else if (e.shiftKey && !mod && (e.code === "Digit2" || e.code === "Numpad2")) {
1500
+ camera.fit("<selection>", { margin: KEYBOARD_FIT_MARGIN });
1501
+ e.preventDefault();
1502
+ } else if (mod && (e.code === "Equal" || e.code === "NumpadAdd")) {
1503
+ camera.set_zoom(clamp_zoom(camera.zoom * ZOOM_STEP));
1504
+ e.preventDefault();
1505
+ } else if (mod && (e.code === "Minus" || e.code === "NumpadSubtract")) {
1506
+ camera.set_zoom(clamp_zoom(camera.zoom / ZOOM_STEP));
1507
+ e.preventDefault();
1508
+ }
1509
+ };
1510
+ owner_doc.addEventListener("keydown", on_keydown);
1511
+ return () => owner_doc.removeEventListener("keydown", on_keydown);
1512
+ }
1513
+ }
1514
+ ];
1515
+ /** Install every default binding into the gesture layer. */
1516
+ function applyDefaultGestures(gestures) {
1517
+ for (const b of DEFAULT_GESTURE_BINDINGS) gestures.bind(b);
1518
+ }
1519
+ //#endregion
1520
+ //#region src/text-surface.ts
1521
+ const SVG_NS = "http://www.w3.org/2000/svg";
1522
+ const XML_NS = "http://www.w3.org/XML/1998/namespace";
1523
+ var SvgTextSurface = class {
1524
+ constructor(textEl) {
1525
+ this.prevXmlSpace = void 0;
1526
+ this.prevPointerEvents = void 0;
1527
+ this.last_caret_idx = -1;
1528
+ this.last_caret_visible = false;
1529
+ this.last_sel_start = -1;
1530
+ this.last_sel_end = -1;
1531
+ this.textEl = textEl;
1532
+ const ownerDoc = textEl.ownerDocument;
1533
+ let mountAnchor = textEl;
1534
+ while (mountAnchor.parentElement instanceof SVGElement && (mountAnchor.localName === "tspan" || mountAnchor.localName === "textPath")) mountAnchor = mountAnchor.parentElement;
1535
+ const parent = mountAnchor.parentNode;
1536
+ if (!parent) throw new Error("text element has no parent");
1537
+ const computedWhitespace = ownerDoc.defaultView?.getComputedStyle(textEl).whiteSpace;
1538
+ if (!(computedWhitespace === "pre" || computedWhitespace === "pre-wrap" || computedWhitespace === "break-spaces")) {
1539
+ this.prevXmlSpace = textEl.getAttributeNS(XML_NS, "space");
1540
+ textEl.setAttributeNS(XML_NS, "xml:space", "preserve");
1541
+ }
1542
+ this.prevPointerEvents = textEl.getAttribute("pointer-events");
1543
+ textEl.setAttribute("pointer-events", "bounding-box");
1544
+ const selection = ownerDoc.createElementNS(SVG_NS, "rect");
1545
+ selection.setAttribute("fill", "#2563eb");
1546
+ selection.setAttribute("fill-opacity", "0.25");
1547
+ selection.setAttribute("pointer-events", "none");
1548
+ selection.setAttribute("data-svg-text-edit-selection", "");
1549
+ selection.style.display = "none";
1550
+ parent.insertBefore(selection, mountAnchor);
1551
+ this.selectionRect = selection;
1552
+ const caret = ownerDoc.createElementNS(SVG_NS, "rect");
1553
+ caret.setAttribute("fill", "#2563eb");
1554
+ caret.setAttribute("pointer-events", "none");
1555
+ caret.setAttribute("data-svg-text-edit-caret", "");
1556
+ caret.style.display = "none";
1557
+ parent.insertBefore(caret, mountAnchor.nextSibling);
1558
+ this.caretRect = caret;
1559
+ }
1560
+ setText(text) {
1561
+ if (this.textEl.textContent !== text) this.textEl.textContent = text;
1562
+ }
1563
+ setCaret(index, visible) {
1564
+ if (index === this.last_caret_idx && visible === this.last_caret_visible) return;
1565
+ this.last_caret_idx = index;
1566
+ this.last_caret_visible = visible;
1567
+ if (!visible) {
1568
+ this.caretRect.style.display = "none";
1569
+ return;
1570
+ }
1571
+ const m = this.metrics();
1572
+ const x = this.charX(index);
1573
+ this.caretRect.setAttribute("x", String(x - .75));
1574
+ this.caretRect.setAttribute("y", String(m.top));
1575
+ this.caretRect.setAttribute("width", "1.5");
1576
+ this.caretRect.setAttribute("height", String(m.height));
1577
+ this.caretRect.style.display = "block";
1578
+ }
1579
+ setSelection(start, end) {
1580
+ if (start === this.last_sel_start && end === this.last_sel_end) return;
1581
+ this.last_sel_start = start;
1582
+ this.last_sel_end = end;
1583
+ if (start === end) {
1584
+ this.selectionRect.style.display = "none";
1585
+ return;
1586
+ }
1587
+ const m = this.metrics();
1588
+ const x1 = this.charX(start);
1589
+ const x2 = this.charX(end);
1590
+ this.selectionRect.setAttribute("x", String(Math.min(x1, x2)));
1591
+ this.selectionRect.setAttribute("y", String(m.top));
1592
+ this.selectionRect.setAttribute("width", String(Math.abs(x2 - x1)));
1593
+ this.selectionRect.setAttribute("height", String(m.height));
1594
+ this.selectionRect.style.display = "block";
1595
+ }
1596
+ dispose(keepEditMutations = false) {
1597
+ this.caretRect.remove();
1598
+ this.selectionRect.remove();
1599
+ if (this.prevXmlSpace !== void 0 && !keepEditMutations) if (this.prevXmlSpace === null) this.textEl.removeAttributeNS(XML_NS, "space");
1600
+ else this.textEl.setAttributeNS(XML_NS, "xml:space", this.prevXmlSpace);
1601
+ if (this.prevPointerEvents !== void 0) if (this.prevPointerEvents === null) this.textEl.removeAttribute("pointer-events");
1602
+ else this.textEl.setAttribute("pointer-events", this.prevPointerEvents);
1603
+ this.prevXmlSpace = void 0;
1604
+ this.prevPointerEvents = void 0;
1605
+ }
1606
+ positionAtPoint(clientX, clientY) {
1607
+ const ctm = this.textEl.getScreenCTM();
1608
+ const svg = this.textEl.ownerSVGElement;
1609
+ if (!ctm || !svg) return 0;
1610
+ const pt = svg.createSVGPoint();
1611
+ pt.x = clientX;
1612
+ pt.y = clientY;
1613
+ const local = pt.matrixTransform(ctm.inverse());
1614
+ return this.localXToCharIndex(local.x);
1615
+ }
1616
+ /**
1617
+ * Single-line `<text>` element: there's no "previous visual line" to move
1618
+ * to. Cocoa single-line convention: Up/PageUp/line_start → doc start;
1619
+ * Down/PageDown/line_end → doc end.
1620
+ */
1621
+ positionForNavigation(_index, direction) {
1622
+ const text = this.textEl.textContent ?? "";
1623
+ switch (direction) {
1624
+ case "up":
1625
+ case "page_up":
1626
+ case "line_start": return 0;
1627
+ case "down":
1628
+ case "page_down":
1629
+ case "line_end": return text.length;
1630
+ }
1631
+ }
1632
+ metrics() {
1633
+ try {
1634
+ const b = this.textEl.getBBox();
1635
+ if (b.height > 0) return {
1636
+ top: b.y,
1637
+ height: b.height
1638
+ };
1639
+ } catch {}
1640
+ const fontSize = parseFloat(this.textEl.ownerDocument.defaultView?.getComputedStyle(this.textEl).fontSize ?? "16") || 16;
1641
+ return {
1642
+ top: parseFloat(this.textEl.getAttribute("y") ?? "0") - fontSize * .85,
1643
+ height: fontSize
1644
+ };
1645
+ }
1646
+ charX(i) {
1647
+ const text = this.textEl.textContent ?? "";
1648
+ const baseX = parseFloat(this.textEl.getAttribute("x") ?? "0");
1649
+ if (text.length === 0) return baseX;
1650
+ if (i <= 0) try {
1651
+ return this.textEl.getStartPositionOfChar(0).x;
1652
+ } catch {
1653
+ return baseX;
1654
+ }
1655
+ if (i >= text.length) try {
1656
+ return this.textEl.getEndPositionOfChar(text.length - 1).x;
1657
+ } catch {
1658
+ return baseX;
1659
+ }
1660
+ try {
1661
+ return this.textEl.getStartPositionOfChar(i).x;
1662
+ } catch {
1663
+ return baseX;
1664
+ }
1665
+ }
1666
+ localXToCharIndex(localX) {
1667
+ const text = this.textEl.textContent ?? "";
1668
+ if (!text) return 0;
1669
+ for (let i = 0; i < text.length; i++) try {
1670
+ const ext = this.textEl.getExtentOfChar(i);
1671
+ if (localX < ext.x + ext.width / 2) return i;
1672
+ } catch {
1673
+ break;
1674
+ }
1675
+ return text.length;
1676
+ }
1677
+ };
1678
+ //#endregion
1679
+ //#region src/dom.ts
1680
+ /** Stamped on every rendered SVG element by `render()` so external
1681
+ * tooling (host inspectors, the layers panel, snapshot tests) can map
1682
+ * a DOM node back to its `NodeId`. The cmath fat-hit picker doesn't
1683
+ * use it — see `_pick_node_at_world`. The legacy elementFromPoint
1684
+ * fallback (active when `hit_tolerance_px <= 0`) walks up via
1685
+ * {@link walk_to_id} below. */
1686
+ const ID_ATTR = "data-grida-id";
1687
+ /** Walk from `target` up through `parentElement`, returning the first id
1688
+ * found on a `[data-grida-id]` ancestor. When `exclude_root` matches an
1689
+ * encountered id, the walk stops and returns `null` — selection HUD
1690
+ * treats root as non-selectable; measurement HUD passes `undefined`
1691
+ * so the root id can be returned. */
1692
+ function walk_to_id(target, exclude_root) {
1693
+ let cur = target;
1694
+ while (cur instanceof Element) {
1695
+ const id = cur.getAttribute(ID_ATTR);
1696
+ if (id) return id === exclude_root ? null : id;
1697
+ cur = cur.parentElement;
1698
+ }
1699
+ return null;
1700
+ }
1701
+ const SVG_HUD_GROUP = {
1702
+ selection: "svg-editor.selection",
1703
+ selectionControls: "svg-editor.selection-controls",
1704
+ sizeMeter: "svg-editor.size-meter",
1705
+ memberOutline: "svg-editor.member-outline"
1706
+ };
1707
+ /** KeyboardEvent.key values for the modifiers the surface tracks. Used to
1708
+ * short-circuit window-level `keydown`/`keyup` for non-modifier keystrokes. */
1709
+ const IS_MODIFIER_KEY = {
1710
+ Shift: true,
1711
+ Alt: true,
1712
+ Meta: true,
1713
+ Control: true
1714
+ };
1715
+ /** Sentinel placed in `text_edit` before `createTextEditor` returns, so the
1716
+ * surface skips render() during the in-flight mount and doesn't yank the
1717
+ * live `<text>` element out from under the about-to-mount text surface. */
1718
+ const TEXT_EDIT_PENDING = { __pending: true };
1719
+ /**
1720
+ * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
1721
+ * whose `detach()` is the inverse — DOM cleared, listeners removed,
1722
+ * gestures uninstalled.
1723
+ *
1724
+ * Usage is one-shot per container: the surface owns the container's children
1725
+ * for its lifetime, and `detach()` restores it to empty.
1726
+ */
1727
+ function attach_dom_surface(editor, options) {
1728
+ const surface = new DomSurface(editor, options);
1729
+ const inner = editor.attach(surface);
1730
+ return {
1731
+ detach: () => {
1732
+ surface.detach_gestures();
1733
+ inner.detach();
1734
+ },
1735
+ camera: surface.camera,
1736
+ gestures: surface.gestures
1737
+ };
1738
+ }
1739
+ var DomSurface = class DomSurface {
1740
+ constructor(editor, options) {
1741
+ this.editor = editor;
1742
+ this.svg_root = null;
1743
+ this.teardown = [];
1744
+ this.element_index = /* @__PURE__ */ new Map();
1745
+ this.last_pointer = {
1746
+ x: 0,
1747
+ y: 0
1748
+ };
1749
+ this.last_pointer_valid = false;
1750
+ this.resize_observer = null;
1751
+ this.redraw_raf_id = null;
1752
+ this._geometry_provider = null;
1753
+ this._hit_shapes = null;
1754
+ this._z_order_cache = [];
1755
+ this._z_order_dirty = true;
1756
+ this.active_preview = null;
1757
+ this.text_edit = null;
1758
+ this.text_edit_target = null;
1759
+ this.text_edit_original = "";
1760
+ this.current_tool = TOOL_CURSOR;
1761
+ this.pending_insert = null;
1762
+ this.editor_hover_internal = null;
1763
+ this.container = options.container;
1764
+ const container = this.container;
1765
+ this.fit_on_attach = options.fit === true;
1766
+ 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.");
1767
+ if (getComputedStyle(container).position === "static") container.style.position = "relative";
1768
+ container.style.userSelect = "none";
1769
+ container.style.webkitUserSelect = "none";
1770
+ const translate_options = () => {
1771
+ const style = this.editor.style;
1772
+ const zoom = this.camera.zoom || 1;
1773
+ return {
1774
+ pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
1775
+ snap_enabled: style.snap_enabled,
1776
+ snap_threshold_px: style.snap_threshold_px / zoom
1777
+ };
1778
+ };
1779
+ this.translate_orchestrator = new TranslateOrchestrator({
1780
+ get_doc: () => this.editor_internal().doc,
1781
+ emit: () => this.editor_internal().emit(),
1782
+ open_preview: (label) => this.editor_internal().history.preview(label),
1783
+ open_snap: (ids) => this.open_snap_session_for(ids),
1784
+ options: translate_options
1785
+ });
1786
+ const resize_options = () => {
1787
+ const style = this.editor.style;
1788
+ const zoom = this.camera.zoom || 1;
1789
+ return {
1790
+ pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
1791
+ snap_enabled: style.snap_enabled,
1792
+ snap_threshold_px: style.snap_threshold_px / zoom
1793
+ };
1794
+ };
1795
+ this.resize_orchestrator = new ResizeOrchestrator({
1796
+ get_doc: () => this.editor_internal().doc,
1797
+ emit: () => this.editor_internal().emit(),
1798
+ open_preview: (label) => this.editor_internal().history.preview(label),
1799
+ open_snap: (ids) => this.open_snap_session_for(ids),
1800
+ options: resize_options,
1801
+ bbox_world: (id) => this.bbox_world(id) ?? {
1802
+ x: 0,
1803
+ y: 0,
1804
+ width: 0,
1805
+ height: 0
1806
+ }
1807
+ });
1808
+ const rotate_options = () => ({ angle_snap_step_radians: this.editor.style.angle_snap_step_radians });
1809
+ this.rotate_orchestrator = new RotateOrchestrator({
1810
+ get_doc: () => this.editor_internal().doc,
1811
+ emit: () => this.editor_internal().emit(),
1812
+ open_preview: (label) => this.editor_internal().history.preview(label),
1813
+ options: rotate_options,
1814
+ bbox_world: (id) => this.bbox_world(id) ?? {
1815
+ x: 0,
1816
+ y: 0,
1817
+ width: 0,
1818
+ height: 0
1819
+ }
1820
+ });
1821
+ const editor_ref = this.editor;
1822
+ const editor_for_watcher = {
1823
+ get document() {
1824
+ return editor_ref.document;
1825
+ },
1826
+ get state() {
1827
+ return editor_ref.state;
1828
+ },
1829
+ subscribe_translate_commit: (cb) => this.editor_internal().subscribe_translate_commit(cb)
1830
+ };
1831
+ this.nudge_dwell_watcher = new NudgeDwellWatcher({
1832
+ editor: editor_for_watcher,
1833
+ open_snap: (ids) => this.open_snap_session_for(ids),
1834
+ options: translate_options,
1835
+ on_guides_change: () => this.request_redraw(),
1836
+ window: container.ownerDocument.defaultView ?? window
1837
+ });
1838
+ this.teardown.push(() => this.nudge_dwell_watcher.dispose());
1839
+ this.hud_canvas = container.ownerDocument.createElement("canvas");
1840
+ Object.assign(this.hud_canvas.style, {
1841
+ position: "absolute",
1842
+ left: "0",
1843
+ top: "0",
1844
+ pointerEvents: "none"
1845
+ });
1846
+ container.appendChild(this.hud_canvas);
1847
+ this.hud = new Surface(this.hud_canvas, {
1848
+ pick: (p) => this.hit_test(p[0], p[1]),
1849
+ shapeOf: (id) => this.shape_of(id),
1850
+ onIntent: (i) => this.commit_intent(i),
1851
+ style: {
1852
+ chromeColor: editor.style.chrome_color,
1853
+ showRotationHandles: true
1854
+ },
1855
+ groups: {
1856
+ selection: SVG_HUD_GROUP.selection,
1857
+ selectionControls: SVG_HUD_GROUP.selectionControls
1858
+ },
1859
+ visibility: ({ gesture }) => {
1860
+ if (gesture.kind !== "translate") return void 0;
1861
+ return { hidden: [
1862
+ SVG_HUD_GROUP.selection,
1863
+ SVG_HUD_GROUP.selectionControls,
1864
+ SVG_HUD_GROUP.sizeMeter
1865
+ ] };
1866
+ },
1867
+ pixelGrid: {
1868
+ enabled: editor.style.pixel_grid,
1869
+ zoomThreshold: 4,
1870
+ transform: options.initial_camera
1871
+ }
1872
+ });
1873
+ this.hud.setCursorRenderer(cursors.defaultRenderer());
1874
+ this.camera = new Camera({
1875
+ resolve_bounds: (target) => this.resolve_world_bounds(target),
1876
+ initial: options.initial_camera
1877
+ });
1878
+ this.hud.setPixelGridTransform(this.camera.transform);
1879
+ this.teardown.push(this.camera.subscribe(() => {
1880
+ this.apply_camera_transform();
1881
+ this.hud.setPixelGridTransform(this.camera.transform);
1882
+ this.sync_surface_selection();
1883
+ this.redraw();
1884
+ }));
1885
+ this.render();
1886
+ this.sync_canvas_size();
1887
+ this.sync_surface_selection();
1888
+ this.redraw();
1889
+ const win = container.ownerDocument.defaultView ?? window;
1890
+ const raf = win.requestAnimationFrame(() => {
1891
+ this.sync_canvas_size();
1892
+ this.honor_initial_fit();
1893
+ this.redraw();
1894
+ });
1895
+ this.teardown.push(() => win.cancelAnimationFrame(raf));
1896
+ this.gestures = new Gestures({
1897
+ container,
1898
+ svg_root: () => this.svg_root,
1899
+ hud_canvas: this.hud_canvas,
1900
+ camera: this.camera,
1901
+ editor,
1902
+ handle: { detach: () => {} }
1903
+ });
1904
+ if (options.gestures !== false) applyDefaultGestures(this.gestures);
1905
+ const unsub = editor.subscribe(() => {
1906
+ this.current_tool = editor.state.tool;
1907
+ this.render();
1908
+ this.sync_surface_selection();
1909
+ this.hud.setPixelGrid({
1910
+ enabled: editor.style.pixel_grid,
1911
+ zoomThreshold: 4
1912
+ });
1913
+ this.sync_canvas_size();
1914
+ this.sync_cursor();
1915
+ if (this.pending_insert) {
1916
+ const t = this.current_tool;
1917
+ const cur = this.pending_insert;
1918
+ if (t.type === "insert" ? t.tag !== cur.tag : cur.phase === "armed") {
1919
+ if (cur.phase === "drawing") cur.session.discard();
1920
+ this.pending_insert = null;
1921
+ }
1922
+ }
1923
+ this.request_redraw();
1924
+ });
1925
+ this.teardown.push(unsub);
1926
+ this.teardown.push(editor.subscribe_geometry(() => this.request_redraw()));
1927
+ if (typeof ResizeObserver !== "undefined") {
1928
+ this.resize_observer = new ResizeObserver(() => this.sync_canvas_size());
1929
+ this.resize_observer.observe(container);
1930
+ this.teardown.push(() => this.resize_observer?.disconnect());
1931
+ } else {
1932
+ const win = container.ownerDocument.defaultView ?? window;
1933
+ const fn = () => this.sync_canvas_size();
1934
+ win.addEventListener("resize", fn);
1935
+ this.teardown.push(() => win.removeEventListener("resize", fn));
1936
+ }
1937
+ this.wire_events();
1938
+ const internal = editor._internal;
1939
+ this.editor_hover_internal = internal;
1940
+ internal.set_content_edit_driver((id) => this.enter_content_edit(id));
1941
+ this.teardown.push(() => internal.set_content_edit_driver(null));
1942
+ internal.set_computed_resolver({
1943
+ computed_property: (id, name) => {
1944
+ const el = this.element_index.get(id);
1945
+ if (!el) return null;
1946
+ const value = getComputedStyle(el).getPropertyValue(name);
1947
+ return value === "" ? null : value;
1948
+ },
1949
+ computed_paint: (id, channel) => {
1950
+ const el = this.element_index.get(id);
1951
+ if (!el) return null;
1952
+ const computed = getComputedStyle(el).getPropertyValue(channel);
1953
+ if (computed === "") return null;
1954
+ return {
1955
+ computed,
1956
+ resolved_paint: parse_paint(computed)
1957
+ };
1958
+ }
1959
+ });
1960
+ this.teardown.push(() => internal.set_computed_resolver(null));
1961
+ const geometry = new MemoizedGeometryProvider(new SvgGeometryDriver({
1962
+ element_for: (id) => this.element_index.get(id) ?? null,
1963
+ root: () => this.svg_root,
1964
+ camera: () => this.camera,
1965
+ container: () => this.container,
1966
+ pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root)
1967
+ }), {
1968
+ subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
1969
+ subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
1970
+ });
1971
+ this._geometry_provider = geometry;
1972
+ internal.set_geometry(geometry);
1973
+ this.teardown.push(() => {
1974
+ internal.set_geometry(null);
1975
+ geometry.dispose();
1976
+ this._geometry_provider = null;
1977
+ });
1978
+ const hit_shapes = new MemoizedHitShapeProvider(new SvgHitShapeDriver({
1979
+ doc: () => {
1980
+ try {
1981
+ return this.editor_internal().doc;
1982
+ } catch {
1983
+ return null;
1984
+ }
1985
+ },
1986
+ bounds_of: (id) => geometry.bounds_of(id)
1987
+ }), {
1988
+ subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
1989
+ subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
1990
+ });
1991
+ this._hit_shapes = hit_shapes;
1992
+ this._z_order_dirty = true;
1993
+ this.teardown.push(editor.subscribe_with_selector((s) => s.structure_version, () => {
1994
+ this._z_order_dirty = true;
1995
+ }));
1996
+ this.teardown.push(() => {
1997
+ hit_shapes.dispose();
1998
+ this._hit_shapes = null;
1999
+ this._z_order_cache = [];
2000
+ });
2001
+ internal.set_surface_hover_override_driver((id) => {
2002
+ const response = this.hud.setHoverOverride(id);
2003
+ if (response.hoverChanged) internal.push_surface_hover(this.hud.hover());
2004
+ if (response.needsRedraw) this.redraw();
2005
+ });
2006
+ this.teardown.push(() => internal.set_surface_hover_override_driver(null));
2007
+ }
2008
+ paint(_snapshot) {}
2009
+ hit_test(x, y) {
2010
+ return this.pick_at(x, y, false);
2011
+ }
2012
+ /** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
2013
+ * `allow_root` is `false`, root hits are rejected (returns `null`) so
2014
+ * the HUD never hovers / selects / drags the document itself —
2015
+ * selection of the root is a host concern. When `true`, the root id
2016
+ * is returned for callers that need it as a measurement candidate
2017
+ * (`<svg>` is a snap target and should be a measurement target too;
2018
+ * see `compute_measurement_extra`). */
2019
+ pick_at(x, y, allow_root) {
2020
+ const world = this.camera.screen_to_world({
2021
+ x,
2022
+ y
2023
+ });
2024
+ return this._pick_node_at_world(world, allow_root);
2025
+ }
2026
+ /** Resolve a world-space point to a node id.
2027
+ *
2028
+ * Two paths, selected at runtime by `EditorStyle.hit_tolerance_px`:
2029
+ *
2030
+ * - **`> 0` (cmath fat-hit picker)** — walks document z-order
2031
+ * topmost-first via {@link pick_at_world}. Each candidate's
2032
+ * hit-shape comes from the memoized `SvgHitShapeDriver`
2033
+ * (intrinsic geometry or world-space bounds-rect fallback for
2034
+ * `<text>` / `<use>` / transformed nodes). Tolerance is screen-
2035
+ * CSS-px, converted to world units via `camera.zoom` so the band
2036
+ * stays the same width on screen regardless of zoom. Has known
2037
+ * issues — see `docs/wg/feat-svg-editor/hit-test.md`.
2038
+ *
2039
+ * - **`<= 0` (legacy elementFromPoint)** — opt-out of the cmath
2040
+ * picker. Uses the browser's painted-pixel hit-test plus a
2041
+ * walk-up of `data-grida-id`. Pixel-exact, no tolerance, but
2042
+ * renderer-correct on every real-world SVG feature (transforms,
2043
+ * cascade, clip-path, fill-rule, pointer-events). This is the
2044
+ * v1-baseline path; useful for A/B comparison and as a safe
2045
+ * fallback if the cmath path misbehaves.
2046
+ *
2047
+ * `allow_root` controls whether the root `<svg>` may be returned:
2048
+ * selection HUD passes `false`, measurement HUD passes `true`.
2049
+ *
2050
+ * Used by both `pick_at` (HUD hover / measurement) and
2051
+ * `SvgGeometryDriver.node_at_point` (core editor selection) so one
2052
+ * source of truth governs every click that resolves to a node. */
2053
+ _pick_node_at_world(p, allow_root) {
2054
+ const root_id = this.editor.tree().root;
2055
+ const tol_px = this.editor.style.hit_tolerance_px;
2056
+ if (tol_px <= 0) return this._pick_node_via_dom(p, allow_root, root_id);
2057
+ const geometry = this._geometry_provider;
2058
+ const hit_shapes = this._hit_shapes;
2059
+ if (geometry && hit_shapes) {
2060
+ const zoom = this.camera.zoom;
2061
+ const hit = pick_at_world(p, {
2062
+ tolerance_world: zoom > 0 ? tol_px / zoom : 0,
2063
+ ordered_ids: this._ensure_z_order(false, root_id),
2064
+ bounds_of: (id) => geometry.bounds_of(id),
2065
+ hit_shape_of: (id) => hit_shapes.hit_shape_of(id)
2066
+ });
2067
+ if (hit !== null) return hit;
2068
+ }
2069
+ if (allow_root && geometry) {
2070
+ const root_bounds = geometry.bounds_of(root_id);
2071
+ if (root_bounds && cmath.rect.containsPoint(root_bounds, [p.x, p.y])) return root_id;
2072
+ }
2073
+ return null;
2074
+ }
2075
+ /** Legacy DOM-based picker. World point → container CSS px via camera,
2076
+ * then `elementFromPoint` + walk-up to `data-grida-id`. No tolerance,
2077
+ * no cmath, no z-order owned by us — the browser stacks paints and
2078
+ * returns the topmost. The picker the package shipped with at v1. */
2079
+ _pick_node_via_dom(p, allow_root, root_id) {
2080
+ const screen = this.camera.world_to_screen(p);
2081
+ const cr = this.container.getBoundingClientRect();
2082
+ const target = this.container.ownerDocument.elementFromPoint(cr.left + screen.x, cr.top + screen.y);
2083
+ if (!(target instanceof SVGElement)) return null;
2084
+ return walk_to_id(target, allow_root ? void 0 : root_id);
2085
+ }
2086
+ /** Lazily rebuild the z-order list. Walks the document depth-first,
2087
+ * emitting element ids in paint order (back → front). The root is
2088
+ * always pushed first; callers that disallow root pass `false` to
2089
+ * have the picker skip it. (Cheaper to keep one canonical list and
2090
+ * filter the root inline than to maintain two parallel arrays.) */
2091
+ _ensure_z_order(allow_root, root_id) {
2092
+ if (this._z_order_dirty) {
2093
+ const out = [];
2094
+ const doc = (() => {
2095
+ try {
2096
+ return this.editor_internal().doc;
2097
+ } catch {
2098
+ return null;
2099
+ }
2100
+ })();
2101
+ if (doc) {
2102
+ const walk = (id) => {
2103
+ out.push(id);
2104
+ for (const c of doc.element_children_of(id)) walk(c);
2105
+ };
2106
+ walk(doc.root);
2107
+ }
2108
+ this._z_order_cache = out;
2109
+ this._z_order_dirty = false;
2110
+ }
2111
+ if (allow_root) return this._z_order_cache;
2112
+ if (this._z_order_cache.length > 0 && this._z_order_cache[0] === root_id) return this._z_order_cache.slice(1);
2113
+ return this._z_order_cache.filter((id) => id !== root_id);
2114
+ }
2115
+ on_input(_listener) {
2116
+ return () => {};
2117
+ }
2118
+ dispose() {
2119
+ if (this.text_edit) {
2120
+ this.text_edit.cancel();
2121
+ this.text_edit = null;
2122
+ this.text_edit_target = null;
2123
+ }
2124
+ this.gestures._dispose();
2125
+ this.translate_orchestrator.cancel();
2126
+ this.resize_orchestrator.cancel();
2127
+ this.rotate_orchestrator.cancel();
2128
+ this.active_preview = null;
2129
+ if (this.redraw_raf_id !== null) {
2130
+ (this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.redraw_raf_id);
2131
+ this.redraw_raf_id = null;
2132
+ }
2133
+ for (const fn of this.teardown) fn();
2134
+ this.teardown = [];
2135
+ this.hud.dispose();
2136
+ this.hud_canvas.remove();
2137
+ if (this.svg_root) this.svg_root.remove();
2138
+ this.svg_root = null;
2139
+ this.element_index.clear();
2140
+ this.last_pointer_valid = false;
2141
+ }
2142
+ /** Public — invoked by the `DomSurfaceHandle` wrapper before `detach()`. */
2143
+ detach_gestures() {
2144
+ this.gestures._dispose();
2145
+ }
2146
+ render() {
2147
+ if (this.text_edit) return;
2148
+ const owner_doc = this.container.ownerDocument;
2149
+ const doc = this.editor._internal.doc;
2150
+ const svg_text = this.editor.serialize();
2151
+ const wrapper = owner_doc.createElement("div");
2152
+ wrapper.innerHTML = svg_text;
2153
+ const new_svg = wrapper.querySelector("svg");
2154
+ if (!(new_svg instanceof SVGSVGElement)) return;
2155
+ if (this.svg_root) this.svg_root.replaceWith(new_svg);
2156
+ else this.container.insertBefore(new_svg, this.hud_canvas);
2157
+ this.svg_root = new_svg;
2158
+ this.apply_svg_layout();
2159
+ this.apply_camera_transform();
2160
+ this.element_index.clear();
2161
+ const ids = doc.all_elements();
2162
+ let i = 0;
2163
+ const tag_walk = (el) => {
2164
+ if (i < ids.length) {
2165
+ const id = ids[i++];
2166
+ el.setAttribute(ID_ATTR, id);
2167
+ this.element_index.set(id, el);
2168
+ }
2169
+ for (let c = el.firstElementChild; c; c = c.nextElementSibling) if (c instanceof SVGElement) tag_walk(c);
2170
+ };
2171
+ tag_walk(new_svg);
2172
+ }
2173
+ sync_canvas_size() {
2174
+ const cr = this.container.getBoundingClientRect();
2175
+ this.hud.setSize(cr.width, cr.height);
2176
+ this.camera._set_viewport_size(cr.width, cr.height);
2177
+ this.redraw();
2178
+ }
2179
+ /**
2180
+ * Apply absolute positioning + transform-origin to the SVG so the camera's
2181
+ * CSS matrix maps SVG-coord (0,0) cleanly to container-screen (tx, ty).
2182
+ * Called after every render() that may have replaced the root element.
2183
+ */
2184
+ apply_svg_layout() {
2185
+ if (!this.svg_root) return;
2186
+ const style = this.svg_root.style;
2187
+ style.position = "absolute";
2188
+ style.left = "0";
2189
+ style.top = "0";
2190
+ style.transformOrigin = "0 0";
2191
+ }
2192
+ /**
2193
+ * Push the current camera transform to the SVG as a CSS `matrix(...)`.
2194
+ * The HUD canvas stays at identity — selection chrome reads node bounds
2195
+ * via `getScreenCTM()`, which already includes the CSS transform, so
2196
+ * chrome aligns automatically and stays 1px sharp at any zoom.
2197
+ */
2198
+ apply_camera_transform() {
2199
+ if (!this.svg_root) return;
2200
+ const t = this.camera.transform;
2201
+ this.svg_root.style.transform = `matrix(${t[0][0]}, ${t[1][0]}, ${t[0][1]}, ${t[1][1]}, ${t[0][2]}, ${t[1][2]})`;
2202
+ }
2203
+ /** One-shot fit-on-attach. Runs after layout has settled. */
2204
+ honor_initial_fit() {
2205
+ if (!this.fit_on_attach) return;
2206
+ this.fit_on_attach = false;
2207
+ this.camera.fit("<root>");
2208
+ }
2209
+ /**
2210
+ * BoundsResolver for `Camera.fit(target)`. The Camera class handles Rect
2211
+ * passthrough itself; this resolver only sees string targets — sentinels
2212
+ * ("<root>", "<selection>") and NodeIds.
2213
+ */
2214
+ resolve_world_bounds(target) {
2215
+ if (target === "<root>") return this.root_world_bounds();
2216
+ const geometry = this.editor.geometry;
2217
+ if (target === "<selection>") {
2218
+ const sel = this.editor.state.selection;
2219
+ if (sel.length === 0 || !geometry) return null;
2220
+ const rects = [...geometry.bounds_of_many(sel).values()];
2221
+ if (rects.length === 0) return null;
2222
+ return cmath.rect.union(rects);
2223
+ }
2224
+ return geometry?.bounds_of(target) ?? null;
2225
+ }
2226
+ /**
2227
+ * World-space bounds of the root document. Prefer `viewBox` (the SVG's
2228
+ * declared world rect), fall back to `width`/`height` attrs, then the
2229
+ * SVG root's `getBBox()` as a last resort.
2230
+ */
2231
+ root_world_bounds() {
2232
+ const root_id = this.editor.tree().root;
2233
+ const doc = this.editor.document;
2234
+ const view_box = doc.get_attr(root_id, "viewBox");
2235
+ if (view_box) {
2236
+ const parts = view_box.trim().split(/[\s,]+/).map(Number);
2237
+ if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) return {
2238
+ x: parts[0],
2239
+ y: parts[1],
2240
+ width: parts[2],
2241
+ height: parts[3]
2242
+ };
2243
+ }
2244
+ const w = parseFloat(doc.get_attr(root_id, "width") ?? "");
2245
+ const h = parseFloat(doc.get_attr(root_id, "height") ?? "");
2246
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return {
2247
+ x: 0,
2248
+ y: 0,
2249
+ width: w,
2250
+ height: h
2251
+ };
2252
+ if (this.svg_root) try {
2253
+ const b = this.svg_root.getBBox();
2254
+ if (b.width > 0 && b.height > 0) return {
2255
+ x: b.x,
2256
+ y: b.y,
2257
+ width: b.width,
2258
+ height: b.height
2259
+ };
2260
+ } catch {}
2261
+ return null;
2262
+ }
2263
+ /** Single per-frame draw entry — merges host-fed extras with surface chrome. */
2264
+ redraw() {
2265
+ this.hud.draw(merge_hud_draws(this.compute_measurement_extra(), this.compute_size_meter_extra(), this.compute_snap_extra(), this.compute_member_outlines_extra()));
2266
+ }
2267
+ /** RAF-coalesced `redraw` for event sources that may emit many times per
2268
+ * frame (geometry-version bumps mid-drag). Camera/gesture paths still call
2269
+ * `redraw` directly for synchronous chrome alignment. */
2270
+ request_redraw() {
2271
+ if (this.redraw_raf_id !== null) return;
2272
+ const win = this.container.ownerDocument.defaultView ?? window;
2273
+ this.redraw_raf_id = win.requestAnimationFrame(() => {
2274
+ this.redraw_raf_id = null;
2275
+ this.redraw();
2276
+ });
2277
+ }
2278
+ /**
2279
+ * Build the host-fed measurement guide for the current frame, or
2280
+ * `undefined` if no guide should be drawn.
2281
+ *
2282
+ * Master signal: Alt held (read from `surface.modifiers()`). Each
2283
+ * additional condition is a derivation, not a separate flag — this keeps
2284
+ * a single source of truth and lets future Alt-consumers (constrained
2285
+ * resize, axis-lock, …) live next to this one without re-tracking the key.
2286
+ */
2287
+ compute_measurement_extra() {
2288
+ if (!this.hud.modifiers().alt) return void 0;
2289
+ if (this.hud.gesture().kind !== "idle") return void 0;
2290
+ const sel = this.editor.state.selection;
2291
+ if (sel.length === 0) return void 0;
2292
+ let hover = this.hud.hover();
2293
+ if (!hover && this.last_pointer_valid) hover = this.pick_at(this.last_pointer.x, this.last_pointer.y, true);
2294
+ if (!hover) return void 0;
2295
+ if (sel.includes(hover)) return void 0;
2296
+ const a_container = sel.map((id) => this.container_box(id)).filter((r) => r !== null);
2297
+ if (a_container.length === 0) return void 0;
2298
+ const b_container = this.container_box(hover);
2299
+ if (!b_container) return void 0;
2300
+ const m_container = measure(cmath.rect.union(a_container), b_container);
2301
+ if (!m_container) return void 0;
2302
+ const draw = measurementToHUDDraw(m_container, this.editor.style.measurement_color);
2303
+ const geometry = this.editor.geometry;
2304
+ if (geometry) {
2305
+ const a_world = sel.map((id) => geometry.bounds_of(id)).filter((r) => r !== null);
2306
+ const b_world = geometry.bounds_of(hover);
2307
+ if (a_world.length > 0 && b_world) {
2308
+ const m_world = measure(cmath.rect.union(a_world), b_world);
2309
+ if (m_world) {
2310
+ let cursor = 0;
2311
+ for (const line of draw.lines ?? []) {
2312
+ if (line.label === void 0) continue;
2313
+ const side = next_labellable_side(cursor, m_container, m_world);
2314
+ if (side < 0) break;
2315
+ line.label = cmath.ui.formatNumber(m_world.distance[side], 1);
2316
+ cursor = side + 1;
2317
+ }
2318
+ }
2319
+ }
2320
+ }
2321
+ return draw;
2322
+ }
2323
+ /** Pill position is container-space (tracks camera); values are
2324
+ * world-space (zoom-invariant). Hidden in text-edit mode. */
2325
+ compute_size_meter_extra() {
2326
+ if (!this.editor.style.show_size_meter) return void 0;
2327
+ if (this.editor.state.mode === "edit-content") return void 0;
2328
+ const sel = this.editor.state.selection;
2329
+ if (sel.length === 0) return void 0;
2330
+ const geometry = this.editor.geometry;
2331
+ if (!geometry) return void 0;
2332
+ const color = this.editor.style.chrome_color;
2333
+ if (sel.length === 1) {
2334
+ const shape = this.shape_of(sel[0]);
2335
+ if (shape && shape.kind === "transformed") {
2336
+ const { local, matrix } = shape;
2337
+ const { anchor, angle } = pick_lowest_side_anchor(cmath.rect.toCorners(local).map((p) => cmath.vector2.transform(p, matrix)), true);
2338
+ const label = `${cmath.ui.formatNumber(local.width, 1)} × ${cmath.ui.formatNumber(local.height, 1)}`;
2339
+ return { lines: [{
2340
+ x1: anchor[0],
2341
+ y1: anchor[1],
2342
+ x2: anchor[0],
2343
+ y2: anchor[1],
2344
+ label,
2345
+ color,
2346
+ labelAngle: angle,
2347
+ group: SVG_HUD_GROUP.sizeMeter
2348
+ }] };
2349
+ }
2350
+ if (shape && shape.kind === "line") {
2351
+ const { p1, p2 } = shape;
2352
+ const { anchor, angle } = pick_lowest_side_anchor([p1, p2], false);
2353
+ const dx = p2[0] - p1[0];
2354
+ const dy = p2[1] - p1[1];
2355
+ const label = `${cmath.ui.formatNumber(Math.abs(dx), 1)} × ${cmath.ui.formatNumber(Math.abs(dy), 1)}`;
2356
+ return { lines: [{
2357
+ x1: anchor[0],
2358
+ y1: anchor[1],
2359
+ x2: anchor[0],
2360
+ y2: anchor[1],
2361
+ label,
2362
+ color,
2363
+ labelAngle: angle,
2364
+ group: SVG_HUD_GROUP.sizeMeter
2365
+ }] };
2366
+ }
2367
+ }
2368
+ const world_rects = [];
2369
+ const container_rects = [];
2370
+ for (const id of sel) {
2371
+ const world = geometry.bounds_of(id);
2372
+ const container = this.container_box(id);
2373
+ if (!world || !container) continue;
2374
+ world_rects.push(world);
2375
+ container_rects.push(container);
2376
+ }
2377
+ if (world_rects.length === 0) return void 0;
2378
+ const world_union = cmath.rect.union(world_rects);
2379
+ const container_union = cmath.rect.union(container_rects);
2380
+ const cx = container_union.x + container_union.width / 2;
2381
+ const by = container_union.y + container_union.height;
2382
+ return { lines: [{
2383
+ x1: cx,
2384
+ y1: by,
2385
+ x2: cx,
2386
+ y2: by,
2387
+ label: `${cmath.ui.formatNumber(world_union.width, 1)} × ${cmath.ui.formatNumber(world_union.height, 1)}`,
2388
+ color,
2389
+ group: SVG_HUD_GROUP.sizeMeter
2390
+ }] };
2391
+ }
2392
+ /**
2393
+ * Thin outline rects for each individually-selected member, drawn
2394
+ * inside the single envelope chrome when a multi-selection is active.
2395
+ * Lets the user see _what_ is selected separately from _what will
2396
+ * resize as a unit_ (the envelope itself, with corner handles).
2397
+ *
2398
+ * Single-member selections already get their outline from the
2399
+ * chrome's own rect renderer — emitting outlines here would be
2400
+ * redundant (double-stroked outline). Returns `undefined` then.
2401
+ */
2402
+ compute_member_outlines_extra() {
2403
+ if (this.editor.state.mode === "edit-content") return void 0;
2404
+ const sel = this.editor.state.selection;
2405
+ if (sel.length < 2) return void 0;
2406
+ const color = this.editor.style.chrome_color;
2407
+ const rects = [];
2408
+ for (const id of sel) {
2409
+ const r = this.container_box(id);
2410
+ if (!r) continue;
2411
+ rects.push({
2412
+ x: r.x,
2413
+ y: r.y,
2414
+ width: r.width,
2415
+ height: r.height,
2416
+ stroke: true,
2417
+ color,
2418
+ group: SVG_HUD_GROUP.memberOutline
2419
+ });
2420
+ }
2421
+ return rects.length > 0 ? { rects } : void 0;
2422
+ }
2423
+ compute_snap_extra() {
2424
+ const insert_guide = this.pending_insert?.phase === "drawing" ? this.pending_insert.snap_session?.last_guide : void 0;
2425
+ if (insert_guide) return snapGuideToHUDDraw(this.project_guide_to_screen(insert_guide), this.editor.style.measurement_color);
2426
+ 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;
2427
+ if (guides.length === 0) return void 0;
2428
+ return snapGuideToHUDDraw(this.project_guide_to_screen(guides[0]), this.editor.style.measurement_color);
2429
+ }
2430
+ /** Project a snap guide from world space (pipeline output) to screen
2431
+ * CSS-px (the HUD canvas's identity-transform coordinate system).
2432
+ * Lines + points project via `camera.world_to_screen`; rules carry
2433
+ * a single axis-offset, so they project a representative point on
2434
+ * that axis (the camera has no rotation, so per-axis scale +
2435
+ * translate fully describes the projection). */
2436
+ project_guide_to_screen(g) {
2437
+ const cam = this.camera;
2438
+ return {
2439
+ lines: g.lines.map((l) => {
2440
+ const p1 = cam.world_to_screen({
2441
+ x: l.x1,
2442
+ y: l.y1
2443
+ });
2444
+ const p2 = cam.world_to_screen({
2445
+ x: l.x2,
2446
+ y: l.y2
2447
+ });
2448
+ return {
2449
+ ...l,
2450
+ x1: p1.x,
2451
+ y1: p1.y,
2452
+ x2: p2.x,
2453
+ y2: p2.y
2454
+ };
2455
+ }),
2456
+ points: g.points.map(([x, y]) => {
2457
+ const p = cam.world_to_screen({
2458
+ x,
2459
+ y
2460
+ });
2461
+ return [p.x, p.y];
2462
+ }),
2463
+ rules: g.rules.map(([axis, offset]) => {
2464
+ const p = cam.world_to_screen(axis === "x" ? {
2465
+ x: offset,
2466
+ y: 0
2467
+ } : {
2468
+ x: 0,
2469
+ y: offset
2470
+ });
2471
+ return [axis, axis === "x" ? p.x : p.y];
2472
+ })
2473
+ };
2474
+ }
2475
+ /** Freeze snap inputs at gesture start: dragged agent rects +
2476
+ * neighbor candidate rects. Both come from `bbox_world_for_snap` →
2477
+ * `bbox_world`, which projects each element's `getBBox()` through
2478
+ * its own `transform=` so the rects sit in **doc space** — the root
2479
+ * SVG's user-coordinate system, with element-local rotations / etc.
2480
+ * already accounted for. The pipeline operates in this same space,
2481
+ * so snap inputs and pipeline deltas share a frame end-to-end.
2482
+ *
2483
+ * Returns a fresh `SnapSession`; the caller (orchestrator OR
2484
+ * nudge-dwell watcher) owns its lifetime. */
2485
+ open_snap_session_for(ids) {
2486
+ const doc = this.editor.document;
2487
+ const neighbor_ids = compute_neighborhood(doc, ids);
2488
+ const agent_id_set = /* @__PURE__ */ new Set();
2489
+ for (const id of ids) for (const inner of snap_descent(doc, id)) agent_id_set.add(inner);
2490
+ const agents = [];
2491
+ for (const id of agent_id_set) {
2492
+ const r = this.bbox_world_for_snap(id);
2493
+ if (r) agents.push(r);
2494
+ }
2495
+ const neighbors = [];
2496
+ for (const id of neighbor_ids) {
2497
+ const r = this.bbox_world_for_snap(id);
2498
+ if (r) neighbors.push(r);
2499
+ }
2500
+ return new SnapSession({
2501
+ agents,
2502
+ neighbors
2503
+ });
2504
+ }
2505
+ /** Cancel any in-flight gesture (orchestrator + active_preview). Used
2506
+ * by Escape and the `cancel_gesture` intent. Returns whether anything
2507
+ * was canceled. */
2508
+ cancel_in_flight() {
2509
+ let canceled = false;
2510
+ if (this.translate_orchestrator.has_active_session()) {
2511
+ this.translate_orchestrator.cancel();
2512
+ canceled = true;
2513
+ }
2514
+ if (this.resize_orchestrator.has_active_session()) {
2515
+ this.resize_orchestrator.cancel();
2516
+ canceled = true;
2517
+ }
2518
+ if (this.rotate_orchestrator.has_active_session()) {
2519
+ this.rotate_orchestrator.cancel();
2520
+ canceled = true;
2521
+ }
2522
+ if (this.active_preview) {
2523
+ this.active_preview.session.discard();
2524
+ this.active_preview = null;
2525
+ canceled = true;
2526
+ }
2527
+ if (this.pending_insert) {
2528
+ if (this.pending_insert.phase === "drawing") {
2529
+ this.pending_insert.session.discard();
2530
+ this.pending_insert.snap_session?.dispose?.();
2531
+ }
2532
+ this.pending_insert = null;
2533
+ this.editor.set_tool({ type: "cursor" });
2534
+ canceled = true;
2535
+ }
2536
+ if (canceled) this.request_redraw();
2537
+ return canceled;
2538
+ }
2539
+ sync_surface_selection() {
2540
+ const state = this.editor.state;
2541
+ if (state.mode === "edit-content") {
2542
+ this.hud.setSelection([]);
2543
+ return;
2544
+ }
2545
+ if (state.selection.length <= 1) {
2546
+ this.hud.setSelection(state.selection);
2547
+ return;
2548
+ }
2549
+ this.hud.setSelection(this.build_selection_groups(state.selection));
2550
+ }
2551
+ /**
2552
+ * Build the HUD's `SelectionGroup[]` for a multi-member selection.
2553
+ * Singleton selections do NOT go through this helper — see
2554
+ * {@link sync_surface_selection} for the policy.
2555
+ */
2556
+ build_selection_groups(selection) {
2557
+ if (selection.length < 2) return [];
2558
+ const rects = [];
2559
+ for (const id of selection) {
2560
+ const r = this.container_box(id);
2561
+ if (r) rects.push(r);
2562
+ }
2563
+ if (rects.length === 0) return [];
2564
+ return [{
2565
+ ids: selection,
2566
+ shape: {
2567
+ kind: "rect",
2568
+ rect: cmath.rect.union(rects)
2569
+ }
2570
+ }];
2571
+ }
2572
+ /**
2573
+ * Return the selection shape for a node. Vector `<line>` nodes return
2574
+ * `{ kind: "line", p1, p2 }` so the HUD lays out endpoint knobs; all
2575
+ * other nodes return `{ kind: "rect", rect }` using the container-space
2576
+ * bounding box.
2577
+ */
2578
+ shape_of(id) {
2579
+ const tag = this.tag_of(id);
2580
+ if (tag === "line") {
2581
+ const line = this.line_endpoints_in_container(id);
2582
+ if (line) return {
2583
+ kind: "line",
2584
+ p1: line.p1,
2585
+ p2: line.p2
2586
+ };
2587
+ }
2588
+ const el = this.element_index.get(id);
2589
+ if (!(el instanceof SVGGraphicsElement) || typeof el.getBBox !== "function" || typeof el.getScreenCTM !== "function" || tag === "svg") {
2590
+ const rect = this.container_box(id);
2591
+ return rect ? {
2592
+ kind: "rect",
2593
+ rect
2594
+ } : null;
2595
+ }
2596
+ let bbox_local;
2597
+ try {
2598
+ const b = el.getBBox();
2599
+ bbox_local = {
2600
+ x: b.x,
2601
+ y: b.y,
2602
+ width: b.width,
2603
+ height: b.height
2604
+ };
2605
+ } catch {
2606
+ const rect = this.container_box(id);
2607
+ return rect ? {
2608
+ kind: "rect",
2609
+ rect
2610
+ } : null;
2611
+ }
2612
+ const ctm = el.getScreenCTM();
2613
+ if (!ctm) {
2614
+ const rect = this.container_box(id);
2615
+ return rect ? {
2616
+ kind: "rect",
2617
+ rect
2618
+ } : null;
2619
+ }
2620
+ if (ctm.b === 0 && ctm.c === 0) {
2621
+ const rect = this.container_box(id);
2622
+ return rect ? {
2623
+ kind: "rect",
2624
+ rect
2625
+ } : null;
2626
+ }
2627
+ const cr = this.container.getBoundingClientRect();
2628
+ const dx = -cr.left + this.container.scrollLeft;
2629
+ const dy = -cr.top + this.container.scrollTop;
2630
+ return {
2631
+ kind: "transformed",
2632
+ local: bbox_local,
2633
+ matrix: [[
2634
+ ctm.a,
2635
+ ctm.c,
2636
+ ctm.e + dx
2637
+ ], [
2638
+ ctm.b,
2639
+ ctm.d,
2640
+ ctm.f + dy
2641
+ ]]
2642
+ };
2643
+ }
2644
+ /**
2645
+ * Project an SVG `<line>`'s `x1,y1,x2,y2` from its own coordinate space
2646
+ * to the container's coordinate space, where the HUD operates.
2647
+ */
2648
+ line_endpoints_in_container(id) {
2649
+ const el = this.element_index.get(id);
2650
+ if (!(el instanceof SVGGraphicsElement)) return null;
2651
+ if (typeof el.getScreenCTM !== "function") return null;
2652
+ const ctm = el.getScreenCTM();
2653
+ if (!ctm || !this.svg_root) return null;
2654
+ const x1 = parseFloat(el.getAttribute("x1") ?? "0");
2655
+ const y1 = parseFloat(el.getAttribute("y1") ?? "0");
2656
+ const x2 = parseFloat(el.getAttribute("x2") ?? "0");
2657
+ const y2 = parseFloat(el.getAttribute("y2") ?? "0");
2658
+ if (!Number.isFinite(x1) || !Number.isFinite(y1)) return null;
2659
+ if (!Number.isFinite(x2) || !Number.isFinite(y2)) return null;
2660
+ const project = (px, py) => {
2661
+ return [ctm.a * px + ctm.c * py + ctm.e, ctm.b * px + ctm.d * py + ctm.f];
2662
+ };
2663
+ const cr = this.container.getBoundingClientRect();
2664
+ const [s1x, s1y] = project(x1, y1);
2665
+ const [s2x, s2y] = project(x2, y2);
2666
+ return {
2667
+ p1: [s1x - cr.left + this.container.scrollLeft, s1y - cr.top + this.container.scrollTop],
2668
+ p2: [s2x - cr.left + this.container.scrollLeft, s2y - cr.top + this.container.scrollTop]
2669
+ };
2670
+ }
2671
+ /** Container-space bounding rect for a node. Callers running a batch
2672
+ * (snap session open, marquee) can pass a pre-read `container_rect`
2673
+ * to avoid the per-call layout flush.
2674
+ *
2675
+ * `<svg>` elements (root or nested) establish a viewport (SVG 2 §7.2).
2676
+ * Their visible canvas is the viewport rect, NOT the union of
2677
+ * descendant geometry that `getBBox()` reports (SVG 2 §4.6.4). For
2678
+ * those we read `getBoundingClientRect()` — the CSSOM rendered box
2679
+ * of the `<svg>` element itself, independent of children. Every
2680
+ * other element type still goes through `getBBox` + `getScreenCTM`. */
2681
+ container_box(id, container_rect) {
2682
+ const el = this.element_index.get(id);
2683
+ if (!el) return null;
2684
+ const ge = el;
2685
+ if (typeof ge.getBBox !== "function" || typeof ge.getScreenCTM !== "function") return null;
2686
+ const cr = container_rect ?? this.container.getBoundingClientRect();
2687
+ if (this.tag_of(id) === "svg") {
2688
+ const r = el.getBoundingClientRect();
2689
+ return {
2690
+ x: r.left - cr.left + this.container.scrollLeft,
2691
+ y: r.top - cr.top + this.container.scrollTop,
2692
+ width: r.width,
2693
+ height: r.height
2694
+ };
2695
+ }
2696
+ let bbox;
2697
+ try {
2698
+ const b = ge.getBBox();
2699
+ bbox = {
2700
+ x: b.x,
2701
+ y: b.y,
2702
+ width: b.width,
2703
+ height: b.height
2704
+ };
2705
+ } catch {
2706
+ return null;
2707
+ }
2708
+ const ctm = ge.getScreenCTM();
2709
+ if (!ctm) return null;
2710
+ const project = (px, py) => ({
2711
+ x: ctm.a * px + ctm.c * py + ctm.e,
2712
+ y: ctm.b * px + ctm.d * py + ctm.f
2713
+ });
2714
+ const corners = [
2715
+ project(bbox.x, bbox.y),
2716
+ project(bbox.x + bbox.width, bbox.y),
2717
+ project(bbox.x + bbox.width, bbox.y + bbox.height),
2718
+ project(bbox.x, bbox.y + bbox.height)
2719
+ ];
2720
+ const xs = corners.map((c) => c.x);
2721
+ const ys = corners.map((c) => c.y);
2722
+ const left = Math.min(...xs);
2723
+ const top = Math.min(...ys);
2724
+ const right = Math.max(...xs);
2725
+ const bottom = Math.max(...ys);
2726
+ return {
2727
+ x: left - cr.left + this.container.scrollLeft,
2728
+ y: top - cr.top + this.container.scrollTop,
2729
+ width: right - left,
2730
+ height: bottom - top
2731
+ };
2732
+ }
2733
+ wire_events() {
2734
+ const owner_doc = this.container.ownerDocument;
2735
+ const win = owner_doc.defaultView ?? window;
2736
+ const on = (target, event, handler) => {
2737
+ target.addEventListener(event, handler);
2738
+ this.teardown.push(() => target.removeEventListener(event, handler));
2739
+ };
2740
+ on(this.container, "pointerdown", (e) => this.dispatch_pointer(e, "pointer_down"));
2741
+ on(win, "pointermove", (e) => this.dispatch_pointer(e, "pointer_move"));
2742
+ on(win, "pointerup", (e) => this.dispatch_pointer(e, "pointer_up"));
2743
+ on(owner_doc, "keydown", (e) => this.on_keydown(e));
2744
+ on(win, "keydown", (e) => {
2745
+ if (e.repeat || !IS_MODIFIER_KEY[e.key]) return;
2746
+ this.sync_modifiers(e);
2747
+ });
2748
+ on(win, "keyup", (e) => {
2749
+ if (!IS_MODIFIER_KEY[e.key]) return;
2750
+ this.sync_modifiers(e);
2751
+ });
2752
+ on(win, "blur", () => this.sync_modifiers(null));
2753
+ on(this.container, "contextmenu", (e) => e.preventDefault());
2754
+ }
2755
+ /**
2756
+ * Master signal for modifier-driven UX consumers (measurement, future
2757
+ * constrained-resize, …). Modifier changes aren't on the pointer-event
2758
+ * path, so derived overlays would otherwise wait for the next pointer
2759
+ * move; redraw eagerly. `null` means modifiers are forced clear
2760
+ * (blur / focus-out).
2761
+ */
2762
+ sync_modifiers(e) {
2763
+ const next = e ? {
2764
+ shift: e.shiftKey,
2765
+ alt: e.altKey,
2766
+ meta: e.metaKey,
2767
+ ctrl: e.ctrlKey
2768
+ } : NO_MODS;
2769
+ const prev = this.hud.modifiers();
2770
+ if (prev.shift === next.shift && prev.alt === next.alt && prev.meta === next.meta && prev.ctrl === next.ctrl) return;
2771
+ const response = this.hud.dispatch({
2772
+ kind: "modifiers",
2773
+ mods: next
2774
+ });
2775
+ if (prev.shift !== next.shift && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
2776
+ if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
2777
+ if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
2778
+ this.redraw();
2779
+ if (response.cursorChanged) this.sync_cursor();
2780
+ if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
2781
+ }
2782
+ dispatch_pointer(e, kind) {
2783
+ if (this.text_edit) {
2784
+ const target_el = this.text_edit_target ? this.element_index.get(this.text_edit_target) : null;
2785
+ const over_target = !!target_el && e.target instanceof Element && (e.target === target_el || target_el.contains(e.target));
2786
+ if (kind === "pointer_down") {
2787
+ e.preventDefault();
2788
+ if (over_target) this.text_edit.pointerDown(e.clientX, e.clientY, e.shiftKey);
2789
+ else this.text_edit.commit();
2790
+ } else if (kind === "pointer_move") {
2791
+ this.text_edit.pointerMove(e.clientX, e.clientY);
2792
+ this.container.style.cursor = over_target ? "text" : "default";
2793
+ } else if (kind === "pointer_up") this.text_edit.pointerUp();
2794
+ return;
2795
+ }
2796
+ const cr = this.container.getBoundingClientRect();
2797
+ const x = e.clientX - cr.left;
2798
+ const y = e.clientY - cr.top;
2799
+ this.last_pointer.x = x;
2800
+ this.last_pointer.y = y;
2801
+ this.last_pointer_valid = true;
2802
+ const mods = {
2803
+ shift: e.shiftKey,
2804
+ alt: e.altKey,
2805
+ meta: e.metaKey,
2806
+ ctrl: e.ctrlKey
2807
+ };
2808
+ const tool = this.current_tool;
2809
+ if (tool.type === "insert") {
2810
+ if (kind === "pointer_down") {
2811
+ if (e.button === 0) {
2812
+ try {
2813
+ this.container.setPointerCapture(e.pointerId);
2814
+ } catch {}
2815
+ this.start_insert_gesture(tool.tag, {
2816
+ x,
2817
+ y
2818
+ });
2819
+ return;
2820
+ }
2821
+ } else if (this.pending_insert) {
2822
+ if (kind === "pointer_move") {
2823
+ this.update_insert_gesture({
2824
+ x,
2825
+ y
2826
+ }, mods);
2827
+ return;
2828
+ }
2829
+ if (kind === "pointer_up" && e.button === 0) {
2830
+ this.commit_insert_gesture({
2831
+ x,
2832
+ y
2833
+ }, mods);
2834
+ return;
2835
+ }
2836
+ }
2837
+ }
2838
+ const button = e.button === 0 ? "primary" : e.button === 2 ? "secondary" : "middle";
2839
+ let event;
2840
+ if (kind === "pointer_move") event = {
2841
+ kind,
2842
+ x,
2843
+ y,
2844
+ mods
2845
+ };
2846
+ else event = {
2847
+ kind,
2848
+ x,
2849
+ y,
2850
+ button,
2851
+ mods
2852
+ };
2853
+ const gesture_before_kind = kind === "pointer_up" ? null : this.hud.gesture().kind;
2854
+ const response = this.hud.dispatch(event);
2855
+ if (gesture_before_kind === "idle" && this.hud.gesture().kind !== "idle") try {
2856
+ this.container.setPointerCapture(e.pointerId);
2857
+ } catch {}
2858
+ if (response.needsRedraw) this.redraw();
2859
+ if (response.cursorChanged) this.sync_cursor();
2860
+ if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
2861
+ }
2862
+ static {
2863
+ this.INSERT_DRAG_THRESHOLD_PX_SQ = 4;
2864
+ }
2865
+ /** Arm an insertion gesture on pointer-down. No IR mutation — keeping
2866
+ * the IR pristine through the click window lets click-no-drag commit
2867
+ * as one atomic `commands.insert` rather than create-zero-then-resize. */
2868
+ start_insert_gesture(tag, screen_pt) {
2869
+ this.pending_insert = {
2870
+ phase: "armed",
2871
+ tag,
2872
+ anchor: this.camera.screen_to_world(screen_pt),
2873
+ anchor_screen: {
2874
+ x: screen_pt.x,
2875
+ y: screen_pt.y
2876
+ }
2877
+ };
2878
+ }
2879
+ /** Per-frame update. `armed` waits for the drag threshold; `drawing`
2880
+ * pushes a frame through the preview session. */
2881
+ update_insert_gesture(screen_pt, mods) {
2882
+ const cur = this.pending_insert;
2883
+ if (!cur) return;
2884
+ if (cur.phase === "armed") {
2885
+ const dx = screen_pt.x - cur.anchor_screen.x;
2886
+ const dy = screen_pt.y - cur.anchor_screen.y;
2887
+ if (dx * dx + dy * dy < DomSurface.INSERT_DRAG_THRESHOLD_PX_SQ) return;
2888
+ this.arm_to_draw(cur);
2889
+ }
2890
+ const live = this.pending_insert;
2891
+ if (live?.phase !== "drawing") return;
2892
+ this.push_drawing_frame(live, screen_pt, mods);
2893
+ }
2894
+ /** Transition `armed` → `drawing`: open `insert_preview` + snap session. */
2895
+ arm_to_draw(armed) {
2896
+ const session = this.editor.commands.insert_preview(armed.tag, initial_attrs(armed.tag, armed.anchor));
2897
+ const snap_session = this.editor.style.snap_enabled && (armed.tag === "rect" || armed.tag === "ellipse") ? this.open_snap_session_for([session.id]) : null;
2898
+ this.pending_insert = {
2899
+ phase: "drawing",
2900
+ tag: armed.tag,
2901
+ anchor: armed.anchor,
2902
+ anchor_screen: armed.anchor_screen,
2903
+ session,
2904
+ snap_session
2905
+ };
2906
+ }
2907
+ /** Push one drag frame through the preview session. */
2908
+ push_drawing_frame(drawing, screen_pt, mods) {
2909
+ let world = this.camera.screen_to_world(screen_pt);
2910
+ if (drawing.snap_session) {
2911
+ const corrected = this.snap_insert_point(drawing.tag, drawing.anchor, drawing.anchor_screen, world, drawing.snap_session);
2912
+ if (corrected) world = corrected;
2913
+ }
2914
+ const dm = {
2915
+ shift: mods.shift,
2916
+ alt: mods.alt
2917
+ };
2918
+ drawing.session.update(compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
2919
+ }
2920
+ /** Commit on pointer-up. `armed` → one-shot `commands.insert` with
2921
+ * `default_attrs` (click-no-drag, never touches the IR mid-gesture).
2922
+ * `drawing` → push final frame + close the preview. */
2923
+ commit_insert_gesture(screen_pt, mods) {
2924
+ const cur = this.pending_insert;
2925
+ if (!cur) return;
2926
+ if (cur.phase === "armed") this.editor.commands.insert(cur.tag, default_attrs(cur.tag, cur.anchor));
2927
+ else {
2928
+ this.push_drawing_frame(cur, screen_pt, mods);
2929
+ cur.session.commit();
2930
+ cur.snap_session?.dispose?.();
2931
+ }
2932
+ this.pending_insert = null;
2933
+ this.editor.set_tool({ type: "cursor" });
2934
+ }
2935
+ /** Snap the in-progress insert's moving corner to neighbor geometry.
2936
+ * Returns a corrected world-space pointer, or `null` to leave the
2937
+ * input uncorrected. Operates in container CSS-px (the snap engine's
2938
+ * native space) and projects the correction back to world via
2939
+ * `camera.zoom`. Rect / ellipse only. */
2940
+ snap_insert_point(tag, anchor, anchor_screen, current, snap_session) {
2941
+ if (tag !== "rect" && tag !== "ellipse") return null;
2942
+ const zoom = this.camera.zoom;
2943
+ if (zoom <= 0) return null;
2944
+ if (current.x === anchor.x && current.y === anchor.y) return null;
2945
+ const current_screen = this.camera.world_to_screen(current);
2946
+ const effective = {
2947
+ x: Math.min(anchor_screen.x, current_screen.x),
2948
+ y: Math.min(anchor_screen.y, current_screen.y),
2949
+ width: Math.abs(current_screen.x - anchor_screen.x),
2950
+ height: Math.abs(current_screen.y - anchor_screen.y)
2951
+ };
2952
+ const edges_x = current.x === anchor.x ? null : current.x > anchor.x ? "right" : "left";
2953
+ const edges_y = current.y === anchor.y ? null : current.y > anchor.y ? "bottom" : "top";
2954
+ const opts = {
2955
+ enabled: this.editor.style.snap_enabled,
2956
+ threshold_px: this.editor.style.snap_threshold_px
2957
+ };
2958
+ const result = snap_session.snap_resize(effective, {
2959
+ x: edges_x,
2960
+ y: edges_y
2961
+ }, opts);
2962
+ if (result.dx === 0 && result.dy === 0) return current;
2963
+ return {
2964
+ x: current.x + result.dx / zoom,
2965
+ y: current.y + result.dy / zoom
2966
+ };
2967
+ }
2968
+ sync_cursor() {
2969
+ if (this.text_edit) {
2970
+ this.container.style.cursor = "default";
2971
+ return;
2972
+ }
2973
+ if (this.current_tool.type === "insert") {
2974
+ this.container.style.cursor = "crosshair";
2975
+ return;
2976
+ }
2977
+ this.container.style.cursor = this.hud.cursorCss();
2978
+ }
2979
+ on_keydown(e) {
2980
+ if (this.text_edit) return;
2981
+ if (e.code === "Escape") this.cancel_in_flight();
2982
+ 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;
2983
+ if (this.editor.keymap.claims(e)) e.preventDefault();
2984
+ this.editor.keymap.dispatch(e);
2985
+ }
2986
+ commit_intent(intent) {
2987
+ switch (intent.kind) {
2988
+ case "select":
2989
+ this.editor.commands.select(intent.ids, { mode: intent.mode });
2990
+ return;
2991
+ case "deselect_all":
2992
+ this.editor.commands.deselect();
2993
+ return;
2994
+ case "translate":
2995
+ this.handle_translate(intent);
2996
+ return;
2997
+ case "resize":
2998
+ this.handle_resize(intent);
2999
+ return;
3000
+ case "rotate":
3001
+ this.handle_rotate(intent);
3002
+ return;
3003
+ case "marquee_select":
3004
+ this.handle_marquee(intent);
3005
+ return;
3006
+ case "set_endpoint":
3007
+ this.handle_set_endpoint(intent);
3008
+ return;
3009
+ case "enter_content_edit":
3010
+ this.editor.commands.select(intent.id);
3011
+ this.editor.enter_content_edit(intent.id);
3012
+ return;
3013
+ case "cancel_gesture":
3014
+ this.cancel_in_flight();
3015
+ return;
3016
+ }
3017
+ }
3018
+ handle_translate(intent) {
3019
+ if (intent.ids.length === 0) return;
3020
+ const zoom = this.camera.zoom || 1;
3021
+ const dx_world = intent.dx / zoom;
3022
+ const dy_world = intent.dy / zoom;
3023
+ this.translate_orchestrator.drive({
3024
+ ids: intent.ids,
3025
+ movement: [dx_world, dy_world]
3026
+ }, this.current_translate_modifiers(), {
3027
+ phase: intent.phase,
3028
+ policy: "engine",
3029
+ snap: true
3030
+ });
3031
+ if (intent.phase === "commit") this.request_redraw();
3032
+ }
3033
+ /** Snapshot of HUD modifier state mapped to pipeline `TranslateModifiers`.
3034
+ * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
3035
+ * read live so mid-drag Shift press/release reflects on the next pass. */
3036
+ current_translate_modifiers() {
3037
+ return {
3038
+ axis_lock: this.hud.modifiers().shift ? "by_dominance" : "off",
3039
+ force_disable_snap: false
3040
+ };
3041
+ }
3042
+ /** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
3043
+ * pull-at-consume discipline as `current_translate_modifiers`. */
3044
+ current_resize_modifiers() {
3045
+ return {
3046
+ aspect_lock: this.hud.modifiers().shift ? "uniform" : "off",
3047
+ force_disable_snap: false
3048
+ };
3049
+ }
3050
+ handle_resize(intent) {
3051
+ if (intent.ids.length === 0) return;
3052
+ for (const id of intent.ids) if (!is_resizable_node(this.editor.document, id)) return;
3053
+ const dir = intent.anchor;
3054
+ let target_width;
3055
+ let target_height;
3056
+ if (intent.shape && intent.shape.kind === "transformed") {
3057
+ target_width = intent.shape.local.width;
3058
+ target_height = intent.shape.local.height;
3059
+ } else {
3060
+ const zoom = this.camera.zoom || 1;
3061
+ target_width = intent.rect.width / zoom;
3062
+ target_height = intent.rect.height / zoom;
3063
+ }
3064
+ this.resize_orchestrator.drive({
3065
+ ids: intent.ids,
3066
+ direction: dir,
3067
+ target_width,
3068
+ target_height
3069
+ }, this.current_resize_modifiers(), {
3070
+ phase: intent.phase === "commit" ? "commit" : "preview",
3071
+ snap: true
3072
+ });
3073
+ if (intent.phase === "commit") this.request_redraw();
3074
+ }
3075
+ /** Snapshot of HUD modifier state mapped to `RotateModifiers`. Same
3076
+ * pull-at-consume discipline as the translate / resize equivalents. */
3077
+ current_rotate_modifiers() {
3078
+ return {
3079
+ angle_snap: this.hud.modifiers().shift ? "step" : "off",
3080
+ force_disable_snap: false
3081
+ };
3082
+ }
3083
+ handle_rotate(intent) {
3084
+ if (intent.ids.length === 0) return;
3085
+ const result = this.rotate_orchestrator.drive({
3086
+ ids: intent.ids,
3087
+ angle_radians: intent.angle
3088
+ }, this.current_rotate_modifiers(), { phase: intent.phase === "commit" ? "commit" : "preview" });
3089
+ if (result && result.outcome && result.outcome.kind === "refused") this.emit_rotate_refusal(result.outcome.verdicts);
3090
+ if (intent.phase === "commit") this.request_redraw();
3091
+ }
3092
+ /** Map each refusal verdict to a user-facing chip message. v1 fires
3093
+ * one toast for the first refusal encountered — the user can address
3094
+ * it and try again. Refusal verdicts come straight from
3095
+ * `is_rotatable`. */
3096
+ emit_rotate_refusal(verdicts) {
3097
+ for (const v of verdicts.values()) {
3098
+ if (v.kind !== "refuse") continue;
3099
+ 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.";
3100
+ const hud = this.hud;
3101
+ if (typeof hud.setTransientToast === "function") hud.setTransientToast(message);
3102
+ else console.warn(`[svg-editor] ${message}`);
3103
+ return;
3104
+ }
3105
+ }
3106
+ /**
3107
+ * Apply a `set_endpoint` intent — moving one endpoint of a vector
3108
+ * `<line>` to a new container-space position. Unprojects to the element's
3109
+ * own (SVG) coord space and updates the corresponding attribute.
3110
+ */
3111
+ handle_set_endpoint(intent) {
3112
+ const id = intent.id;
3113
+ if (this.tag_of(id) !== "line") return;
3114
+ const internal = this.editor_internal();
3115
+ const doc = internal.doc;
3116
+ const emit = internal.emit;
3117
+ if (!this.active_preview || this.active_preview.kind !== "endpoint" || this.active_preview.id !== id || this.active_preview.endpoint !== intent.endpoint) {
3118
+ if (this.active_preview) this.active_preview.session.discard();
3119
+ const initial = {
3120
+ x1: numAttr(doc, id, "x1"),
3121
+ y1: numAttr(doc, id, "y1"),
3122
+ x2: numAttr(doc, id, "x2"),
3123
+ y2: numAttr(doc, id, "y2")
3124
+ };
3125
+ this.active_preview = {
3126
+ kind: "endpoint",
3127
+ id,
3128
+ endpoint: intent.endpoint,
3129
+ initial,
3130
+ session: internal.history.preview("set-endpoint")
3131
+ };
3132
+ }
3133
+ const initial = this.active_preview.initial;
3134
+ const endpoint = this.active_preview.endpoint;
3135
+ const pos_own = this.container_point_in_own_frame(id, intent.pos[0], intent.pos[1]);
3136
+ if (!pos_own) return;
3137
+ const target_x = pos_own.x;
3138
+ const target_y = pos_own.y;
3139
+ const apply = () => {
3140
+ if (endpoint === "p1") {
3141
+ doc.set_attr(id, "x1", String(target_x));
3142
+ doc.set_attr(id, "y1", String(target_y));
3143
+ } else {
3144
+ doc.set_attr(id, "x2", String(target_x));
3145
+ doc.set_attr(id, "y2", String(target_y));
3146
+ }
3147
+ emit();
3148
+ };
3149
+ const revert = () => {
3150
+ doc.set_attr(id, "x1", String(initial.x1));
3151
+ doc.set_attr(id, "y1", String(initial.y1));
3152
+ doc.set_attr(id, "x2", String(initial.x2));
3153
+ doc.set_attr(id, "y2", String(initial.y2));
3154
+ emit();
3155
+ };
3156
+ this.active_preview.session.set({
3157
+ providerId: "svg-editor",
3158
+ apply,
3159
+ revert
3160
+ });
3161
+ if (intent.phase === "commit") {
3162
+ this.active_preview.session.commit();
3163
+ this.active_preview = null;
3164
+ }
3165
+ }
3166
+ /**
3167
+ * Convert a container-space point to the element's own SVG coord space.
3168
+ * Inverse of `line_endpoints_in_container`'s projection.
3169
+ */
3170
+ container_point_in_own_frame(id, cx, cy) {
3171
+ const el = this.element_index.get(id);
3172
+ if (!(el instanceof SVGGraphicsElement)) return null;
3173
+ if (typeof el.getScreenCTM !== "function") return null;
3174
+ const ctm = el.getScreenCTM();
3175
+ if (!ctm || !this.svg_root) return null;
3176
+ const cr = this.container.getBoundingClientRect();
3177
+ const inv = ctm.inverse();
3178
+ const p = this.svg_root.createSVGPoint();
3179
+ p.x = cx + cr.left - this.container.scrollLeft;
3180
+ p.y = cy + cr.top - this.container.scrollTop;
3181
+ const t = p.matrixTransform(inv);
3182
+ return {
3183
+ x: t.x,
3184
+ y: t.y
3185
+ };
3186
+ }
3187
+ handle_marquee(intent) {
3188
+ if (intent.phase !== "commit") return;
3189
+ const ids = [];
3190
+ for (const id of this.element_index.keys()) {
3191
+ if (id === this.editor.tree().root) continue;
3192
+ const box = this.container_box(id);
3193
+ if (!box) continue;
3194
+ if (cmath.rect.intersects(box, intent.rect)) ids.push(id);
3195
+ }
3196
+ if (ids.length === 0) {
3197
+ if (!intent.additive) this.editor.commands.deselect();
3198
+ return;
3199
+ }
3200
+ this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
3201
+ }
3202
+ enter_content_edit(id) {
3203
+ if (this.text_edit) return false;
3204
+ const el = this.element_index.get(id);
3205
+ if (!(el instanceof SVGElement)) return false;
3206
+ const doc = this.editor._internal;
3207
+ if (!(el instanceof SVGTextContentElement)) return false;
3208
+ this.text_edit_target = id;
3209
+ this.text_edit_original = doc.doc.text_of(id);
3210
+ this.text_edit = TEXT_EDIT_PENDING;
3211
+ this.editor.commands.set_mode("edit-content");
3212
+ this.sync_surface_selection();
3213
+ this.sync_cursor();
3214
+ this.redraw();
3215
+ const text_surface = new SvgTextSurface(this.element_index.get(id) ?? el);
3216
+ const is_mac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
3217
+ let settled = false;
3218
+ const cleanup_after_commit_or_cancel = () => {
3219
+ this.editor.commands.set_mode("select");
3220
+ this.render();
3221
+ this.sync_surface_selection();
3222
+ this.sync_cursor();
3223
+ this.redraw();
3224
+ this.text_edit = null;
3225
+ this.text_edit_target = null;
3226
+ };
3227
+ this.text_edit = createTextEditor({
3228
+ container: this.container,
3229
+ initialText: this.text_edit_original,
3230
+ layout: text_surface,
3231
+ surface: text_surface,
3232
+ isMac: is_mac,
3233
+ ariaLabel: "edit svg text",
3234
+ requiresMutationsForCommit: (text) => /\s{2,}|^\s|\s$/.test(text),
3235
+ callbacks: {
3236
+ onChange: (text) => {
3237
+ doc.doc.set_text(id, text);
3238
+ },
3239
+ onCommit: (final_text) => {
3240
+ if (settled) return;
3241
+ settled = true;
3242
+ doc.doc.set_text(id, this.text_edit_original);
3243
+ cleanup_after_commit_or_cancel();
3244
+ if (final_text !== this.text_edit_original) this.editor.commands.set_text(final_text);
3245
+ },
3246
+ onCancel: () => {
3247
+ if (settled) return;
3248
+ settled = true;
3249
+ doc.doc.set_text(id, this.text_edit_original);
3250
+ cleanup_after_commit_or_cancel();
3251
+ doc.emit();
3252
+ },
3253
+ onUndoFallthrough: () => {
3254
+ this.text_edit?.commit();
3255
+ this.editor.commands.undo();
3256
+ },
3257
+ onRedoFallthrough: () => {
3258
+ this.text_edit?.commit();
3259
+ this.editor.commands.redo();
3260
+ }
3261
+ }
3262
+ });
3263
+ return true;
3264
+ }
3265
+ tag_of(id) {
3266
+ return this.editor.tree().nodes.get(id)?.tag ?? "";
3267
+ }
3268
+ bbox_local(id) {
3269
+ const el = this.element_index.get(id);
3270
+ if (!el) return null;
3271
+ const ge = el;
3272
+ if (typeof ge.getBBox !== "function") return null;
3273
+ try {
3274
+ const b = ge.getBBox();
3275
+ return {
3276
+ x: b.x,
3277
+ y: b.y,
3278
+ width: b.width,
3279
+ height: b.height
3280
+ };
3281
+ } catch {
3282
+ return null;
3283
+ }
3284
+ }
3285
+ /** Doc-space AABB of `id`'s rendered geometry — local box projected
3286
+ * through the element's own `transform=`. This is the rect snap,
3287
+ * resize-baseline, and rotate-pivot consumers want: where the user
3288
+ * actually sees the element in the root SVG's user-coordinate system.
3289
+ *
3290
+ * Flat-doc design target: ancestor transforms (`<g transform=...>`)
3291
+ * are out of scope; only the element's own transform is projected.
3292
+ * `getBBox` (called via `bbox_local`) ignores the element's transform
3293
+ * per SVG 2 §4.6.4, so the projection here is what bridges the gap. */
3294
+ bbox_world(id) {
3295
+ const local = this.bbox_local(id);
3296
+ if (!local) return null;
3297
+ return project_local_bbox(local, this.editor.document.get_attr(id, "transform"));
3298
+ }
3299
+ /** World-space rect for snap purposes. Differs from `bbox_world` for
3300
+ * `<svg>` viewport-establishing elements: `getBBox()` on an `<svg>`
3301
+ * reports the union of descendant geometry (SVG 2 §4.6.4), which —
3302
+ * when the dragged element is a descendant — silently turns the
3303
+ * dragged element's own pre-gesture position into a snap target via
3304
+ * the parent's edges. Use the viewport extent instead so the root
3305
+ * SVG's snap edges represent the canvas boundary, not "wherever the
3306
+ * children happen to be right now". */
3307
+ bbox_world_for_snap(id) {
3308
+ if (this.tag_of(id) === "svg") {
3309
+ const el = this.element_index.get(id);
3310
+ if (el instanceof SVGSVGElement) {
3311
+ const vp = svg_viewport_bounds(el);
3312
+ if (vp) return vp;
3313
+ }
3314
+ }
3315
+ return this.bbox_world(id);
3316
+ }
3317
+ editor_internal() {
3318
+ return this.editor._internal;
3319
+ }
3320
+ };
3321
+ function numAttr(doc, id, name) {
3322
+ return svg_parse.parse_number(doc.get_attr(id, name));
3323
+ }
3324
+ /** World-space viewport rect of an `<svg>` element. Prefers `viewBox`
3325
+ * (the declared user-space rect — what the user perceives as canvas),
3326
+ * falls back to `width`/`height` at (0,0). For nested `<svg>` with a
3327
+ * positional `x`/`y`, the declared viewBox/(0,0) is in the nested
3328
+ * element's OWN user space; callers are responsible for CTM
3329
+ * projection if a different frame is desired. v1 nested-svg story is
3330
+ * documented in docs/wg/feat-svg-editor/geometry.md as out of scope. */
3331
+ function svg_viewport_bounds(el) {
3332
+ const vb = el.getAttribute("viewBox");
3333
+ if (vb) {
3334
+ const parts = vb.trim().split(/[\s,]+/).map(Number);
3335
+ if (parts.length === 4 && parts.every(Number.isFinite)) return {
3336
+ x: parts[0],
3337
+ y: parts[1],
3338
+ width: parts[2],
3339
+ height: parts[3]
3340
+ };
3341
+ }
3342
+ const w = parseFloat(el.getAttribute("width") ?? "");
3343
+ const h = parseFloat(el.getAttribute("height") ?? "");
3344
+ if (Number.isFinite(w) && Number.isFinite(h)) return {
3345
+ x: 0,
3346
+ y: 0,
3347
+ width: w,
3348
+ height: h
3349
+ };
3350
+ return null;
3351
+ }
3352
+ /** Index of the next side ≥ `start` (in [top, right, bottom, left] order)
3353
+ * where both measurements have a positive distance — i.e. the next side
3354
+ * that `measurementToHUDDraw` would emit a labelled line for. -1 if
3355
+ * none. */
3356
+ /** Among the edges of a point sequence, pick the one whose outward
3357
+ * normal points most downward — i.e. which edge visually FORMS the
3358
+ * bottom of the shape. For a CW-wound polygon (TL/TR/BR/BL, in y-down
3359
+ * screen space) the outward normal of an edge `(p1, p2)` is the edge
3360
+ * direction rotated 90° CW: `(dy, -dx)`, so the normal's Y component
3361
+ * equals `-dx / |edge|`. Ranking by *normalized* outward-Y, not by
3362
+ * midpoint-Y, makes a long-thin rotated rect anchor the label on its
3363
+ * long bottom edge (correct) instead of the short bottom corner
3364
+ * (which happens to have a lower midpoint but doesn't read as "the
3365
+ * bottom side"). Returns the midpoint as `anchor` and the edge's
3366
+ * direction angle normalized to `(-π/2, π/2]` — so a HUDLine label
3367
+ * drawn at the anchor with `labelAngle = angle` reads right-side-up
3368
+ * and its perpendicular offset points further down.
3369
+ *
3370
+ * - `closed = true`: pts are polygon vertices, all consecutive pairs
3371
+ * (incl. last→first) are edges. Use for rects (4 corners → 4 edges).
3372
+ * - `closed = false`: only consecutive pairs in order are edges. Use
3373
+ * for lines (2 endpoints → 1 edge). With a single edge there's no
3374
+ * "outward" to rank — the function just returns the midpoint and
3375
+ * normalized angle. */
3376
+ function pick_lowest_side_anchor(pts, closed) {
3377
+ const edge_count = closed ? pts.length : pts.length - 1;
3378
+ let best_score = -Infinity;
3379
+ let best = null;
3380
+ for (let i = 0; i < edge_count; i++) {
3381
+ const p1 = pts[i];
3382
+ const p2 = pts[(i + 1) % pts.length];
3383
+ const dx = p2[0] - p1[0];
3384
+ const dy = p2[1] - p1[1];
3385
+ const len = Math.hypot(dx, dy) || 1;
3386
+ const score = -dx / len;
3387
+ if (score > best_score) {
3388
+ best_score = score;
3389
+ let theta = Math.atan2(dy, dx);
3390
+ if (theta > Math.PI / 2) theta -= Math.PI;
3391
+ else if (theta <= -Math.PI / 2) theta += Math.PI;
3392
+ best = {
3393
+ anchor: [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2],
3394
+ angle: theta
3395
+ };
3396
+ }
3397
+ }
3398
+ return best;
3399
+ }
3400
+ function next_labellable_side(start, a, b) {
3401
+ for (let i = start; i < 4; i++) if (a.distance[i] > 0 && b.distance[i] > 0) return i;
3402
+ return -1;
3403
+ }
3404
+ /** Concatenate the primitive arrays of N `HUDDraw`s. `undefined` inputs
3405
+ * collapse cleanly so callers can pass per-feature builders without
3406
+ * null-guarding each one. */
3407
+ function merge_hud_draws(...draws) {
3408
+ const present = draws.filter((d) => d !== void 0);
3409
+ if (present.length === 0) return void 0;
3410
+ if (present.length === 1) return present[0];
3411
+ const out = {};
3412
+ for (const d of present) {
3413
+ if (d.lines) (out.lines ??= []).push(...d.lines);
3414
+ if (d.rects) (out.rects ??= []).push(...d.rects);
3415
+ if (d.rules) (out.rules ??= []).push(...d.rules);
3416
+ if (d.points) (out.points ??= []).push(...d.points);
3417
+ if (d.polylines) (out.polylines ??= []).push(...d.polylines);
3418
+ if (d.screenRects) (out.screenRects ??= []).push(...d.screenRects);
3419
+ }
3420
+ return out;
3421
+ }
3422
+ var SvgGeometryDriver = class {
3423
+ constructor(accessors) {
3424
+ this.accessors = accessors;
3425
+ }
3426
+ bounds_of(id) {
3427
+ const el = this.accessors.element_for(id);
3428
+ if (!el) return null;
3429
+ if (el instanceof SVGSVGElement) return svg_viewport_bounds(el);
3430
+ const ge = el;
3431
+ if (typeof ge.getBBox !== "function" || typeof ge.getCTM !== "function") return null;
3432
+ let bbox;
3433
+ try {
3434
+ const b = ge.getBBox();
3435
+ bbox = {
3436
+ x: b.x,
3437
+ y: b.y,
3438
+ width: b.width,
3439
+ height: b.height
3440
+ };
3441
+ } catch {
3442
+ return null;
3443
+ }
3444
+ const ctm = ge.getCTM();
3445
+ if (!ctm) return bbox;
3446
+ const project = (px, py) => ({
3447
+ x: ctm.a * px + ctm.c * py + ctm.e,
3448
+ y: ctm.b * px + ctm.d * py + ctm.f
3449
+ });
3450
+ const corners = [
3451
+ project(bbox.x, bbox.y),
3452
+ project(bbox.x + bbox.width, bbox.y),
3453
+ project(bbox.x + bbox.width, bbox.y + bbox.height),
3454
+ project(bbox.x, bbox.y + bbox.height)
3455
+ ];
3456
+ const xs = corners.map((c) => c.x);
3457
+ const ys = corners.map((c) => c.y);
3458
+ const left = Math.min(...xs);
3459
+ const top = Math.min(...ys);
3460
+ const right = Math.max(...xs);
3461
+ const bottom = Math.max(...ys);
3462
+ return {
3463
+ x: left,
3464
+ y: top,
3465
+ width: right - left,
3466
+ height: bottom - top
3467
+ };
3468
+ }
3469
+ bounds_of_many(ids) {
3470
+ const out = /* @__PURE__ */ new Map();
3471
+ for (const id of ids) {
3472
+ const r = this.bounds_of(id);
3473
+ if (r) out.set(id, r);
3474
+ }
3475
+ return out;
3476
+ }
3477
+ nodes_in_rect(rect) {
3478
+ const root = this.accessors.root();
3479
+ if (!root) return [];
3480
+ const hits = [];
3481
+ root.querySelectorAll(`[${ID_ATTR}]`).forEach((el) => {
3482
+ const id = el.getAttribute(ID_ATTR);
3483
+ if (!id) return;
3484
+ const b = this.bounds_of(id);
3485
+ if (b && cmath.rect.intersects(b, rect)) hits.push(id);
3486
+ });
3487
+ return hits;
3488
+ }
3489
+ node_at_point(p) {
3490
+ return this.accessors.pick_at_world(p, true);
3491
+ }
3492
+ };
3493
+ var SvgHitShapeDriver = class {
3494
+ constructor(accessors) {
3495
+ this.accessors = accessors;
3496
+ }
3497
+ hit_shape_of(id) {
3498
+ const doc = this.accessors.doc();
3499
+ if (!doc) return null;
3500
+ const intrinsic = hit_shape_of_doc(doc, id);
3501
+ if (intrinsic) return intrinsic;
3502
+ if (is_transparent_tag(doc.tag_of(id))) return null;
3503
+ const bounds = this.accessors.bounds_of(id);
3504
+ if (!bounds) return null;
3505
+ return {
3506
+ kind: "rect",
3507
+ x: bounds.x,
3508
+ y: bounds.y,
3509
+ width: bounds.width,
3510
+ height: bounds.height
3511
+ };
3512
+ }
3513
+ };
3514
+ //#endregion
3515
+ export { Camera as a, MemoizedGeometryProvider as i, Gestures as n, DEFAULT_SNAP_OPTIONS as r, attach_dom_surface as t };