@grida/svg-editor 1.0.0-alpha.13 → 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,8 +1,9 @@
1
- import { A as is_text_input_focused, D as parse_transform_list, E as emit_transform_list, _ as apply_translate, a as DEFAULT_STYLE, c as serialize_paint, g as apply_rotate, h as apply_resize, k as plan_group, m as STAGES_NUDGE, o as TOOL_CURSOR, p as prepare_translate_rpc, r as default_paint_attrs, s as parse_paint, u as prepare_rotate_rpc, v as capture_resize_baseline, x as is_resizable, y as capture_translate_baseline } from "./insertions-Okcuo-Ck.mjs";
1
+ import { _ as is_text_input_focused, a as paint, g as array_shallow_equal, h as group, i as TOOL_CURSOR, m as transform, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline } from "./model-B2UWgViT.mjs";
2
2
  import { HistoryImpl } from "@grida/history";
3
3
  import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
4
4
  import cmath from "@grida/cmath";
5
5
  import { XLINK_NS, encode_attr_value, encode_text, parse_svg } from "@grida/svg/parser";
6
+ import { svg_parse } from "@grida/svg/parse";
6
7
  //#region src/commands/registry.ts
7
8
  var CommandRegistry = class {
8
9
  constructor() {
@@ -118,6 +119,7 @@ function registerDefaultCommands(reg, editor) {
118
119
  if (editor.state.mode !== "select") return false;
119
120
  return editor.commands.align(args);
120
121
  });
122
+ reg.register("content.enter", () => editor.enter_content_edit());
121
123
  reg.register("hierarchy.enter", () => {
122
124
  if (editor.state.selection.length !== 1) return false;
123
125
  const id = editor.state.selection[0];
@@ -142,8 +144,10 @@ function registerDefaultCommands(reg, editor) {
142
144
  return true;
143
145
  });
144
146
  reg.register(TOOL_SET, (args) => {
145
- if (editor.state.mode !== "select") return false;
146
- editor.set_tool(args);
147
+ const next = args;
148
+ const required_mode = next.type === "lasso" || next.type === "bend" ? "edit-content" : next.type === "insert" || next.type === "insert-text" ? "select" : null;
149
+ if (required_mode !== null && editor.state.mode !== required_mode) return false;
150
+ editor.set_tool(next);
147
151
  return true;
148
152
  });
149
153
  }
@@ -163,12 +167,6 @@ function registerDefaultCommands(reg, editor) {
163
167
  * measurement). That stays on the HUD modifiers channel. The keymap
164
168
  * only sees Mod+D-shape chords.
165
169
  */
166
- /** Modifiers that, when held, allow a binding to fire even inside a text input. */
167
- const TEXT_INPUT_SAFE_MODS = new Set([
168
- KeyCode.Meta,
169
- KeyCode.Ctrl,
170
- KeyCode.Alt
171
- ]);
172
170
  var Keymap = class {
173
171
  constructor(commands, platformGetter = getKeyboardOS) {
174
172
  this.commands = commands;
@@ -234,15 +232,16 @@ var Keymap = class {
234
232
  * bar even when the binding's handler rejects.
235
233
  *
236
234
  * Pure read; runs no handlers, no side effects. Honors the same
237
- * text-input-focused guard `dispatch` uses, so a typing user's
238
- * keystroke isn't "claimed" by an unrelated unmodified key.
235
+ * form-element focus guard `dispatch` uses, so a typing user's
236
+ * keystroke isn't "claimed" and the browser's native text-editing
237
+ * default (Cmd+A select all, Cmd+Z undo, etc.) wins.
239
238
  */
240
239
  claims(event) {
241
240
  const chunk = eventToChunk(event);
242
241
  if (chunk.keys.length === 0) return false;
243
242
  const list = this.buckets.get(chunkKey(chunk));
244
243
  if (!list || list.length === 0) return false;
245
- if (is_text_input_focused() && !this.has_safe_mod(chunk.mods)) return false;
244
+ if (is_text_input_focused()) return list.some(({ binding }) => binding.allowInFormElement === true);
246
245
  return true;
247
246
  }
248
247
  /**
@@ -250,6 +249,12 @@ var Keymap = class {
250
249
  * order. Returns `true` on the first handler that consumes; returns
251
250
  * `false` if nothing matched or all matches fell through.
252
251
  *
252
+ * **Form-element focus guard.** When a text input is focused
253
+ * (`<input>`, `<textarea>`, contentEditable), bindings are suppressed
254
+ * by default so the platform's native shortcuts (Cmd+A, Cmd+Z, Cmd+C,
255
+ * arrow nav, …) are preserved. A binding can opt out of this guard
256
+ * with `allowInFormElement: true` — see `KeymapBinding`.
257
+ *
253
258
  * `dispatch` is browser-agnostic: it does NOT call `preventDefault()`
254
259
  * or touch the event in any way. The host decides what to do with the
255
260
  * platform default — typically `if (keymap.claims(e)) e.preventDefault()`,
@@ -264,7 +269,7 @@ var Keymap = class {
264
269
  if (!list || list.length === 0) return false;
265
270
  const text_focused = is_text_input_focused();
266
271
  for (const { binding } of list) {
267
- if (text_focused && !this.has_safe_mod(chunk.mods)) continue;
272
+ if (text_focused && binding.allowInFormElement !== true) continue;
268
273
  if (this.commands.invoke(binding.command, binding.args)) return true;
269
274
  }
270
275
  return false;
@@ -284,10 +289,6 @@ var Keymap = class {
284
289
  }
285
290
  return out;
286
291
  }
287
- has_safe_mod(mods) {
288
- for (const m of mods) if (TEXT_INPUT_SAFE_MODS.has(m)) return true;
289
- return false;
290
- }
291
292
  };
292
293
  function compareEntries(a, b) {
293
294
  const pa = a.binding.priority ?? 0;
@@ -382,6 +383,10 @@ const DEFAULT_BINDINGS = [
382
383
  command: "selection.align",
383
384
  args: "vertical_centers"
384
385
  },
386
+ {
387
+ keybinding: kb(KeyCode.Enter),
388
+ command: "content.enter"
389
+ },
385
390
  {
386
391
  keybinding: kb(KeyCode.Enter),
387
392
  command: "hierarchy.enter"
@@ -483,6 +488,16 @@ const DEFAULT_BINDINGS = [
483
488
  tag: "line"
484
489
  }
485
490
  },
491
+ {
492
+ keybinding: kb(KeyCode.KeyT),
493
+ command: TOOL_SET,
494
+ args: { type: "insert-text" }
495
+ },
496
+ {
497
+ keybinding: kb(KeyCode.KeyQ),
498
+ command: TOOL_SET,
499
+ args: { type: "lasso" }
500
+ },
486
501
  {
487
502
  keybinding: kb(KeyCode.BracketRight),
488
503
  command: "reorder",
@@ -509,14 +524,6 @@ function applyDefaultBindings(keymap) {
509
524
  for (const b of DEFAULT_BINDINGS) keymap.bind(b);
510
525
  }
511
526
  //#endregion
512
- //#region src/util/equal.ts
513
- function array_shallow_equal(a, b) {
514
- if (a === b) return true;
515
- if (a.length !== b.length) return false;
516
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
517
- return true;
518
- }
519
- //#endregion
520
527
  //#region src/core/defs.ts
521
528
  var GradientsRegistry = class {
522
529
  constructor(doc) {
@@ -749,6 +756,55 @@ function create_defs(doc) {
749
756
  }
750
757
  //#endregion
751
758
  //#region src/core/document.ts
759
+ /** The native vector tags `retype_to_path` can re-type, keyed by tag → the
760
+ * native geometry attributes it consumes (so no orphaned geometry attr
761
+ * survives on the resulting `<path>`). Covers the geometry primitives
762
+ * (rect / circle / ellipse — always re-typed) and the vertex tags (line /
763
+ * polyline / polygon — re-typed only when an edit escapes their native
764
+ * form). */
765
+ const RETYPABLE_GEOMETRY_ATTRS = {
766
+ line: new Set([
767
+ "x1",
768
+ "y1",
769
+ "x2",
770
+ "y2"
771
+ ]),
772
+ polyline: new Set(["points"]),
773
+ polygon: new Set(["points"]),
774
+ rect: new Set([
775
+ "x",
776
+ "y",
777
+ "width",
778
+ "height",
779
+ "rx",
780
+ "ry"
781
+ ]),
782
+ circle: new Set([
783
+ "cx",
784
+ "cy",
785
+ "r"
786
+ ]),
787
+ ellipse: new Set([
788
+ "cx",
789
+ "cy",
790
+ "rx",
791
+ "ry"
792
+ ])
793
+ };
794
+ /**
795
+ * Parse a single SVG length attribute as a plain user-unit number. Returns
796
+ * `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
797
+ * `5em`) — those are an out-of-scope geometry gap, and refusing them here
798
+ * means the editor never offers a promotion it cannot perform faithfully.
799
+ */
800
+ function parse_user_unit(raw) {
801
+ if (raw === null) return null;
802
+ const s = raw.trim();
803
+ if (s === "") return null;
804
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
805
+ const n = Number(s);
806
+ return Number.isFinite(n) ? n : null;
807
+ }
752
808
  /**
753
809
  * Attribute names whose writes can shift a node's rendered bounds.
754
810
  * Membership drives `_geometry_version` bumps in `set_attr`. Only
@@ -791,11 +847,14 @@ const GEOMETRY_ATTRS = new Set([
791
847
  "marker-mid",
792
848
  "marker-end"
793
849
  ]);
850
+ /** `transform:` CSS property at the start of a declaration list or after `;`. */
851
+ const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
794
852
  var SvgDocument = class SvgDocument {
795
853
  constructor(svg) {
796
854
  this.listeners = /* @__PURE__ */ new Set();
797
855
  this._structure_version = 0;
798
856
  this._geometry_version = 0;
857
+ if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
799
858
  this.source = svg;
800
859
  const parsed = parse_svg(svg);
801
860
  this.original = parsed;
@@ -821,6 +880,7 @@ var SvgDocument = class SvgDocument {
821
880
  }
822
881
  /** Replace document with new svg source (clears edits + history-owned state). */
823
882
  load(svg) {
883
+ if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
824
884
  this.source = svg;
825
885
  const parsed = parse_svg(svg);
826
886
  this.original = parsed;
@@ -1058,6 +1118,292 @@ var SvgDocument = class SvgDocument {
1058
1118
  for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
1059
1119
  return true;
1060
1120
  }
1121
+ /**
1122
+ * Returns a tag-discriminated snapshot of the authored geometry attrs
1123
+ * if this node is eligible for vector (vertex) editing — else `null`.
1124
+ *
1125
+ * Eligibility:
1126
+ * - `<path>` — requires non-empty `d`.
1127
+ * - `<line>` — requires two distinct finite user-unit endpoints.
1128
+ * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
1129
+ * - `<polygon>` — same as polyline.
1130
+ * - `<rect>` — requires finite user-unit `width`/`height` > 0.
1131
+ * - `<circle>` — requires finite user-unit `r` > 0.
1132
+ * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
1133
+ *
1134
+ * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
1135
+ * their native attributes while the geometry stays expressible there; an
1136
+ * edit that escapes the native form (a curve, or a topology change that
1137
+ * leaves the canonical chain) re-types the element to `<path>`. The
1138
+ * geometry primitives (`rect` / `circle` / `ellipse`) have no native
1139
+ * vector form, so any vector edit re-types them. In all cases the native
1140
+ * tag is preserved byte-for-byte until the first re-typing edit commits
1141
+ * (see `retype_to_path`). Design:
1142
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
1143
+ *
1144
+ * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
1145
+ * an out-of-scope gap, so such an element returns `null` rather than
1146
+ * advertising an edit the editor cannot perform faithfully.
1147
+ *
1148
+ * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
1149
+ * editable outline).
1150
+ */
1151
+ /**
1152
+ * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
1153
+ * endpoints). An **absent** attribute takes the SVG default (`0`); a
1154
+ * **present** attribute that is not a plain user-unit number (`%`, `px`,
1155
+ * `em`, …) is out of scope and yields `null` so the caller refuses the
1156
+ * element — the same gate required attrs (width / radius) already apply.
1157
+ *
1158
+ * The absent-vs-present distinction is the point: a bare `?? 0` would
1159
+ * silently coerce an authored `x1="5px"` to `0`, then the first native
1160
+ * writeback would overwrite that authored value. Refusing keeps the
1161
+ * editor from misrepresenting geometry it cannot read faithfully.
1162
+ */
1163
+ optional_user_unit_coord(id, name) {
1164
+ const raw = this.get_attr(id, name);
1165
+ if (raw === null) return 0;
1166
+ return parse_user_unit(raw);
1167
+ }
1168
+ is_vector_edit_target(id) {
1169
+ const n = this.nodes.get(id);
1170
+ if (!n || n.kind !== "element") return null;
1171
+ if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
1172
+ switch (n.local) {
1173
+ case "path": {
1174
+ const d = this.get_attr(id, "d");
1175
+ if (d === null || d.trim().length === 0) return null;
1176
+ return {
1177
+ kind: "path",
1178
+ d
1179
+ };
1180
+ }
1181
+ case "line": {
1182
+ const x1 = this.optional_user_unit_coord(id, "x1");
1183
+ const y1 = this.optional_user_unit_coord(id, "y1");
1184
+ const x2 = this.optional_user_unit_coord(id, "x2");
1185
+ const y2 = this.optional_user_unit_coord(id, "y2");
1186
+ if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
1187
+ if (x1 === x2 && y1 === y2) return null;
1188
+ return {
1189
+ kind: "line",
1190
+ x1,
1191
+ y1,
1192
+ x2,
1193
+ y2
1194
+ };
1195
+ }
1196
+ case "polyline":
1197
+ case "polygon": {
1198
+ const raw = this.get_attr(id, "points") ?? "";
1199
+ const parsed = svg_parse.parse_points(raw);
1200
+ if (parsed.length < 2) return null;
1201
+ const points = parsed.map((p) => [p.x, p.y]);
1202
+ return n.local === "polyline" ? {
1203
+ kind: "polyline",
1204
+ points
1205
+ } : {
1206
+ kind: "polygon",
1207
+ points
1208
+ };
1209
+ }
1210
+ case "rect": {
1211
+ const x = this.optional_user_unit_coord(id, "x");
1212
+ const y = this.optional_user_unit_coord(id, "y");
1213
+ if (x === null || y === null) return null;
1214
+ const width = parse_user_unit(this.get_attr(id, "width"));
1215
+ const height = parse_user_unit(this.get_attr(id, "height"));
1216
+ if (width === null || height === null) return null;
1217
+ if (width <= 0 || height <= 0) return null;
1218
+ const rx_attr = this.get_attr(id, "rx");
1219
+ const ry_attr = this.get_attr(id, "ry");
1220
+ const rx_parsed = rx_attr === null ? null : parse_user_unit(rx_attr);
1221
+ const ry_parsed = ry_attr === null ? null : parse_user_unit(ry_attr);
1222
+ if (rx_attr !== null && rx_parsed === null) return null;
1223
+ if (ry_attr !== null && ry_parsed === null) return null;
1224
+ let rx = rx_parsed ?? ry_parsed ?? 0;
1225
+ let ry = ry_parsed ?? rx_parsed ?? 0;
1226
+ rx = Math.max(0, Math.min(rx, width / 2));
1227
+ ry = Math.max(0, Math.min(ry, height / 2));
1228
+ return {
1229
+ kind: "rect",
1230
+ x,
1231
+ y,
1232
+ width,
1233
+ height,
1234
+ rx,
1235
+ ry
1236
+ };
1237
+ }
1238
+ case "circle": {
1239
+ const cx = this.optional_user_unit_coord(id, "cx");
1240
+ const cy = this.optional_user_unit_coord(id, "cy");
1241
+ if (cx === null || cy === null) return null;
1242
+ const r = parse_user_unit(this.get_attr(id, "r"));
1243
+ if (r === null || r <= 0) return null;
1244
+ return {
1245
+ kind: "circle",
1246
+ cx,
1247
+ cy,
1248
+ r
1249
+ };
1250
+ }
1251
+ case "ellipse": {
1252
+ const cx = this.optional_user_unit_coord(id, "cx");
1253
+ const cy = this.optional_user_unit_coord(id, "cy");
1254
+ if (cx === null || cy === null) return null;
1255
+ const rx = parse_user_unit(this.get_attr(id, "rx"));
1256
+ const ry = parse_user_unit(this.get_attr(id, "ry"));
1257
+ if (rx === null || ry === null) return null;
1258
+ if (rx <= 0 || ry <= 0) return null;
1259
+ return {
1260
+ kind: "ellipse",
1261
+ cx,
1262
+ cy,
1263
+ rx,
1264
+ ry
1265
+ };
1266
+ }
1267
+ default: return null;
1268
+ }
1269
+ }
1270
+ /**
1271
+ * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
1272
+ * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
1273
+ * its native geometry attributes and setting `d`. A structural mutation:
1274
+ * this layer executes the re-type; it does not decide when one is
1275
+ * warranted.
1276
+ *
1277
+ * Idempotent: returns `null` if `id` is not currently one of those tags
1278
+ * (so it is safe to call repeatedly — once re-typed, e.g. already a
1279
+ * `<path>`, further calls are no-ops). Otherwise mutates the node and
1280
+ * returns an opaque {@link RetypeRecord} reversal token.
1281
+ *
1282
+ * Identity, children, `self_closing`, non-geometry attributes, and all
1283
+ * source trivia are preserved unchanged — only the tag and the geometry
1284
+ * attributes move. Pass the token to {@link revert_retype} to restore
1285
+ * the original primitive byte-for-byte.
1286
+ *
1287
+ * (see test/svg-editor-vector-promote-to-path.md)
1288
+ */
1289
+ retype_to_path(id, d) {
1290
+ const n = this.nodes.get(id);
1291
+ if (!n || n.kind !== "element") return null;
1292
+ const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
1293
+ if (!geom) return null;
1294
+ const prev_local = n.local;
1295
+ const prev_raw_tag = n.raw_tag;
1296
+ const removed = [];
1297
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
1298
+ const a = n.attrs[i];
1299
+ if (a.prefix === null && a.ns === null && geom.has(a.local)) {
1300
+ removed.push({
1301
+ index: i,
1302
+ token: a
1303
+ });
1304
+ n.attrs.splice(i, 1);
1305
+ }
1306
+ }
1307
+ removed.reverse();
1308
+ n.local = "path";
1309
+ n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
1310
+ n.attrs.push({
1311
+ raw_name: "d",
1312
+ prefix: null,
1313
+ local: "d",
1314
+ ns: null,
1315
+ value: d,
1316
+ pre: " ",
1317
+ eq_trivia: "",
1318
+ quote: "\""
1319
+ });
1320
+ let added_fill_none = false;
1321
+ if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
1322
+ n.attrs.push({
1323
+ raw_name: "fill",
1324
+ prefix: null,
1325
+ local: "fill",
1326
+ ns: null,
1327
+ value: "none",
1328
+ pre: " ",
1329
+ eq_trivia: "",
1330
+ quote: "\""
1331
+ });
1332
+ added_fill_none = true;
1333
+ }
1334
+ this._structure_version++;
1335
+ this._geometry_version++;
1336
+ this.emit();
1337
+ return {
1338
+ prev_local,
1339
+ prev_raw_tag,
1340
+ removed,
1341
+ added_fill_none
1342
+ };
1343
+ }
1344
+ /**
1345
+ * Reverse a {@link retype_to_path}: restore the original tag, remove the
1346
+ * `d` attribute the promotion added, and splice the captured geometry
1347
+ * attribute tokens back at their original positions (preserving their
1348
+ * trivia, so a later `serialize()` is byte-equal to the pre-promotion
1349
+ * source).
1350
+ */
1351
+ revert_retype(id, token) {
1352
+ const n = this.nodes.get(id);
1353
+ if (!n || n.kind !== "element") return;
1354
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
1355
+ const a = n.attrs[i];
1356
+ if (a.prefix === null && a.ns === null && a.local === "d") {
1357
+ n.attrs.splice(i, 1);
1358
+ break;
1359
+ }
1360
+ }
1361
+ if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
1362
+ const a = n.attrs[i];
1363
+ if (a.prefix === null && a.ns === null && a.local === "fill") {
1364
+ n.attrs.splice(i, 1);
1365
+ break;
1366
+ }
1367
+ }
1368
+ n.local = token.prev_local;
1369
+ n.raw_tag = token.prev_raw_tag;
1370
+ for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
1371
+ this._structure_version++;
1372
+ this._geometry_version++;
1373
+ this.emit();
1374
+ }
1375
+ /**
1376
+ * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
1377
+ * per-glyph attribute (which conflicts with element-level rotation).
1378
+ */
1379
+ has_glyph_rotate(id) {
1380
+ const tag = this.tag_of(id);
1381
+ if (tag !== "text" && tag !== "tspan") return false;
1382
+ const value = this.get_attr(id, "rotate");
1383
+ if (value === null) return false;
1384
+ return value.trim() !== "";
1385
+ }
1386
+ /**
1387
+ * True iff this element's inline `style=""` declares a `transform:`
1388
+ * CSS property (which would shadow the editor's `transform=` writes).
1389
+ */
1390
+ has_inline_css_transform(id) {
1391
+ const style = this.get_attr(id, "style");
1392
+ if (!style) return false;
1393
+ return CSS_TRANSFORM_PROPERTY.test(style);
1394
+ }
1395
+ /**
1396
+ * True iff this element has a direct `<animateTransform>` child
1397
+ * (which produces a time-varying transform invisible to attribute writes).
1398
+ * Only direct children are checked — nested cases attach to the nearer ancestor.
1399
+ */
1400
+ has_animate_transform_child(id) {
1401
+ for (const c of this.children_of(id)) {
1402
+ const n = this.nodes.get(c);
1403
+ if (n?.kind === "element" && n.local === "animateTransform") return true;
1404
+ }
1405
+ return false;
1406
+ }
1061
1407
  text_of(id) {
1062
1408
  const n = this.nodes.get(id);
1063
1409
  if (!n || n.kind !== "element") return "";
@@ -1149,6 +1495,37 @@ var SvgDocument = class SvgDocument {
1149
1495
  for (const e of this.epilog) out += this.emit_node(e);
1150
1496
  return out;
1151
1497
  }
1498
+ /**
1499
+ * Serialize a single element's subtree as an SVG **fragment**, using the
1500
+ * same trivia-preserving rules as {@link serialize} (attribute order,
1501
+ * quote style, whitespace, comments — emitted exactly as authored).
1502
+ *
1503
+ * This is NOT {@link serialize} scoped to a node — it is a deliberately
1504
+ * weaker output (sdk-design D3, asymmetric outputs stay separate):
1505
+ *
1506
+ * - `serialize()` emits the whole document and carries the P1
1507
+ * whole-document round-trip guarantee.
1508
+ * - `serialize_node()` emits a fragment and does NOT. Namespace
1509
+ * declarations that live on an ancestor (`xmlns:xlink` and friends,
1510
+ * normally on the root `<svg>`) are NOT inlined — a node using
1511
+ * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
1512
+ * element's markup as authored, not a standalone parseable document.
1513
+ *
1514
+ * Throws on an unknown id, a non-element node, or a node detached from
1515
+ * the live tree: the contract is "the markup for a selected element,"
1516
+ * selections are always live elements, and a string return of `""` for a
1517
+ * bad id would hide consumer bugs. The detached case matters because
1518
+ * `remove()` keeps the node in the id map for undo — a stale id from a
1519
+ * removed node would otherwise serialize content no longer in the
1520
+ * document, silently feeding a consumer deleted markup.
1521
+ */
1522
+ serialize_node(id) {
1523
+ const n = this.nodes.get(id);
1524
+ if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
1525
+ if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
1526
+ if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
1527
+ return this.emit_node(n);
1528
+ }
1152
1529
  emit_node(n) {
1153
1530
  switch (n.kind) {
1154
1531
  case "text": return encode_text(n.value);
@@ -1248,153 +1625,162 @@ function delta_for(bbox, target, direction) {
1248
1625
  }
1249
1626
  //#endregion
1250
1627
  //#region src/core/properties.ts
1251
- /** SVG properties that inherit per SVG 2 §6 (subset; the common ones). */
1252
- const INHERITED = new Set([
1253
- "color",
1254
- "cursor",
1255
- "direction",
1256
- "fill",
1257
- "fill-opacity",
1258
- "fill-rule",
1259
- "font",
1260
- "font-family",
1261
- "font-size",
1262
- "font-style",
1263
- "font-variant",
1264
- "font-weight",
1265
- "letter-spacing",
1266
- "marker",
1267
- "marker-end",
1268
- "marker-mid",
1269
- "marker-start",
1270
- "paint-order",
1271
- "pointer-events",
1272
- "shape-rendering",
1273
- "stroke",
1274
- "stroke-dasharray",
1275
- "stroke-dashoffset",
1276
- "stroke-linecap",
1277
- "stroke-linejoin",
1278
- "stroke-miterlimit",
1279
- "stroke-opacity",
1280
- "stroke-width",
1281
- "text-anchor",
1282
- "text-rendering",
1283
- "visibility",
1284
- "word-spacing",
1285
- "writing-mode"
1286
- ]);
1287
- /** Initial values for known properties (subset). */
1288
- const INITIAL = {
1289
- fill: "black",
1290
- stroke: "none",
1291
- "fill-opacity": "1",
1292
- "stroke-opacity": "1",
1293
- "stroke-width": "1",
1294
- opacity: "1",
1295
- visibility: "visible",
1296
- display: "inline"
1297
- };
1298
- /**
1299
- * Resolve a property's declared value and its provenance for a single node.
1300
- *
1301
- * The cascade engine here covers what the README says is in scope:
1302
- * presentation attributes + inline style + parent inheritance + initial.
1303
- * `<style>` block matching is deferred.
1304
- */
1305
- function resolve_declared(doc, id, property) {
1306
- const inline = doc.get_style(id, property);
1307
- if (inline !== null && inline !== "") return {
1308
- declared: inline,
1309
- provenance: {
1310
- origin: "author",
1311
- carrier: "inline_style"
1312
- }
1628
+ let properties;
1629
+ (function(_properties) {
1630
+ /** SVG properties that inherit per SVG 2 §6 (subset; the common ones). */
1631
+ const INHERITED = new Set([
1632
+ "color",
1633
+ "cursor",
1634
+ "direction",
1635
+ "fill",
1636
+ "fill-opacity",
1637
+ "fill-rule",
1638
+ "font",
1639
+ "font-family",
1640
+ "font-size",
1641
+ "font-style",
1642
+ "font-variant",
1643
+ "font-weight",
1644
+ "letter-spacing",
1645
+ "marker",
1646
+ "marker-end",
1647
+ "marker-mid",
1648
+ "marker-start",
1649
+ "paint-order",
1650
+ "pointer-events",
1651
+ "shape-rendering",
1652
+ "stroke",
1653
+ "stroke-dasharray",
1654
+ "stroke-dashoffset",
1655
+ "stroke-linecap",
1656
+ "stroke-linejoin",
1657
+ "stroke-miterlimit",
1658
+ "stroke-opacity",
1659
+ "stroke-width",
1660
+ "text-anchor",
1661
+ "text-rendering",
1662
+ "visibility",
1663
+ "word-spacing",
1664
+ "writing-mode"
1665
+ ]);
1666
+ /** Initial values for known properties (subset). */
1667
+ const INITIAL = {
1668
+ fill: "black",
1669
+ stroke: "none",
1670
+ "fill-opacity": "1",
1671
+ "stroke-opacity": "1",
1672
+ "stroke-width": "1",
1673
+ opacity: "1",
1674
+ visibility: "visible",
1675
+ display: "inline"
1313
1676
  };
1314
- const attr = doc.get_attr(id, property);
1315
- if (attr !== null && attr !== "") return {
1316
- declared: attr,
1317
- provenance: {
1318
- origin: "author",
1319
- carrier: "presentation_attribute"
1320
- }
1321
- };
1322
- if (INHERITED.has(property)) {
1323
- const parent = doc.parent_of(id);
1324
- if (parent !== null && doc.is_element(parent)) {
1325
- const r = resolve_declared(doc, parent, property);
1326
- if (r.declared !== null) return {
1327
- declared: r.declared,
1328
- provenance: {
1329
- origin: "author",
1330
- carrier: "inherited"
1331
- }
1332
- };
1677
+ function resolve_declared(doc, id, property) {
1678
+ const inline = doc.get_style(id, property);
1679
+ if (inline !== null && inline !== "") return {
1680
+ declared: inline,
1681
+ provenance: {
1682
+ origin: "author",
1683
+ carrier: "inline_style"
1684
+ }
1685
+ };
1686
+ const attr = doc.get_attr(id, property);
1687
+ if (attr !== null && attr !== "") return {
1688
+ declared: attr,
1689
+ provenance: {
1690
+ origin: "author",
1691
+ carrier: "presentation_attribute"
1692
+ }
1693
+ };
1694
+ if (INHERITED.has(property)) {
1695
+ const parent = doc.parent_of(id);
1696
+ if (parent !== null && doc.is_element(parent)) {
1697
+ const r = resolve_declared(doc, parent, property);
1698
+ if (r.declared !== null) return {
1699
+ declared: r.declared,
1700
+ provenance: {
1701
+ origin: "author",
1702
+ carrier: "inherited"
1703
+ }
1704
+ };
1705
+ }
1333
1706
  }
1707
+ return {
1708
+ declared: INITIAL[property] ?? null,
1709
+ provenance: {
1710
+ origin: "user_agent",
1711
+ carrier: "defaulted"
1712
+ }
1713
+ };
1334
1714
  }
1335
- return {
1336
- declared: INITIAL[property] ?? null,
1337
- provenance: {
1338
- origin: "user_agent",
1339
- carrier: "defaulted"
1340
- }
1341
- };
1342
- }
1343
- /**
1344
- * Type-parsed computed value for known properties. Unknown property names
1345
- * return the declared string as-is.
1346
- */
1347
- function compute_known(property, declared) {
1348
- if (declared === null) return null;
1349
- const trimmed = declared.trim();
1350
- if (trimmed === "inherit" || trimmed === "initial" || trimmed === "unset" || trimmed === "revert" || trimmed === "revert-layer") return null;
1351
- if (/^var\s*\(/i.test(trimmed)) return {
1352
- error: "invalid_at_computed_value_time",
1353
- reason: `var() substitution requires a cascade engine (not implemented)`
1354
- };
1355
- switch (property) {
1356
- case "opacity":
1357
- case "fill-opacity":
1358
- case "stroke-opacity":
1359
- case "stroke-width":
1360
- case "x":
1361
- case "y":
1362
- case "width":
1363
- case "height":
1364
- case "cx":
1365
- case "cy":
1366
- case "r":
1367
- case "rx":
1368
- case "ry":
1369
- case "font-size": {
1370
- const n = parseFloat(trimmed);
1371
- return Number.isFinite(n) ? n : trimmed;
1715
+ _properties.resolve_declared = resolve_declared;
1716
+ function compute_known(property, declared) {
1717
+ if (declared === null) return null;
1718
+ const trimmed = declared.trim();
1719
+ if (trimmed === "inherit" || trimmed === "initial" || trimmed === "unset" || trimmed === "revert" || trimmed === "revert-layer") return null;
1720
+ if (/^var\s*\(/i.test(trimmed)) return {
1721
+ error: "invalid_at_computed_value_time",
1722
+ reason: `var() substitution requires a cascade engine (not implemented)`
1723
+ };
1724
+ switch (property) {
1725
+ case "opacity":
1726
+ case "fill-opacity":
1727
+ case "stroke-opacity":
1728
+ case "stroke-width":
1729
+ case "x":
1730
+ case "y":
1731
+ case "width":
1732
+ case "height":
1733
+ case "cx":
1734
+ case "cy":
1735
+ case "r":
1736
+ case "rx":
1737
+ case "ry":
1738
+ case "font-size": {
1739
+ const n = parseFloat(trimmed);
1740
+ return Number.isFinite(n) ? n : trimmed;
1741
+ }
1742
+ default: return trimmed;
1372
1743
  }
1373
- default: return trimmed;
1374
1744
  }
1375
- }
1376
- function read_property(doc, id, property) {
1377
- const { declared, provenance } = resolve_declared(doc, id, property);
1378
- return {
1379
- declared,
1380
- computed: compute_known(property, declared),
1381
- provenance
1382
- };
1383
- }
1384
- /** Which carrier should a `set_property` write to? Per the README (P1):
1385
- * whichever carrier currently wins the cascade. If nothing wins (defaulted /
1386
- * inherited), write a presentation attribute by default. */
1387
- function choose_write_carrier(doc, id, property) {
1388
- const inline = doc.get_style(id, property);
1389
- if (inline !== null && inline !== "") return "inline_style";
1390
- return "presentation_attribute";
1391
- }
1745
+ _properties.compute_known = compute_known;
1746
+ function read(doc, id, property) {
1747
+ const { declared, provenance } = resolve_declared(doc, id, property);
1748
+ return {
1749
+ declared,
1750
+ computed: compute_known(property, declared),
1751
+ provenance
1752
+ };
1753
+ }
1754
+ _properties.read = read;
1755
+ function choose_write_carrier(doc, id, property) {
1756
+ const inline = doc.get_style(id, property);
1757
+ if (inline !== null && inline !== "") return "inline_style";
1758
+ return "presentation_attribute";
1759
+ }
1760
+ _properties.choose_write_carrier = choose_write_carrier;
1761
+ function value_equals(a, b) {
1762
+ if (a === b) return true;
1763
+ if (a.declared !== b.declared) return false;
1764
+ if (a.provenance.carrier !== b.provenance.carrier) return false;
1765
+ if (a.provenance.origin !== b.provenance.origin) return false;
1766
+ if (a.computed === b.computed) return true;
1767
+ if (a.computed && b.computed && typeof a.computed === "object" && typeof b.computed === "object" && "error" in a.computed && "error" in b.computed) return a.computed.error === b.computed.error && a.computed.reason === b.computed.reason;
1768
+ return false;
1769
+ }
1770
+ _properties.value_equals = value_equals;
1771
+ })(properties || (properties = {}));
1392
1772
  //#endregion
1393
1773
  //#region src/core/editor.ts
1394
1774
  const PROVIDER_ID = "svg-editor";
1395
1775
  /** Max characters in a synthesized display label before truncation. */
1396
1776
  const DISPLAY_LABEL_MAX_LEN = 40;
1397
- function createSvgEditor(opts) {
1777
+ /**
1778
+ * Wide internal factory — returns the full object including the
1779
+ * `_internal` / `keymap` surfaces in its inferred type. Stays private.
1780
+ * The public `createSvgEditor` below wraps this and narrows the return
1781
+ * to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
1782
+ */
1783
+ function _create_svg_editor_internal(opts) {
1398
1784
  const doc = new SvgDocument(opts.svg);
1399
1785
  const history = new HistoryImpl();
1400
1786
  const defs = create_defs(doc);
@@ -1438,6 +1824,7 @@ function createSvgEditor(opts) {
1438
1824
  can_undo: history.stack.canUndo,
1439
1825
  can_redo: history.stack.canRedo,
1440
1826
  version,
1827
+ content_version: doc_version,
1441
1828
  structure_version: doc.structure_version,
1442
1829
  geometry_version: doc.geometry_version,
1443
1830
  load_version
@@ -1526,7 +1913,7 @@ function createSvgEditor(opts) {
1526
1913
  }
1527
1914
  function tools_equal(a, b) {
1528
1915
  if (a.type !== b.type) return false;
1529
- if (a.type === "cursor") return true;
1916
+ if (a.type === "cursor" || a.type === "lasso" || a.type === "bend" || a.type === "insert-text") return true;
1530
1917
  return b.type === "insert" && a.tag === b.tag;
1531
1918
  }
1532
1919
  function set_tool(next) {
@@ -1587,8 +1974,8 @@ function createSvgEditor(opts) {
1587
1974
  const key = `${id}${name}`;
1588
1975
  const cached = property_cache.get(key);
1589
1976
  if (cached && cached.doc_version === doc_version) return cached.value;
1590
- const next = read_property(doc, id, name);
1591
- if (cached && property_value_equals(cached.value, next)) {
1977
+ const next = properties.read(doc, id, name);
1978
+ if (cached && properties.value_equals(cached.value, next)) {
1592
1979
  cached.doc_version = doc_version;
1593
1980
  return cached.value;
1594
1981
  }
@@ -1624,13 +2011,13 @@ function createSvgEditor(opts) {
1624
2011
  const key = `${id}${channel}`;
1625
2012
  const cached = paint_cache.get(key);
1626
2013
  if (cached && cached.doc_version === doc_version) return cached.value;
1627
- const { declared, provenance } = resolve_declared(doc, id, channel);
2014
+ const { declared, provenance } = properties.resolve_declared(doc, id, channel);
1628
2015
  const next = {
1629
2016
  declared,
1630
- computed: parse_paint(declared),
2017
+ computed: paint.parse(declared),
1631
2018
  provenance
1632
2019
  };
1633
- if (cached && paint_value_equals(cached.value, next)) {
2020
+ if (cached && paint.value_equals(cached.value, next)) {
1634
2021
  cached.doc_version = doc_version;
1635
2022
  return cached.value;
1636
2023
  }
@@ -1641,7 +2028,7 @@ function createSvgEditor(opts) {
1641
2028
  return next;
1642
2029
  }
1643
2030
  function write_property(id, name, value) {
1644
- if (choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
2031
+ if (properties.choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
1645
2032
  else doc.set_attr(id, name, value);
1646
2033
  }
1647
2034
  function set_property(name, value) {
@@ -1704,14 +2091,14 @@ function createSvgEditor(opts) {
1704
2091
  discard: () => preview.discard()
1705
2092
  };
1706
2093
  }
1707
- function set_paint(channel, paint) {
2094
+ function set_paint(channel, p) {
1708
2095
  if (selection.length === 0) return;
1709
- set_property(channel, serialize_paint(paint));
2096
+ set_property(channel, paint.serialize(p));
1710
2097
  }
1711
2098
  function preview_paint(channel) {
1712
2099
  const session = preview_property(channel);
1713
2100
  return {
1714
- update: (paint) => session.update(serialize_paint(paint)),
2101
+ update: (p) => session.update(paint.serialize(p)),
1715
2102
  commit: () => session.commit(),
1716
2103
  discard: () => session.discard()
1717
2104
  };
@@ -1729,7 +2116,7 @@ function createSvgEditor(opts) {
1729
2116
  function do_translate_oneshot(delta, stages, label) {
1730
2117
  if (selection.length === 0) return false;
1731
2118
  if (delta.dx === 0 && delta.dy === 0) return false;
1732
- const { apply, revert } = prepare_translate_rpc({
2119
+ const { apply, revert } = translate_pipeline.prepare_rpc({
1733
2120
  doc,
1734
2121
  ids: selection,
1735
2122
  delta: {
@@ -1742,7 +2129,8 @@ function createSvgEditor(opts) {
1742
2129
  snap_threshold_px: style.snap_threshold_px
1743
2130
  },
1744
2131
  emit,
1745
- stages
2132
+ stages,
2133
+ project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
1746
2134
  });
1747
2135
  apply();
1748
2136
  history.atomic(label, (tx) => {
@@ -1758,7 +2146,7 @@ function createSvgEditor(opts) {
1758
2146
  if (do_translate_oneshot(delta, void 0, "translate")) notify_translate_commit();
1759
2147
  }
1760
2148
  function nudge(delta) {
1761
- if (do_translate_oneshot(delta, STAGES_NUDGE, "nudge")) notify_translate_commit();
2149
+ if (do_translate_oneshot(delta, translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
1762
2150
  }
1763
2151
  /**
1764
2152
  * One-shot multi-member resize to an explicit target rect. Mirrors a
@@ -1783,13 +2171,13 @@ function createSvgEditor(opts) {
1783
2171
  if (!geometry_provider) return false;
1784
2172
  const members = [];
1785
2173
  for (const id of ids) {
1786
- if (!is_resizable(doc.tag_of(id))) continue;
2174
+ if (!resize_pipeline.intent.is_resizable(doc.tag_of(id))) continue;
1787
2175
  const bbox = geometry_provider.bounds_of(id);
1788
2176
  if (!bbox) continue;
1789
2177
  members.push({
1790
2178
  id,
1791
- rz: capture_resize_baseline(doc, id, bbox),
1792
- tx_pre: capture_translate_baseline(doc, id),
2179
+ rz: resize_pipeline.intent.capture_baseline(doc, id, bbox),
2180
+ tx_pre: translate_pipeline.intent.capture_baseline(doc, id),
1793
2181
  transform_pre: doc.get_attr(id, "transform"),
1794
2182
  bbox
1795
2183
  });
@@ -1805,16 +2193,16 @@ function createSvgEditor(opts) {
1805
2193
  const dx = target.x - union.x;
1806
2194
  const dy = target.y - union.y;
1807
2195
  const apply = () => {
1808
- for (const m of members) apply_resize(doc, m.id, m.rz, sx, sy, origin);
2196
+ for (const m of members) resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
1809
2197
  if (dx !== 0 || dy !== 0) for (const m of members) {
1810
- const tx_after = capture_translate_baseline(doc, m.id);
1811
- apply_translate(doc, m.id, tx_after, dx, dy);
2198
+ const tx_after = translate_pipeline.intent.capture_baseline(doc, m.id);
2199
+ translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
1812
2200
  }
1813
2201
  emit();
1814
2202
  };
1815
2203
  const revert = () => {
1816
2204
  for (const m of members) {
1817
- apply_resize(doc, m.id, m.rz, 1, 1, origin);
2205
+ resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
1818
2206
  doc.set_attr(m.id, "transform", m.transform_pre);
1819
2207
  }
1820
2208
  emit();
@@ -1855,10 +2243,11 @@ function createSvgEditor(opts) {
1855
2243
  function rotate(angle, opts) {
1856
2244
  const ids = opts?.ids ?? selection;
1857
2245
  if (ids.length === 0) return false;
1858
- const prepared = prepare_rotate_rpc({
2246
+ const pivot = opts?.pivot ?? default_rotate_pivot(ids);
2247
+ const prepared = rotate_pipeline.prepare_rpc({
1859
2248
  doc,
1860
2249
  ids,
1861
- pivot: opts?.pivot ?? default_rotate_pivot(ids),
2250
+ pivot,
1862
2251
  angle_radians: angle,
1863
2252
  options: { angle_snap_step_radians: style.angle_snap_step_radians },
1864
2253
  emit
@@ -1877,10 +2266,11 @@ function createSvgEditor(opts) {
1877
2266
  function rotate_to(angle, opts) {
1878
2267
  const ids = opts?.ids ?? selection;
1879
2268
  if (ids.length === 0) return false;
1880
- const probe = prepare_rotate_rpc({
2269
+ const pivot = opts?.pivot ?? default_rotate_pivot(ids);
2270
+ const probe = rotate_pipeline.prepare_rpc({
1881
2271
  doc,
1882
2272
  ids,
1883
- pivot: opts?.pivot ?? default_rotate_pivot(ids),
2273
+ pivot,
1884
2274
  angle_radians: 0,
1885
2275
  options: { angle_snap_step_radians: style.angle_snap_step_radians },
1886
2276
  emit: () => {}
@@ -1890,12 +2280,12 @@ function createSvgEditor(opts) {
1890
2280
  const apply = () => {
1891
2281
  for (const m of probe.plan.members) {
1892
2282
  const delta = angle - m.baseline.current_rotation_deg * DEG_TO_RAD;
1893
- apply_rotate(doc, m.id, m.baseline, delta);
2283
+ rotate_pipeline.intent.apply(doc, m.id, m.baseline, delta);
1894
2284
  }
1895
2285
  emit();
1896
2286
  };
1897
2287
  const revert = () => {
1898
- for (const m of probe.plan.members) apply_rotate(doc, m.id, m.baseline, 0);
2288
+ for (const m of probe.plan.members) rotate_pipeline.intent.apply(doc, m.id, m.baseline, 0);
1899
2289
  emit();
1900
2290
  };
1901
2291
  apply();
@@ -1915,7 +2305,7 @@ function createSvgEditor(opts) {
1915
2305
  for (const id of ids) {
1916
2306
  const pre = doc.get_attr(id, "transform");
1917
2307
  if (pre === null) continue;
1918
- const ops = parse_transform_list(pre);
2308
+ const ops = transform.parse(pre);
1919
2309
  if (ops === null) continue;
1920
2310
  if (ops.length === 1 && ops[0].type === "matrix") continue;
1921
2311
  members.push({
@@ -1925,104 +2315,11 @@ function createSvgEditor(opts) {
1925
2315
  });
1926
2316
  }
1927
2317
  if (members.length === 0) return false;
1928
- const IDENT = [
1929
- 1,
1930
- 0,
1931
- 0,
1932
- 1,
1933
- 0,
1934
- 0
1935
- ];
1936
- function mul(m1, m2) {
1937
- const [a1, b1, c1, d1, e1, f1] = m1;
1938
- const [a2, b2, c2, d2, e2, f2] = m2;
1939
- return [
1940
- a1 * a2 + c1 * b2,
1941
- b1 * a2 + d1 * b2,
1942
- a1 * c2 + c1 * d2,
1943
- b1 * c2 + d1 * d2,
1944
- a1 * e2 + c1 * f2 + e1,
1945
- b1 * e2 + d1 * f2 + f1
1946
- ];
1947
- }
1948
- function op_to_mat(op) {
1949
- switch (op.type) {
1950
- case "matrix": return [
1951
- op.a,
1952
- op.b,
1953
- op.c,
1954
- op.d,
1955
- op.e,
1956
- op.f
1957
- ];
1958
- case "translate": return [
1959
- 1,
1960
- 0,
1961
- 0,
1962
- 1,
1963
- op.tx,
1964
- op.ty
1965
- ];
1966
- case "rotate": {
1967
- const rad = op.angle * Math.PI / 180;
1968
- const c = Math.cos(rad);
1969
- const s = Math.sin(rad);
1970
- if (op.cx === 0 && op.cy === 0) return [
1971
- c,
1972
- s,
1973
- -s,
1974
- c,
1975
- 0,
1976
- 0
1977
- ];
1978
- const e = op.cx - c * op.cx + s * op.cy;
1979
- const f = op.cy - s * op.cx - c * op.cy;
1980
- return [
1981
- c,
1982
- s,
1983
- -s,
1984
- c,
1985
- e,
1986
- f
1987
- ];
1988
- }
1989
- case "scale": return [
1990
- op.sx,
1991
- 0,
1992
- 0,
1993
- op.sy,
1994
- 0,
1995
- 0
1996
- ];
1997
- case "skewX": {
1998
- const rad = op.angle * Math.PI / 180;
1999
- return [
2000
- 1,
2001
- 0,
2002
- Math.tan(rad),
2003
- 1,
2004
- 0,
2005
- 0
2006
- ];
2007
- }
2008
- case "skewY": {
2009
- const rad = op.angle * Math.PI / 180;
2010
- return [
2011
- 1,
2012
- Math.tan(rad),
2013
- 0,
2014
- 1,
2015
- 0,
2016
- 0
2017
- ];
2018
- }
2019
- }
2020
- }
2021
2318
  const apply = () => {
2022
2319
  for (const m of members) {
2023
- let mat = IDENT;
2024
- for (const op of m.ops) mat = mul(mat, op_to_mat(op));
2025
- doc.set_attr(m.id, "transform", emit_transform_list([{
2320
+ let mat = FLATTEN_IDENT;
2321
+ for (const op of m.ops) mat = flatten_mul(mat, flatten_op_to_mat(op));
2322
+ doc.set_attr(m.id, "transform", transform.emit([{
2026
2323
  type: "matrix",
2027
2324
  a: mat[0],
2028
2325
  b: mat[1],
@@ -2072,7 +2369,7 @@ function createSvgEditor(opts) {
2072
2369
  for (const id of ids) {
2073
2370
  const bbox = geometry_provider.bounds_of(id);
2074
2371
  if (!bbox) continue;
2075
- const baseline = capture_translate_baseline(doc, id);
2372
+ const baseline = translate_pipeline.intent.capture_baseline(doc, id);
2076
2373
  if (baseline.type === "unsupported") continue;
2077
2374
  members.push({
2078
2375
  id,
@@ -2094,12 +2391,12 @@ function createSvgEditor(opts) {
2094
2391
  const apply = () => {
2095
2392
  for (const m of members) {
2096
2393
  const d = deltas.get(m.id);
2097
- if (d) apply_translate(doc, m.id, m.baseline, d.x, d.y);
2394
+ if (d) translate_pipeline.intent.apply(doc, m.id, m.baseline, d.x, d.y);
2098
2395
  }
2099
2396
  emit();
2100
2397
  };
2101
2398
  const revert = () => {
2102
- for (const m of members) if (deltas.has(m.id)) apply_translate(doc, m.id, m.baseline, 0, 0);
2399
+ for (const m of members) if (deltas.has(m.id)) translate_pipeline.intent.apply(doc, m.id, m.baseline, 0, 0);
2103
2400
  emit();
2104
2401
  };
2105
2402
  apply();
@@ -2224,8 +2521,8 @@ function createSvgEditor(opts) {
2224
2521
  });
2225
2522
  });
2226
2523
  }
2227
- function group() {
2228
- const plan = plan_group(doc, selection);
2524
+ function group$1() {
2525
+ const plan = group.plan(doc, selection);
2229
2526
  if (!plan) return false;
2230
2527
  const group_id = doc.create_element("g");
2231
2528
  const original_selection = selection;
@@ -2353,12 +2650,70 @@ function createSvgEditor(opts) {
2353
2650
  }
2354
2651
  };
2355
2652
  }
2653
+ /**
2654
+ * Text-creation bracket for the click-to-place text tool. Creates an
2655
+ * empty `<text>` with `initial` attrs, opens a single history preview,
2656
+ * and selects it — the DOM surface then mounts inline content-edit on
2657
+ * it. The surface finalizes the returned session when content-edit
2658
+ * exits:
2659
+ *
2660
+ * - `commit()` — snapshots the live text content into the delta and
2661
+ * commits ONE undo step (create + text together). Redo replays both,
2662
+ * so a redone text insert keeps its content (a plain `insert_preview`
2663
+ * would lose it — text is not an attribute).
2664
+ * - `discard()` — rolls the creation back entirely: no node, no
2665
+ * committed history entry. This is the empty-equals-delete rule for a
2666
+ * freshly-placed node (design:
2667
+ * `docs/wg/feat-svg-editor/text-tool.md`).
2668
+ *
2669
+ * The node is inserted empty on open (so the caret has somewhere to
2670
+ * live); live edits mutate its text in place, and `commit()` reads the
2671
+ * final text back off the document.
2672
+ */
2673
+ function insert_text_preview(initial, opts) {
2674
+ const parent = opts?.parent ?? doc.root;
2675
+ const id = doc.create_element("text");
2676
+ const previous_selection = selection;
2677
+ const attrs = { ...initial };
2678
+ let committed_text = "";
2679
+ const apply = () => {
2680
+ for (const name in attrs) doc.set_attr(id, name, attrs[name]);
2681
+ if (doc.parent_of(id) === null) doc.insert(id, parent, null);
2682
+ doc.set_text(id, committed_text);
2683
+ set_selection([id]);
2684
+ };
2685
+ const revert = () => {
2686
+ doc.remove(id);
2687
+ set_selection(previous_selection);
2688
+ };
2689
+ const preview = history.preview("insert text");
2690
+ let active = true;
2691
+ preview.set({
2692
+ providerId: PROVIDER_ID,
2693
+ apply,
2694
+ revert
2695
+ });
2696
+ return {
2697
+ id,
2698
+ commit() {
2699
+ if (!active) return;
2700
+ active = false;
2701
+ committed_text = doc.text_of(id);
2702
+ preview.commit();
2703
+ },
2704
+ discard() {
2705
+ if (!active) return;
2706
+ active = false;
2707
+ preview.discard();
2708
+ }
2709
+ };
2710
+ }
2356
2711
  /** Per-tag default paint attrs. Wrapped so callers don't need to depend
2357
2712
  * on the InsertableTag type — `insert()` accepts arbitrary string tags
2358
2713
  * (so `commands.insert("path", ...)` works for paste / RPC) but only
2359
2714
  * the closed insertable set gets default paint. */
2360
2715
  function default_paint_attrs_for(tag) {
2361
- if (tag === "rect" || tag === "ellipse" || tag === "line") return default_paint_attrs(tag);
2716
+ if (tag === "rect" || tag === "ellipse" || tag === "line") return insertions.default_paint_attrs(tag);
2362
2717
  return {};
2363
2718
  }
2364
2719
  function set_text(value) {
@@ -2407,7 +2762,7 @@ function createSvgEditor(opts) {
2407
2762
  function enter_content_edit(target) {
2408
2763
  const id = target ?? (selection.length === 1 ? selection[0] : null);
2409
2764
  if (!id) return false;
2410
- if (!doc.is_text_edit_target(id)) return false;
2765
+ if (!doc.is_text_edit_target(id) && doc.is_vector_edit_target(id) === null) return false;
2411
2766
  if (!content_edit_driver) return false;
2412
2767
  return content_edit_driver(id);
2413
2768
  }
@@ -2455,7 +2810,7 @@ function createSvgEditor(opts) {
2455
2810
  align,
2456
2811
  reorder,
2457
2812
  remove,
2458
- group,
2813
+ group: group$1,
2459
2814
  insert,
2460
2815
  insert_preview,
2461
2816
  set_text,
@@ -2511,6 +2866,10 @@ function createSvgEditor(opts) {
2511
2866
  emit();
2512
2867
  }
2513
2868
  const public_editor = {
2869
+ /**
2870
+ * Low-level IR handle. Mutating directly bypasses history; prefer
2871
+ * `editor.commands` for app code.
2872
+ */
2514
2873
  document: doc,
2515
2874
  get state() {
2516
2875
  return snapshot();
@@ -2521,9 +2880,28 @@ function createSvgEditor(opts) {
2521
2880
  node_paint,
2522
2881
  dom_computed_property,
2523
2882
  dom_computed_paint,
2883
+ /**
2884
+ * Enter content-edit mode on a `<text>` node. Returns `false` (no-op)
2885
+ * when no DOM surface is attached.
2886
+ */
2524
2887
  enter_content_edit,
2525
2888
  defs,
2526
2889
  commands,
2890
+ /**
2891
+ * Human-readable label for hierarchy panels. SVG has no native "name";
2892
+ * this is the package's single source of truth so panels don't reinvent
2893
+ * the rule.
2894
+ *
2895
+ * Rule:
2896
+ * - `<text>` → text content, whitespace-collapsed and truncated at
2897
+ * ~40 chars (falls back to `"text"` for empty content).
2898
+ * - Otherwise → tag name, suffixed with `#id` when the `id` attribute
2899
+ * is present (e.g. `"rect #sun"`).
2900
+ *
2901
+ * `opts.tagLabel` lets callers substitute a friendlier or localized
2902
+ * term for the raw tag (e.g. `"rect"` → `"Rectangle"`). Only invoked
2903
+ * on the non-text branch.
2904
+ */
2527
2905
  display_label(id, opts) {
2528
2906
  const tag = doc.tag_of(id);
2529
2907
  if (tag === "text") {
@@ -2538,30 +2916,59 @@ function createSvgEditor(opts) {
2538
2916
  tree() {
2539
2917
  return tree_snapshot();
2540
2918
  },
2919
+ /**
2920
+ * The effective hover from the attached HUD surface — what's under the
2921
+ * pointer, OR whatever `set_surface_hover_override` last pushed. Used
2922
+ * by out-of-canvas UI (layers panel, breadcrumbs) to mirror the canvas
2923
+ * highlight. Returns `null` when nothing is hovered.
2924
+ */
2541
2925
  surface_hover() {
2542
2926
  return current_surface_hover;
2543
2927
  },
2928
+ /**
2929
+ * Push a hover override into the HUD surface — e.g. when the user
2930
+ * hovers a row in a layers panel. The HUD will render the override's
2931
+ * outline and (when applicable) drive measurement to that node.
2932
+ * Pass `null` to clear and let the pointer pick take over again.
2933
+ */
2544
2934
  set_surface_hover_override(id) {
2545
2935
  if (surface_hover_override === id) return;
2546
2936
  surface_hover_override = id;
2547
2937
  if (surface_hover_override_driver) surface_hover_override_driver(id);
2548
2938
  },
2939
+ /**
2940
+ * Subscribe to changes in the effective surface hover. Fires when the
2941
+ * HUD reports a new pointer pick AND when an override is set/cleared.
2942
+ * Cheap channel — does NOT bump `state.version`.
2943
+ */
2549
2944
  subscribe_surface_hover(cb) {
2550
2945
  surface_hover_listeners.add(cb);
2551
2946
  return () => {
2552
2947
  surface_hover_listeners.delete(cb);
2553
2948
  };
2554
2949
  },
2950
+ /**
2951
+ * Subscribe to bounds-affecting changes. Fires when any document
2952
+ * mutation advances `state.geometry_version` — drag, resize, text
2953
+ * edit, structural insert/remove. Skips presentation-only writes
2954
+ * (fill, opacity, stroke-color).
2955
+ */
2555
2956
  subscribe_geometry(cb) {
2556
2957
  geometry_listeners.add(cb);
2557
2958
  return () => {
2558
2959
  geometry_listeners.delete(cb);
2559
2960
  };
2560
2961
  },
2962
+ /**
2963
+ * World-space geometry queries. Non-null when a DOM surface is
2964
+ * attached; null otherwise (queries need a renderer to read bbox
2965
+ * from). Read-only — never mutates document state.
2966
+ */
2561
2967
  get geometry() {
2562
2968
  return geometry_provider;
2563
2969
  },
2564
2970
  modes,
2971
+ /** Switch the active tool. No history entry; bumps `state.version`. */
2565
2972
  set_tool,
2566
2973
  get style() {
2567
2974
  return style;
@@ -2569,6 +2976,21 @@ function createSvgEditor(opts) {
2569
2976
  set_style,
2570
2977
  load,
2571
2978
  serialize,
2979
+ /**
2980
+ * Serialize a single element's subtree as an SVG **fragment**, using the
2981
+ * same trivia-preserving rules as {@link serialize} — for handing "the
2982
+ * markup of the element the user selected" to a downstream consumer
2983
+ * (e.g. an AI agent) without re-serializing the whole document.
2984
+ *
2985
+ * Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
2986
+ * carry `serialize()`'s whole-document round-trip guarantee. Namespace
2987
+ * declarations on an ancestor (`xmlns:xlink`, normally on the root
2988
+ * `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
2989
+ * `xmlns:xlink`. Throws on an unknown id or a non-element node.
2990
+ */
2991
+ serialize_node(id) {
2992
+ return doc.serialize_node(id);
2993
+ },
2572
2994
  reset,
2573
2995
  attach,
2574
2996
  detach,
@@ -2577,6 +2999,7 @@ function createSvgEditor(opts) {
2577
2999
  _internal: {
2578
3000
  doc,
2579
3001
  history: { preview: (label) => history.preview(label) },
3002
+ insert_text_preview,
2580
3003
  emit,
2581
3004
  subscribe_translate_commit(cb) {
2582
3005
  translate_commit_listeners.add(cb);
@@ -2608,37 +3031,112 @@ function createSvgEditor(opts) {
2608
3031
  applyDefaultBindings(keymap);
2609
3032
  return public_editor;
2610
3033
  }
2611
- function paint_value_equals(a, b) {
2612
- if (a === b) return true;
2613
- if (a.declared !== b.declared) return false;
2614
- if (a.provenance.carrier !== b.provenance.carrier) return false;
2615
- if (a.provenance.origin !== b.provenance.origin) return false;
2616
- return paint_equals(a.computed, b.computed);
2617
- }
2618
- function paint_equals(a, b) {
2619
- if (a === b) return true;
2620
- if (a == null || b == null) return false;
2621
- if ("error" in a || "error" in b) return "error" in a && "error" in b && a.error === b.error && a.reason === b.reason;
2622
- if (a.kind !== b.kind) return false;
2623
- if (a.kind === "color" && b.kind === "color") {
2624
- if (a.value.kind !== b.value.kind) return false;
2625
- if (a.value.kind === "rgb" && b.value.kind === "rgb") return a.value.value === b.value.value;
2626
- return true;
3034
+ /**
3035
+ * Construct a headless SVG editor. The returned object is the public
3036
+ * editor surface — observation (`state`, `subscribe`), commands
3037
+ * (`commands.*`), lifecycle (`attach` / `dispose`), and the typed-read
3038
+ * caches (`node_paint`, `node_properties`). Surfaces (DOM, headless)
3039
+ * attach later via `editor.attach(surface)`.
3040
+ */
3041
+ function createSvgEditor(opts) {
3042
+ if (opts == null || typeof opts.svg !== "string") {
3043
+ const got = opts == null ? String(opts) : opts.svg === null ? "null" : typeof opts.svg;
3044
+ throw new TypeError(`createSvgEditor({ svg }) requires { svg: string }, got svg=${got}`);
2627
3045
  }
2628
- if (a.kind === "ref" && b.kind === "ref") return a.id === b.id;
2629
- if (a.kind === "none" && b.kind === "none") return true;
2630
- if (a.kind === "context_fill" && b.kind === "context_fill") return true;
2631
- if (a.kind === "context_stroke" && b.kind === "context_stroke") return true;
2632
- return false;
3046
+ return _create_svg_editor_internal(opts);
2633
3047
  }
2634
- function property_value_equals(a, b) {
2635
- if (a === b) return true;
2636
- if (a.declared !== b.declared) return false;
2637
- if (a.provenance.carrier !== b.provenance.carrier) return false;
2638
- if (a.provenance.origin !== b.provenance.origin) return false;
2639
- if (a.computed === b.computed) return true;
2640
- if (a.computed && b.computed && typeof a.computed === "object" && typeof b.computed === "object" && "error" in a.computed && "error" in b.computed) return a.computed.error === b.computed.error && a.computed.reason === b.computed.reason;
2641
- return false;
3048
+ const FLATTEN_IDENT = [
3049
+ 1,
3050
+ 0,
3051
+ 0,
3052
+ 1,
3053
+ 0,
3054
+ 0
3055
+ ];
3056
+ function flatten_mul(m1, m2) {
3057
+ const [a1, b1, c1, d1, e1, f1] = m1;
3058
+ const [a2, b2, c2, d2, e2, f2] = m2;
3059
+ return [
3060
+ a1 * a2 + c1 * b2,
3061
+ b1 * a2 + d1 * b2,
3062
+ a1 * c2 + c1 * d2,
3063
+ b1 * c2 + d1 * d2,
3064
+ a1 * e2 + c1 * f2 + e1,
3065
+ b1 * e2 + d1 * f2 + f1
3066
+ ];
3067
+ }
3068
+ function flatten_op_to_mat(op) {
3069
+ switch (op.type) {
3070
+ case "matrix": return [
3071
+ op.a,
3072
+ op.b,
3073
+ op.c,
3074
+ op.d,
3075
+ op.e,
3076
+ op.f
3077
+ ];
3078
+ case "translate": return [
3079
+ 1,
3080
+ 0,
3081
+ 0,
3082
+ 1,
3083
+ op.tx,
3084
+ op.ty
3085
+ ];
3086
+ case "rotate": {
3087
+ const rad = op.angle * Math.PI / 180;
3088
+ const c = Math.cos(rad);
3089
+ const s = Math.sin(rad);
3090
+ if (op.cx === 0 && op.cy === 0) return [
3091
+ c,
3092
+ s,
3093
+ -s,
3094
+ c,
3095
+ 0,
3096
+ 0
3097
+ ];
3098
+ const e = op.cx - c * op.cx + s * op.cy;
3099
+ const f = op.cy - s * op.cx - c * op.cy;
3100
+ return [
3101
+ c,
3102
+ s,
3103
+ -s,
3104
+ c,
3105
+ e,
3106
+ f
3107
+ ];
3108
+ }
3109
+ case "scale": return [
3110
+ op.sx,
3111
+ 0,
3112
+ 0,
3113
+ op.sy,
3114
+ 0,
3115
+ 0
3116
+ ];
3117
+ case "skewX": {
3118
+ const rad = op.angle * Math.PI / 180;
3119
+ return [
3120
+ 1,
3121
+ 0,
3122
+ Math.tan(rad),
3123
+ 1,
3124
+ 0,
3125
+ 0
3126
+ ];
3127
+ }
3128
+ case "skewY": {
3129
+ const rad = op.angle * Math.PI / 180;
3130
+ return [
3131
+ 1,
3132
+ Math.tan(rad),
3133
+ 0,
3134
+ 1,
3135
+ 0,
3136
+ 0
3137
+ ];
3138
+ }
3139
+ }
2642
3140
  }
2643
3141
  //#endregion
2644
3142
  export { createSvgEditor as t };