@grida/svg-editor 1.0.0-alpha.14 → 1.0.0-alpha.15

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.
@@ -1,6 +1,7 @@
1
- import { _ as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, g as array_shallow_equal, h as group, i as TOOL_CURSOR, l as RotateOrchestrator, m as transform, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel } from "./model-DIzZmeyf.mjs";
1
+ import { _ as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, g as array_shallow_equal, h as group, i as TOOL_CURSOR, l as RotateOrchestrator, m as transform, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel } from "./model-B2UWgViT.mjs";
2
2
  import cmath from "@grida/cmath";
3
3
  import { svg_parse } from "@grida/svg/parse";
4
+ import { SVGShapes } from "@grida/svg/pathdata";
4
5
  import vn from "@grida/vn";
5
6
  import { createTextEditor } from "@grida/text-editor/dom";
6
7
  import { NO_MODS, Surface, measurementToHUDDraw, snapGuideToHUDDraw } from "@grida/hud";
@@ -342,6 +343,12 @@ var MemoizedGeometryProvider = class {
342
343
  node_at_point(p) {
343
344
  return this.driver.node_at_point(p);
344
345
  }
346
+ /** Pass-through. Frame projection depends on live layout, not on the
347
+ * bounds cache, so there is nothing to memoize. Falls back to the raw
348
+ * delta when the driver can't resolve a frame. */
349
+ world_delta_to_local(id, delta) {
350
+ return this.driver.world_delta_to_local?.(id, delta) ?? delta;
351
+ }
345
352
  /** Unsubscribe from both signals. Call on surface detach. */
346
353
  dispose() {
347
354
  for (const unsub of this.unsubscribers) unsub();
@@ -1204,7 +1211,8 @@ function sub_selection_equal(a, b) {
1204
1211
  var VectorEditSession = class {
1205
1212
  constructor(node_id, source, session_d) {
1206
1213
  this.node_id = node_id;
1207
- this.source = source;
1214
+ this._source = source;
1215
+ this._source_before_promotion = null;
1208
1216
  this._session_d = session_d;
1209
1217
  this._last_seen_d = session_d;
1210
1218
  this._selected_vertices = [];
@@ -1212,6 +1220,51 @@ var VectorEditSession = class {
1212
1220
  this._selected_tangents = [];
1213
1221
  this._hovered_control = null;
1214
1222
  }
1223
+ /** Source tag the session currently projects through. See `_source`. */
1224
+ get source() {
1225
+ return this._source;
1226
+ }
1227
+ /**
1228
+ * Flip the source to `path` after the underlying element was promoted
1229
+ * (rect / circle / ellipse → `<path>`). Idempotent: a second call while
1230
+ * already promoted does nothing, so the pre-promotion source captured by
1231
+ * the first flip is never clobbered.
1232
+ */
1233
+ promote_source_to_path() {
1234
+ if (this._source_before_promotion !== null) return;
1235
+ if (this._source.kind === "path") return;
1236
+ this._source_before_promotion = this._source;
1237
+ this._source = {
1238
+ kind: "path",
1239
+ d: this._session_d
1240
+ };
1241
+ }
1242
+ /** Reverse a {@link promote_source_to_path} (gesture undo). No-op if the
1243
+ * source was never promoted. */
1244
+ restore_source() {
1245
+ if (this._source_before_promotion === null) return;
1246
+ this._source = this._source_before_promotion;
1247
+ this._source_before_promotion = null;
1248
+ }
1249
+ /**
1250
+ * Re-sync the source to the document's current tag, outright. Unlike
1251
+ * {@link promote_source_to_path} / {@link restore_source} (which manage a
1252
+ * single primitive→path flip within one gesture), this sets the source to
1253
+ * an authoritative value derived from the live document and clears the
1254
+ * promotion bookkeeping.
1255
+ *
1256
+ * The host calls this when an undo/redo re-types the node out from under a
1257
+ * *different* live session object than the one that performed the original
1258
+ * flip (exit + undo-exit creates a fresh session; the captured session's
1259
+ * `restore_source` then no-ops). Without it the live session could keep
1260
+ * `source.kind === "path"` while the node is back to a primitive, and the
1261
+ * next gesture would write a stray `d` onto the native tag. Re-deriving
1262
+ * from the document keeps the live session authoritative.
1263
+ */
1264
+ sync_source(source) {
1265
+ this._source = source;
1266
+ this._source_before_promotion = null;
1267
+ }
1215
1268
  /** The session's current PathModel-form `d`. Gesture handlers read
1216
1269
  * this instead of `doc.get_attr(node_id, "d")` so they stay tag-
1217
1270
  * oblivious (non-path sources have no `d` on the document). */
@@ -1376,26 +1429,39 @@ var VectorEditSession = class {
1376
1429
  function source_to_session_d(source) {
1377
1430
  switch (source.kind) {
1378
1431
  case "path": return source.d;
1432
+ case "line": return vn.toSVGPathData(vn.fromPolyline([[source.x1, source.y1], [source.x2, source.y2]]));
1379
1433
  case "polyline": return vn.toSVGPathData(vn.fromPolyline(source.points.map((p) => [p[0], p[1]])));
1380
1434
  case "polygon": return vn.toSVGPathData(vn.fromPolygon(source.points.map((p) => [p[0], p[1]])));
1435
+ case "circle": return vn.toSVGPathData(vn.fromEllipse({
1436
+ x: source.cx - source.r,
1437
+ y: source.cy - source.r,
1438
+ width: source.r * 2,
1439
+ height: source.r * 2
1440
+ }));
1441
+ case "ellipse": return vn.toSVGPathData(vn.fromEllipse({
1442
+ x: source.cx - source.rx,
1443
+ y: source.cy - source.ry,
1444
+ width: source.rx * 2,
1445
+ height: source.ry * 2
1446
+ }));
1447
+ case "rect": return SVGShapes.createRect(source.x, source.y, source.width, source.height, source.rx, source.ry).encode();
1381
1448
  }
1382
1449
  }
1383
1450
  /**
1384
- * Tag-aware document write. Given a new path-data `d` from a gesture,
1451
+ * Native-attribute writeback. Given a new path-data `d` from a gesture,
1385
1452
  * project it back into the source tag's native attrs and write those —
1386
- * unless the source is `<path>`, in which case `d` is written
1387
- * directly.
1453
+ * `<path>` takes `d` directly; `<line>` takes `x1/y1/x2/y2`;
1454
+ * `<polyline>` / `<polygon>` take `points`.
1388
1455
  *
1389
- * Returns `true` on success. Returns `false` for non-path sources when
1390
- * the model can no longer be expressed in the source tag's native attrs
1391
- * (tangent introduced, topology change). v1 treats `false` as gesture
1392
- * refusal the caller should NOT fall through and write `d` on a non-
1393
- * path element. Promotion to `<path>` lives in v1.1+.
1456
+ * Returns `true` if the geometry was written natively. Returns `false`
1457
+ * when the source tag cannot express the geometry a curve was introduced
1458
+ * or the topology left the tag's canonical form, OR the source is a
1459
+ * geometry primitive (rect / circle / ellipse) which has no native vector
1460
+ * form at all. A `false` return is the re-type-to-`<path>` signal; the
1461
+ * caller ({@link vector_apply}) handles it. This function never re-types.
1394
1462
  *
1395
- * Symmetric across apply / revert: gesture handlers call this for both
1396
- * the in-flight write and the undo-revert (since both are just "set the
1397
- * geometry to this d"), so apply/revert stay consistent even when one
1398
- * writes native attrs and the other would.
1463
+ * Symmetric across apply / revert: callers use it for both the in-flight
1464
+ * write and the undo-revert (both are just "set the geometry to this d").
1399
1465
  */
1400
1466
  function apply_session_d(doc, node_id, source, d) {
1401
1467
  if (source.kind === "path") {
@@ -1404,10 +1470,62 @@ function apply_session_d(doc, node_id, source, d) {
1404
1470
  }
1405
1471
  const native = PathModel.fromSvgPathD(d).toNativeAttrs(source.kind);
1406
1472
  if (native === null) return false;
1473
+ if (native.kind === "line") {
1474
+ doc.set_attr(node_id, "x1", String(native.x1));
1475
+ doc.set_attr(node_id, "y1", String(native.y1));
1476
+ doc.set_attr(node_id, "x2", String(native.x2));
1477
+ doc.set_attr(node_id, "y2", String(native.y2));
1478
+ return true;
1479
+ }
1407
1480
  const points = native.points.map((p) => `${p[0]},${p[1]}`).join(" ");
1408
1481
  doc.set_attr(node_id, "points", points);
1409
1482
  return true;
1410
1483
  }
1484
+ /**
1485
+ * Session-aware geometry write — the single commit chokepoint the DOM
1486
+ * gesture handlers call so re-typing stays in one place rather than being
1487
+ * reimplemented per gesture. One uniform rule across every source:
1488
+ *
1489
+ * 1. Try native writeback ({@link apply_session_d}). For `<path>` and for
1490
+ * a vertex tag (`line` / `polyline` / `polygon`) whose edit still fits
1491
+ * its native form, this writes and we're done — the element keeps its
1492
+ * tag.
1493
+ * 2. If native writeback refused (a curve was introduced, the topology
1494
+ * escaped the canonical chain, or the source is a geometry primitive
1495
+ * with no native form), re-type the element to `<path>` via
1496
+ * {@link SvgDocument.retype_to_path} and flip the session source to
1497
+ * `path` (so every downstream reader — overlay, gates, the
1498
+ * external-mutation reconciler — behaves correctly).
1499
+ *
1500
+ * Returns the {@link RetypeRecord} token iff this call performed a re-type
1501
+ * (so the caller can pair it with the edit in one history bracket and hand
1502
+ * it to {@link vector_revert} on undo); otherwise `null`.
1503
+ */
1504
+ function vector_apply(doc, session, d) {
1505
+ if (apply_session_d(doc, session.node_id, session.source, d)) return null;
1506
+ const token = doc.retype_to_path(session.node_id, d);
1507
+ if (token) {
1508
+ session.promote_source_to_path();
1509
+ return token;
1510
+ }
1511
+ doc.set_attr(session.node_id, "d", d);
1512
+ return null;
1513
+ }
1514
+ /**
1515
+ * Counterpart to {@link vector_apply}. If this gesture re-typed the element
1516
+ * (a non-null `promotion` token), restore the original tag/attrs and the
1517
+ * session source — the re-type and the edit undo as one step. Otherwise
1518
+ * re-write the baseline geometry natively; for a geometry primitive that
1519
+ * never re-typed, {@link apply_session_d} writes nothing (a correct no-op).
1520
+ */
1521
+ function vector_revert(doc, session, baseline_d, promotion) {
1522
+ if (promotion) {
1523
+ doc.revert_retype(session.node_id, promotion);
1524
+ session.restore_source();
1525
+ return;
1526
+ }
1527
+ apply_session_d(doc, session.node_id, session.source, baseline_d);
1528
+ }
1411
1529
  //#endregion
1412
1530
  //#region src/core/vector-edit/marquee.ts
1413
1531
  let marquee;
@@ -1519,11 +1637,6 @@ const IS_MODIFIER_KEY = {
1519
1637
  * surface skips render() during the in-flight mount and doesn't yank the
1520
1638
  * live `<text>` element out from under the about-to-mount text surface. */
1521
1639
  const TEXT_EDIT_PENDING = { __pending: true };
1522
- /** Per-frame `neighbours: []` for the `vector_of` HUD projection. Polyline
1523
- * and polygon sources never render tangent handles in v1 (curve edits would
1524
- * promote to `<path>`); using a frozen module-level array avoids allocating
1525
- * a fresh empty array per redraw frame. */
1526
- const EMPTY_NEIGHBOURS = Object.freeze([]);
1527
1640
  /**
1528
1641
  * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
1529
1642
  * whose `detach()` is the inverse — DOM cleared, listeners removed,
@@ -1578,6 +1691,7 @@ var DomSurface = class DomSurface {
1578
1691
  this.teardown.push(() => this.attention.dispose());
1579
1692
  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.");
1580
1693
  if (getComputedStyle(container).position === "static") container.style.position = "relative";
1694
+ container.style.overflow = "hidden";
1581
1695
  container.style.userSelect = "none";
1582
1696
  container.style.webkitUserSelect = "none";
1583
1697
  const translate_options = () => {
@@ -1594,7 +1708,8 @@ var DomSurface = class DomSurface {
1594
1708
  emit: () => this.editor_internal().emit(),
1595
1709
  open_preview: (label) => this.editor_internal().history.preview(label),
1596
1710
  open_snap: (ids) => this.open_snap_session_for(ids),
1597
- options: translate_options
1711
+ options: translate_options,
1712
+ project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d
1598
1713
  });
1599
1714
  const resize_options = () => {
1600
1715
  const style = this.editor.style;
@@ -2334,14 +2449,15 @@ var DomSurface = class DomSurface {
2334
2449
  const neighbor_ids = compute_neighborhood(doc, ids);
2335
2450
  const agent_id_set = /* @__PURE__ */ new Set();
2336
2451
  for (const id of ids) for (const inner of snap_descent(doc, id)) agent_id_set.add(inner);
2452
+ const bounds_of = (id) => this._geometry_provider?.bounds_of(id) ?? null;
2337
2453
  const agents = [];
2338
2454
  for (const id of agent_id_set) {
2339
- const r = this.bbox_world_for_snap(id);
2455
+ const r = bounds_of(id);
2340
2456
  if (r) agents.push(r);
2341
2457
  }
2342
2458
  const neighbors = [];
2343
2459
  for (const id of neighbor_ids) {
2344
- const r = this.bbox_world_for_snap(id);
2460
+ const r = bounds_of(id);
2345
2461
  if (r) neighbors.push(r);
2346
2462
  }
2347
2463
  return new SnapSession({
@@ -3259,7 +3375,7 @@ var DomSurface = class DomSurface {
3259
3375
  if (this.text_edit || this.vector_edit) return false;
3260
3376
  const tag = this.tag_of(id);
3261
3377
  if (tag === "text" || tag === "tspan") return this.enter_text_edit(id);
3262
- if (tag === "path" || tag === "polyline" || tag === "polygon") return this.enter_vector_edit(id);
3378
+ if (this.editor_internal().doc.is_vector_edit_target(id) !== null) return this.enter_vector_edit(id);
3263
3379
  return false;
3264
3380
  }
3265
3381
  /**
@@ -3542,11 +3658,11 @@ var DomSurface = class DomSurface {
3542
3658
  b_control: project_point_through_ctm(b_ctrl_local[0], b_ctrl_local[1], ctm, offset)
3543
3659
  };
3544
3660
  }),
3545
- neighbours: this.vector_edit.source.kind === "path" ? model.neighbouringVertices({
3661
+ neighbours: model.neighbouringVertices({
3546
3662
  vertices: this.vector_edit.selected_vertices,
3547
3663
  segments: this.vector_edit.selected_segments,
3548
3664
  tangents: this.vector_edit.selected_tangents
3549
- }) : EMPTY_NEIGHBOURS,
3665
+ }),
3550
3666
  origin: [0, 0]
3551
3667
  };
3552
3668
  }
@@ -3611,11 +3727,54 @@ var DomSurface = class DomSurface {
3611
3727
  replay_vector_session_state(target_node_id, d, selection) {
3612
3728
  const cur = this.vector_edit;
3613
3729
  if (!cur || cur.node_id !== target_node_id) return;
3614
- if (d !== null) cur.mark_seen(d);
3730
+ if (d !== null) {
3731
+ cur.mark_seen(d);
3732
+ const live_source = this.editor_internal().doc.is_vector_edit_target(cur.node_id);
3733
+ if (live_source) cur.sync_source(live_source);
3734
+ }
3615
3735
  cur.restore_selection(selection);
3616
3736
  this.sync_selection_mirror();
3617
3737
  }
3618
3738
  /**
3739
+ * Build the `{ apply, revert }` history step for a vector-edit geometry
3740
+ * delta — the single chokepoint that routes the write through
3741
+ * {@link vector_apply} / {@link vector_revert} so promote-to-path of a
3742
+ * primitive source (rect / circle / ellipse) is handled in one place
3743
+ * rather than per gesture.
3744
+ *
3745
+ * `promo` is the per-gesture token holder (shared by reference across an
3746
+ * `active_preview`'s preview frames and its committed step) so the
3747
+ * promotion that fires on the first frame is the one undo reverses —
3748
+ * promotion + first edit collapse into a single undo step. On redo after
3749
+ * an undo-demote, `apply` re-promotes and refreshes the token.
3750
+ *
3751
+ * `after_selection` / `before_selection` drive sub-selection replay;
3752
+ * pass `null` (preview frames) to skip it.
3753
+ */
3754
+ vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection) {
3755
+ const internal = this.editor_internal();
3756
+ const doc = internal.doc;
3757
+ const emit = internal.emit;
3758
+ const session = this.vector_edit;
3759
+ return {
3760
+ providerId: "svg-editor",
3761
+ apply: () => {
3762
+ if (session) {
3763
+ const tok = vector_apply(doc, session, target_d);
3764
+ if (tok) promo.token = tok;
3765
+ }
3766
+ if (after_selection) this.replay_vector_session_state(node_id, target_d, after_selection);
3767
+ emit();
3768
+ },
3769
+ revert: () => {
3770
+ if (session) vector_revert(doc, session, baseline_d, promo.token);
3771
+ promo.token = null;
3772
+ if (before_selection) this.replay_vector_session_state(node_id, baseline_d, before_selection);
3773
+ emit();
3774
+ }
3775
+ };
3776
+ }
3777
+ /**
3619
3778
  * Push a standalone vector sub-selection change as one history entry.
3620
3779
  *
3621
3780
  * Called by selection-only handlers (vertex / segment / tangent click,
@@ -3707,11 +3866,7 @@ var DomSurface = class DomSurface {
3707
3866
  handle_translate_vertices(intent) {
3708
3867
  if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3709
3868
  const internal = this.editor_internal();
3710
- const doc = internal.doc;
3711
- const emit = internal.emit;
3712
3869
  const node_id = intent.node_id;
3713
- const source = this.vector_edit.source;
3714
- const commit = (d) => apply_session_d(doc, node_id, source, d);
3715
3870
  if (!this.active_preview || this.active_preview.kind !== "vector_vertex_translate" || this.active_preview.node_id !== node_id || !array_shallow_equal(this.active_preview.indices, intent.indices)) {
3716
3871
  if (this.active_preview) {
3717
3872
  if ("session" in this.active_preview) this.active_preview.session.discard();
@@ -3722,6 +3877,7 @@ var DomSurface = class DomSurface {
3722
3877
  this.active_preview = {
3723
3878
  kind: "vector_vertex_translate",
3724
3879
  node_id,
3880
+ promo: { token: null },
3725
3881
  indices: [...intent.indices],
3726
3882
  initial_d,
3727
3883
  baseline_model,
@@ -3747,32 +3903,10 @@ var DomSurface = class DomSurface {
3747
3903
  if (intent.phase === "commit") {
3748
3904
  const before_selection = this.active_preview.before_selection;
3749
3905
  const after_selection = this.vector_edit.snapshot_selection();
3750
- this.active_preview.session.set({
3751
- providerId: "svg-editor",
3752
- apply: () => {
3753
- commit(target_d);
3754
- this.replay_vector_session_state(node_id, target_d, after_selection);
3755
- emit();
3756
- },
3757
- revert: () => {
3758
- commit(baseline_d);
3759
- this.replay_vector_session_state(node_id, baseline_d, before_selection);
3760
- emit();
3761
- }
3762
- });
3906
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
3763
3907
  this.active_preview.session.commit();
3764
3908
  this.active_preview = null;
3765
- } else this.active_preview.session.set({
3766
- providerId: "svg-editor",
3767
- apply: () => {
3768
- commit(target_d);
3769
- emit();
3770
- },
3771
- revert: () => {
3772
- commit(baseline_d);
3773
- emit();
3774
- }
3775
- });
3909
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
3776
3910
  }
3777
3911
  /**
3778
3912
  * `translate_vector_selection` — the sub-selection-aware delta-translate.
@@ -3804,11 +3938,7 @@ var DomSurface = class DomSurface {
3804
3938
  handle_translate_vector_selection(intent) {
3805
3939
  if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3806
3940
  const internal = this.editor_internal();
3807
- const doc = internal.doc;
3808
- const emit = internal.emit;
3809
3941
  const node_id = intent.node_id;
3810
- const source = this.vector_edit.source;
3811
- const commit = (d) => apply_session_d(doc, node_id, source, d);
3812
3942
  const ses = this.vector_edit;
3813
3943
  const current_d = this.read_session_d();
3814
3944
  if (current_d === null) return;
@@ -3845,6 +3975,7 @@ var DomSurface = class DomSurface {
3845
3975
  this.active_preview = {
3846
3976
  kind: "vector_translate_selection",
3847
3977
  node_id,
3978
+ promo: { token: null },
3848
3979
  indices: [...indices],
3849
3980
  tangent_refs: [...tangent_refs],
3850
3981
  initial_d,
@@ -3878,32 +4009,10 @@ var DomSurface = class DomSurface {
3878
4009
  if (intent.phase === "commit") {
3879
4010
  const before_selection = this.active_preview.before_selection;
3880
4011
  const after_selection = this.vector_edit.snapshot_selection();
3881
- this.active_preview.session.set({
3882
- providerId: "svg-editor",
3883
- apply: () => {
3884
- commit(target_d);
3885
- this.replay_vector_session_state(node_id, target_d, after_selection);
3886
- emit();
3887
- },
3888
- revert: () => {
3889
- commit(baseline_d);
3890
- this.replay_vector_session_state(node_id, baseline_d, before_selection);
3891
- emit();
3892
- }
3893
- });
4012
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
3894
4013
  this.active_preview.session.commit();
3895
4014
  this.active_preview = null;
3896
- } else this.active_preview.session.set({
3897
- providerId: "svg-editor",
3898
- apply: () => {
3899
- commit(target_d);
3900
- emit();
3901
- },
3902
- revert: () => {
3903
- commit(baseline_d);
3904
- emit();
3905
- }
3906
- });
4015
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
3907
4016
  }
3908
4017
  /** Mirror handler for `select_tangent` (analogous to `select_vertex`). */
3909
4018
  handle_select_tangent(intent) {
@@ -3928,13 +4037,8 @@ var DomSurface = class DomSurface {
3928
4037
  */
3929
4038
  handle_set_tangent_intent(intent) {
3930
4039
  if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3931
- if (this.vector_edit.source.kind !== "path") return;
3932
4040
  const internal = this.editor_internal();
3933
- const doc = internal.doc;
3934
- const emit = internal.emit;
3935
4041
  const node_id = intent.node_id;
3936
- const source = this.vector_edit.source;
3937
- const commit = (d) => apply_session_d(doc, node_id, source, d);
3938
4042
  if (!this.active_preview || this.active_preview.kind !== "vector_set_tangent" || this.active_preview.node_id !== node_id || this.active_preview.tangent[0] !== intent.tangent[0] || this.active_preview.tangent[1] !== intent.tangent[1]) {
3939
4043
  if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
3940
4044
  const initial_d = this.read_session_d();
@@ -3943,6 +4047,7 @@ var DomSurface = class DomSurface {
3943
4047
  this.active_preview = {
3944
4048
  kind: "vector_set_tangent",
3945
4049
  node_id,
4050
+ promo: { token: null },
3946
4051
  tangent: [intent.tangent[0], intent.tangent[1]],
3947
4052
  initial_d,
3948
4053
  baseline_model,
@@ -3960,32 +4065,10 @@ var DomSurface = class DomSurface {
3960
4065
  if (intent.phase === "commit") {
3961
4066
  const before_selection = this.active_preview.before_selection;
3962
4067
  const after_selection = this.vector_edit.snapshot_selection();
3963
- this.active_preview.session.set({
3964
- providerId: "svg-editor",
3965
- apply: () => {
3966
- commit(target_d);
3967
- this.replay_vector_session_state(node_id, target_d, after_selection);
3968
- emit();
3969
- },
3970
- revert: () => {
3971
- commit(baseline_d);
3972
- this.replay_vector_session_state(node_id, baseline_d, before_selection);
3973
- emit();
3974
- }
3975
- });
4068
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
3976
4069
  this.active_preview.session.commit();
3977
4070
  this.active_preview = null;
3978
- } else this.active_preview.session.set({
3979
- providerId: "svg-editor",
3980
- apply: () => {
3981
- commit(target_d);
3982
- emit();
3983
- },
3984
- revert: () => {
3985
- commit(baseline_d);
3986
- emit();
3987
- }
3988
- });
4071
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
3989
4072
  }
3990
4073
  /**
3991
4074
  * Split a segment at parametric position `t`. One-shot atomic edit; no
@@ -3996,35 +4079,20 @@ var DomSurface = class DomSurface {
3996
4079
  handle_split_segment(intent) {
3997
4080
  if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3998
4081
  const node_id = intent.node_id;
3999
- const source = this.vector_edit.source;
4000
- const commit = (d) => apply_session_d(doc, node_id, source, d);
4001
4082
  const baseline_d = this.read_session_d();
4002
4083
  if (baseline_d === null) return;
4003
4084
  const { model: next_model, new_vertex } = PathModel.fromSvgPathD(baseline_d).splitSegment(intent.segment, intent.t);
4004
4085
  const target_d = next_model.toSvgPathD();
4005
4086
  const internal = this.editor_internal();
4006
- const doc = internal.doc;
4007
- const emit = internal.emit;
4008
4087
  const before_selection = this.vector_edit.snapshot_selection();
4009
4088
  const after_selection = Object.freeze({
4010
4089
  vertices: Object.freeze([new_vertex]),
4011
4090
  segments: Object.freeze([]),
4012
4091
  tangents: Object.freeze([])
4013
4092
  });
4093
+ const promo = { token: null };
4014
4094
  const split_session = internal.history.preview("vector/split-segment");
4015
- split_session.set({
4016
- providerId: "svg-editor",
4017
- apply: () => {
4018
- commit(target_d);
4019
- this.replay_vector_session_state(node_id, target_d, after_selection);
4020
- emit();
4021
- },
4022
- revert: () => {
4023
- commit(baseline_d);
4024
- this.replay_vector_session_state(node_id, baseline_d, before_selection);
4025
- emit();
4026
- }
4027
- });
4095
+ split_session.set(this.vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection));
4028
4096
  split_session.commit();
4029
4097
  this.redraw();
4030
4098
  }
@@ -4039,13 +4107,8 @@ var DomSurface = class DomSurface {
4039
4107
  */
4040
4108
  handle_bend_segment(intent) {
4041
4109
  if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
4042
- if (this.vector_edit.source.kind !== "path") return;
4043
4110
  const internal = this.editor_internal();
4044
- const doc = internal.doc;
4045
- const emit = internal.emit;
4046
4111
  const node_id = intent.node_id;
4047
- const source = this.vector_edit.source;
4048
- const commit = (d) => apply_session_d(doc, node_id, source, d);
4049
4112
  if (!this.active_preview || this.active_preview.kind !== "vector_bend_segment" || this.active_preview.node_id !== node_id || this.active_preview.segment !== intent.segment || this.active_preview.ca !== intent.ca) {
4050
4113
  if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
4051
4114
  const initial_d = this.read_session_d();
@@ -4059,6 +4122,7 @@ var DomSurface = class DomSurface {
4059
4122
  this.active_preview = {
4060
4123
  kind: "vector_bend_segment",
4061
4124
  node_id,
4125
+ promo: { token: null },
4062
4126
  segment: intent.segment,
4063
4127
  ca: intent.ca,
4064
4128
  frozen: {
@@ -4084,32 +4148,10 @@ var DomSurface = class DomSurface {
4084
4148
  if (intent.phase === "commit") {
4085
4149
  const before_selection = this.active_preview.before_selection;
4086
4150
  const after_selection = this.vector_edit.snapshot_selection();
4087
- this.active_preview.session.set({
4088
- providerId: "svg-editor",
4089
- apply: () => {
4090
- commit(target_d);
4091
- this.replay_vector_session_state(node_id, target_d, after_selection);
4092
- emit();
4093
- },
4094
- revert: () => {
4095
- commit(baseline_d);
4096
- this.replay_vector_session_state(node_id, baseline_d, before_selection);
4097
- emit();
4098
- }
4099
- });
4151
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
4100
4152
  this.active_preview.session.commit();
4101
4153
  this.active_preview = null;
4102
- } else this.active_preview.session.set({
4103
- providerId: "svg-editor",
4104
- apply: () => {
4105
- commit(target_d);
4106
- emit();
4107
- },
4108
- revert: () => {
4109
- commit(baseline_d);
4110
- emit();
4111
- }
4112
- });
4154
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
4113
4155
  }
4114
4156
  /**
4115
4157
  * Project a doc-space point (HUD's container CSS-px frame) back to the
@@ -4166,24 +4208,6 @@ var DomSurface = class DomSurface {
4166
4208
  const transform_str = this.editor.document.get_attr(id, "transform");
4167
4209
  return transform.project(local, transform_str);
4168
4210
  }
4169
- /** World-space rect for snap purposes. Differs from `bbox_world` for
4170
- * `<svg>` viewport-establishing elements: `getBBox()` on an `<svg>`
4171
- * reports the union of descendant geometry (SVG 2 §4.6.4), which —
4172
- * when the dragged element is a descendant — silently turns the
4173
- * dragged element's own pre-gesture position into a snap target via
4174
- * the parent's edges. Use the viewport extent instead so the root
4175
- * SVG's snap edges represent the canvas boundary, not "wherever the
4176
- * children happen to be right now". */
4177
- bbox_world_for_snap(id) {
4178
- if (this.tag_of(id) === "svg") {
4179
- const el = this.element_index.get(id);
4180
- if (el instanceof SVGSVGElement) {
4181
- const vp = svg_viewport_bounds(el);
4182
- if (vp) return vp;
4183
- }
4184
- }
4185
- return this.bbox_world(id);
4186
- }
4187
4211
  editor_internal() {
4188
4212
  return this.editor._internal;
4189
4213
  }
@@ -4438,6 +4462,36 @@ var SvgGeometryDriver = class {
4438
4462
  node_at_point(p) {
4439
4463
  return this.accessors.pick_at_world(p, true);
4440
4464
  }
4465
+ /** World→local delta projection. The frame an element's position is
4466
+ * written in is its PARENT user-space: a `<rect>`'s `x`/`y` and the
4467
+ * leading `translate(...)` composed onto a `<g>`/transformed node are
4468
+ * both interpreted there. We take the parent element's frame (not the
4469
+ * element's own) so that translating a node whose OWN transform has a
4470
+ * scale/rotation is not double-counted.
4471
+ *
4472
+ * Camera-free: `inv(root.getScreenCTM) ∘ parent.getScreenCTM` maps
4473
+ * parent user-space → root world-space, cancelling the shared CSS /
4474
+ * camera transform. Inverting its linear part turns a world delta into
4475
+ * the local delta. Identity (→ delta unchanged) for flat frames,
4476
+ * top-level nodes, and any degenerate / unavailable matrix. */
4477
+ world_delta_to_local(id, delta) {
4478
+ const parent = this.accessors.element_for(id)?.parentNode;
4479
+ const root = this.accessors.root();
4480
+ if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
4481
+ if (parent === root) return delta;
4482
+ if (typeof parent.getScreenCTM !== "function" || typeof root.getScreenCTM !== "function") return delta;
4483
+ const parent_ctm = parent.getScreenCTM();
4484
+ const root_ctm = root.getScreenCTM();
4485
+ if (!parent_ctm || !root_ctm) return delta;
4486
+ const m = root_ctm.inverse().multiply(parent_ctm);
4487
+ const det = m.a * m.d - m.c * m.b;
4488
+ if (!Number.isFinite(det) || det === 0) return delta;
4489
+ const [x, y] = project_delta_inverse_ctm(delta.x, delta.y, m);
4490
+ return {
4491
+ x,
4492
+ y
4493
+ };
4494
+ }
4441
4495
  };
4442
4496
  var SvgHitShapeDriver = class {
4443
4497
  constructor(accessors) {