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