@grida/svg-editor 1.0.0-alpha.3 → 1.0.0-alpha.5

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,4 +1,4 @@
1
- import { a as capture_resize_baseline, c as is_resizable, i as apply_translate, l as is_text_input_focused, o as capture_translate_baseline, r as apply_resize, s as compute_resize_factors, t as parse_paint } from "./paint-BTKvRItP.mjs";
1
+ import { a as capture_resize_baseline, c as is_resizable, i as apply_translate, l as is_text_input_focused, o as capture_translate_baseline, r as apply_resize, s as compute_resize_factors, t as parse_paint } from "./paint-Cfiw4g_J.mjs";
2
2
  import { createTextEditor } from "@grida/text-editor/dom";
3
3
  import { NO_MODS, Surface, measurementToHUDDraw } from "@grida/hud";
4
4
  import { measure } from "@grida/cmath/_measurement";
@@ -16,9 +16,26 @@ var Camera = class {
16
16
  this.viewport_w = 0;
17
17
  this.viewport_h = 0;
18
18
  this.listeners = /* @__PURE__ */ new Set();
19
+ this._constraints = null;
19
20
  this._transform = opts.initial ?? cmath.transform.identity;
20
21
  this.resolve_bounds = opts.resolve_bounds;
21
22
  }
23
+ /**
24
+ * Current viewport constraint, or `null` for free pan/zoom. Set with
25
+ * `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
26
+ * to clamp zoom + pan; assign `null` to clear.
27
+ *
28
+ * Constraints are applied synchronously inside `set_transform` (and
29
+ * `_set_viewport_size`), so every public mutation respects them
30
+ * automatically — the host never needs to subscribe-and-clamp itself.
31
+ */
32
+ get constraints() {
33
+ return this._constraints;
34
+ }
35
+ set constraints(c) {
36
+ this._constraints = c;
37
+ if (c) this.reenforce();
38
+ }
22
39
  /** Underlying 2D affine. World→screen. */
23
40
  get transform() {
24
41
  return this._transform;
@@ -117,10 +134,15 @@ var Camera = class {
117
134
  * makes external constraint loops (e.g. "subscribe → compute clamped →
118
135
  * set_transform") terminate: the clamp re-emits the same transform on
119
136
  * the second pass, set_transform short-circuits, no recursion.
137
+ *
138
+ * When `camera.constraints` is non-null, the input transform is clamped
139
+ * synchronously before being stored — every public mutation respects the
140
+ * constraint automatically.
120
141
  */
121
142
  set_transform(t) {
122
- if (transform_equal(this._transform, t)) return;
123
- this._transform = t;
143
+ const next = this.apply_constraints(t);
144
+ if (transform_equal(this._transform, next)) return;
145
+ this._transform = next;
124
146
  this.notify();
125
147
  }
126
148
  /** Viewport size in screen pixels. Read by host code computing constraints. */
@@ -174,6 +196,10 @@ var Camera = class {
174
196
  if (w === this.viewport_w && h === this.viewport_h) return;
175
197
  this.viewport_w = w;
176
198
  this.viewport_h = h;
199
+ if (this._constraints) {
200
+ const next = this.apply_constraints(this._transform);
201
+ if (!transform_equal(this._transform, next)) this._transform = next;
202
+ }
177
203
  this.notify();
178
204
  }
179
205
  /** Convert a screen-space point to world-space. */
@@ -193,10 +219,71 @@ var Camera = class {
193
219
  y: sy
194
220
  };
195
221
  }
222
+ /**
223
+ * Apply the current constraint (if any) to a candidate transform.
224
+ * Pure: returns the clamped result, never mutates state. Returns the
225
+ * input unchanged when constraints are null / bounds are unresolvable /
226
+ * viewport is 0.
227
+ */
228
+ apply_constraints(t) {
229
+ if (!this._constraints) return t;
230
+ if (this.viewport_w <= 0 || this.viewport_h <= 0) return t;
231
+ switch (this._constraints.type) {
232
+ case "cover": return clamp_cover(t, this._constraints, this.viewport_w, this.viewport_h, this.resolve_bounds);
233
+ }
234
+ }
235
+ /**
236
+ * Re-clamp the stored transform against the current constraint. Called
237
+ * from the `constraints` setter; `_set_viewport_size` has its own
238
+ * notify-inclusive path.
239
+ */
240
+ reenforce() {
241
+ if (!this._constraints) return;
242
+ const next = this.apply_constraints(this._transform);
243
+ if (transform_equal(this._transform, next)) return;
244
+ this._transform = next;
245
+ this.notify();
246
+ }
196
247
  notify() {
197
248
  for (const cb of this.listeners) cb();
198
249
  }
199
250
  };
251
+ /**
252
+ * Clamp a transform under a `'cover'` constraint:
253
+ * - Zoom lower-bounded at fit-with-padding (the slide always fills the
254
+ * viewport edge-to-edge).
255
+ * - When at min-zoom the slide is locked centered (bounds smaller than
256
+ * viewport on the constrained axis is impossible above min_zoom; below
257
+ * is impossible because zoom is clamped up).
258
+ * - When zoomed in, pan is clamped so the slide always covers the viewport
259
+ * (no black bars).
260
+ *
261
+ * Returns the input transform unchanged when bounds can't be resolved or
262
+ * are degenerate.
263
+ */
264
+ function clamp_cover(t, c, vp_w, vp_h, resolve) {
265
+ const bounds = typeof c.bounds === "string" ? resolve(c.bounds) : c.bounds;
266
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return t;
267
+ const padding = c.padding ?? 0;
268
+ const eff_w = vp_w - 2 * padding;
269
+ const eff_h = vp_h - 2 * padding;
270
+ if (eff_w <= 0 || eff_h <= 0) return t;
271
+ const min_zoom = Math.min(eff_w / bounds.width, eff_h / bounds.height);
272
+ const s = Math.max(t[0][0], min_zoom);
273
+ const sw = s * bounds.width;
274
+ const sh = s * bounds.height;
275
+ const tx = sw > vp_w ? cmath.clamp(t[0][2], vp_w - s * (bounds.x + bounds.width), -s * bounds.x) : (vp_w - sw) / 2 - s * bounds.x;
276
+ const ty = sh > vp_h ? cmath.clamp(t[1][2], vp_h - s * (bounds.y + bounds.height), -s * bounds.y) : (vp_h - sh) / 2 - s * bounds.y;
277
+ return [[
278
+ s,
279
+ 0,
280
+ tx
281
+ ], [
282
+ 0,
283
+ s,
284
+ ty
285
+ ]];
286
+ }
200
287
  function transform_equal(a, b) {
201
288
  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];
202
289
  }
@@ -445,7 +532,9 @@ var SvgTextSurface = class {
445
532
  this.last_sel_end = -1;
446
533
  this.textEl = textEl;
447
534
  const ownerDoc = textEl.ownerDocument;
448
- const parent = textEl.parentNode;
535
+ let mountAnchor = textEl;
536
+ while (mountAnchor.parentElement instanceof SVGElement && (mountAnchor.localName === "tspan" || mountAnchor.localName === "textPath")) mountAnchor = mountAnchor.parentElement;
537
+ const parent = mountAnchor.parentNode;
449
538
  if (!parent) throw new Error("text element has no parent");
450
539
  const computedWhitespace = ownerDoc.defaultView?.getComputedStyle(textEl).whiteSpace;
451
540
  if (!(computedWhitespace === "pre" || computedWhitespace === "pre-wrap" || computedWhitespace === "break-spaces")) {
@@ -460,14 +549,14 @@ var SvgTextSurface = class {
460
549
  selection.setAttribute("pointer-events", "none");
461
550
  selection.setAttribute("data-svg-text-edit-selection", "");
462
551
  selection.style.display = "none";
463
- parent.insertBefore(selection, textEl);
552
+ parent.insertBefore(selection, mountAnchor);
464
553
  this.selectionRect = selection;
465
554
  const caret = ownerDoc.createElementNS(SVG_NS, "rect");
466
555
  caret.setAttribute("fill", "#2563eb");
467
556
  caret.setAttribute("pointer-events", "none");
468
557
  caret.setAttribute("data-svg-text-edit-caret", "");
469
558
  caret.style.display = "none";
470
- parent.insertBefore(caret, textEl.nextSibling);
559
+ parent.insertBefore(caret, mountAnchor.nextSibling);
471
560
  this.caretRect = caret;
472
561
  }
473
562
  setText(text) {
@@ -1210,7 +1299,7 @@ var DomSurface = class {
1210
1299
  commit_intent(intent) {
1211
1300
  switch (intent.kind) {
1212
1301
  case "select":
1213
- this.editor.commands.select(intent.ids, { additive: intent.mode !== "replace" });
1302
+ this.editor.commands.select(intent.ids, { mode: intent.mode });
1214
1303
  return;
1215
1304
  case "deselect_all":
1216
1305
  this.editor.commands.deselect();
@@ -1437,13 +1526,15 @@ var DomSurface = class {
1437
1526
  if (!intent.additive) this.editor.commands.deselect();
1438
1527
  return;
1439
1528
  }
1440
- this.editor.commands.select(ids, { additive: intent.additive });
1529
+ this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
1441
1530
  }
1442
1531
  enter_content_edit(id) {
1443
1532
  if (this.text_edit) return false;
1444
1533
  const el = this.element_index.get(id);
1445
- if (!(el instanceof SVGTextElement)) return false;
1534
+ if (!(el instanceof SVGElement)) return false;
1446
1535
  const doc = this.editor._internal;
1536
+ if (!doc.doc.is_text_edit_target(id)) return false;
1537
+ if (!(el instanceof SVGTextContentElement)) return false;
1447
1538
  this.text_edit_target = id;
1448
1539
  this.text_edit_original = doc.doc.text_of(id);
1449
1540
  this.text_edit = TEXT_EDIT_PENDING;
package/dist/dom.d.mts CHANGED
@@ -1,48 +1,2 @@
1
- import { a as SurfaceHandle, d as Gestures, g as Camera, o as SvgEditor } from "./editor-DSADZszj.mjs";
2
- import cmath from "@grida/cmath";
3
-
4
- //#region src/dom.d.ts
5
- type DomSurfaceOptions = {
6
- /** Mount the SVG inside this container. */container: HTMLElement;
7
- /**
8
- * Install the default gesture set (wheel-pan/zoom, space-drag, middle-mouse,
9
- * keyboard zoom). Default `true`. Pass `false` to start blank and bind à la
10
- * carte via `handle.gestures.bind(...)`.
11
- */
12
- gestures?: boolean;
13
- /**
14
- * Auto-fit the document into the viewport on initial attach. Default
15
- * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
16
- * Subsequent `editor.load()` calls do NOT re-fit — call
17
- * `handle.camera.fit("<root>")` yourself if you want that behavior.
18
- */
19
- fit?: boolean;
20
- /**
21
- * Initial camera transform. Default `cmath.transform.identity`. Ignored
22
- * when `fit: true`.
23
- */
24
- initial_camera?: cmath.Transform;
25
- };
26
- /**
27
- * Surface handle for the DOM surface. Extends the editor's core
28
- * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
29
- * and pointer/wheel/keyboard gesture bindings (`gestures`).
30
- *
31
- * Camera + gestures are **surface-scoped**: detaching the surface drops
32
- * both. They never appear on the headless `SvgEditor`.
33
- */
34
- type DomSurfaceHandle = SurfaceHandle & {
35
- camera: Camera;
36
- gestures: Gestures;
37
- };
38
- /**
39
- * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
40
- * whose `detach()` is the inverse — DOM cleared, listeners removed,
41
- * gestures uninstalled.
42
- *
43
- * Usage is one-shot per container: the surface owns the container's children
44
- * for its lifetime, and `detach()` restores it to empty.
45
- */
46
- declare function attach_dom_surface(editor: SvgEditor, options: DomSurfaceOptions): DomSurfaceHandle;
47
- //#endregion
1
+ import { n as DomSurfaceOptions, r as attach_dom_surface, t as DomSurfaceHandle } from "./dom-CMXNUMjP.mjs";
48
2
  export { DomSurfaceHandle, DomSurfaceOptions, attach_dom_surface };
package/dist/dom.d.ts CHANGED
@@ -1,48 +1,2 @@
1
- import { a as SurfaceHandle, d as Gestures, g as Camera, o as SvgEditor } from "./editor-Da446SPO.js";
2
- import cmath from "@grida/cmath";
3
-
4
- //#region src/dom.d.ts
5
- type DomSurfaceOptions = {
6
- /** Mount the SVG inside this container. */container: HTMLElement;
7
- /**
8
- * Install the default gesture set (wheel-pan/zoom, space-drag, middle-mouse,
9
- * keyboard zoom). Default `true`. Pass `false` to start blank and bind à la
10
- * carte via `handle.gestures.bind(...)`.
11
- */
12
- gestures?: boolean;
13
- /**
14
- * Auto-fit the document into the viewport on initial attach. Default
15
- * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
16
- * Subsequent `editor.load()` calls do NOT re-fit — call
17
- * `handle.camera.fit("<root>")` yourself if you want that behavior.
18
- */
19
- fit?: boolean;
20
- /**
21
- * Initial camera transform. Default `cmath.transform.identity`. Ignored
22
- * when `fit: true`.
23
- */
24
- initial_camera?: cmath.Transform;
25
- };
26
- /**
27
- * Surface handle for the DOM surface. Extends the editor's core
28
- * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
29
- * and pointer/wheel/keyboard gesture bindings (`gestures`).
30
- *
31
- * Camera + gestures are **surface-scoped**: detaching the surface drops
32
- * both. They never appear on the headless `SvgEditor`.
33
- */
34
- type DomSurfaceHandle = SurfaceHandle & {
35
- camera: Camera;
36
- gestures: Gestures;
37
- };
38
- /**
39
- * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
40
- * whose `detach()` is the inverse — DOM cleared, listeners removed,
41
- * gestures uninstalled.
42
- *
43
- * Usage is one-shot per container: the surface owns the container's children
44
- * for its lifetime, and `detach()` restores it to empty.
45
- */
46
- declare function attach_dom_surface(editor: SvgEditor, options: DomSurfaceOptions): DomSurfaceHandle;
47
- //#endregion
1
+ import { n as DomSurfaceOptions, r as attach_dom_surface, t as DomSurfaceHandle } from "./dom-eIgcZ4JC.js";
48
2
  export { DomSurfaceHandle, DomSurfaceOptions, attach_dom_surface };
package/dist/dom.js CHANGED
@@ -1,3 +1,3 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dom = require("./dom-BlJZWpR_.js");
2
+ const require_dom = require("./dom-DyJy1H6Q.js");
3
3
  exports.attach_dom_surface = require_dom.attach_dom_surface;
package/dist/dom.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as attach_dom_surface } from "./dom-D-5D_3o0.mjs";
1
+ import { t as attach_dom_surface } from "./dom-l5Y1Wf8C.mjs";
2
2
  export { attach_dom_surface };
@@ -1,4 +1,4 @@
1
- import { i as apply_translate, l as is_text_input_focused, n as serialize_paint, o as capture_translate_baseline, t as parse_paint } from "./paint-BTKvRItP.mjs";
1
+ import { i as apply_translate, l as is_text_input_focused, n as serialize_paint, o as capture_translate_baseline, t as parse_paint } from "./paint-Cfiw4g_J.mjs";
2
2
  import { HistoryImpl } from "@grida/history";
3
3
  import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
4
4
  //#region src/commands/registry.ts
@@ -489,7 +489,7 @@ var GradientsRegistry = class {
489
489
  };
490
490
  }
491
491
  write_gradient(node, def) {
492
- for (const c of [...this.doc.children_of(node)]) this.doc.remove(c);
492
+ for (const c of this.doc.children_of(node).slice()) this.doc.remove(c);
493
493
  const set_num = (name, v) => {
494
494
  this.doc.set_attr(node, name, v === void 0 ? null : String(v));
495
495
  };
@@ -730,7 +730,6 @@ function parse_attrs(src, from) {
730
730
  const c = src[i];
731
731
  if (c === "/") {
732
732
  if (src[i + 1] !== ">") throw new Error("expected '/>' at " + i);
733
- pre + "";
734
733
  return {
735
734
  attrs,
736
735
  end_index: i + 2,
@@ -1012,6 +1011,25 @@ var SvgDocument = class SvgDocument {
1012
1011
  if (!style) return [];
1013
1012
  return parse_inline_style(style);
1014
1013
  }
1014
+ /**
1015
+ * Whether `id` can be opened in the flat-string text editor.
1016
+ *
1017
+ * v1 contract: the editor only operates on a *single flat text run*. That
1018
+ * means the target must be a `<text>` or `<tspan>` whose direct children
1019
+ * are all text nodes (or it has no children). A `<text>` containing a
1020
+ * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
1021
+ * content from the editor's view, and a flat-text write would leave the
1022
+ * tspan dangling. Tspan-as-target is fine and well-defined when it's a
1023
+ * leaf; only the host decides whether to route double-click to a tspan
1024
+ * or its parent text.
1025
+ */
1026
+ is_text_edit_target(id) {
1027
+ const n = this.nodes.get(id);
1028
+ if (!n || n.kind !== "element") return false;
1029
+ if (n.local !== "text" && n.local !== "tspan") return false;
1030
+ for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
1031
+ return true;
1032
+ }
1015
1033
  text_of(id) {
1016
1034
  const n = this.nodes.get(id);
1017
1035
  if (!n || n.kind !== "element") return "";
@@ -1318,9 +1336,16 @@ function createSvgEditor(opts) {
1318
1336
  let doc_version = 0;
1319
1337
  /** doc_version at the last load()/serialize(); compared to derive `dirty`. */
1320
1338
  let baseline_doc_version = 0;
1339
+ /**
1340
+ * Bumps once per `editor.load(svg)` call. The constructor's initial parse
1341
+ * does NOT count — it's the "factory" state. Hosts subscribe via
1342
+ * `subscribe_with_selector(s => s.load_version, ...)` to react to fresh
1343
+ * document loads without firing on every edit.
1344
+ */
1345
+ let load_version = 0;
1321
1346
  let style = {
1322
1347
  ...DEFAULT_STYLE,
1323
- ...opts.style ?? {}
1348
+ ...opts.style
1324
1349
  };
1325
1350
  const providers = opts.providers ?? {};
1326
1351
  const listeners = /* @__PURE__ */ new Set();
@@ -1335,7 +1360,8 @@ function createSvgEditor(opts) {
1335
1360
  can_undo: history.stack.canUndo,
1336
1361
  can_redo: history.stack.canRedo,
1337
1362
  version,
1338
- structure_version: doc.structure_version
1363
+ structure_version: doc.structure_version,
1364
+ load_version
1339
1365
  });
1340
1366
  }
1341
1367
  function emit() {
@@ -1372,11 +1398,16 @@ function createSvgEditor(opts) {
1372
1398
  }
1373
1399
  function select(target, opts) {
1374
1400
  const ids = typeof target === "string" ? [target] : [...target];
1375
- if (opts?.additive) {
1376
- const merged = new Set(selection);
1377
- for (const id of ids) merged.add(id);
1378
- set_selection([...merged]);
1379
- } else set_selection(ids);
1401
+ const mode = opts?.mode ?? "replace";
1402
+ if (mode === "replace") {
1403
+ set_selection(ids);
1404
+ return;
1405
+ }
1406
+ const next = new Set(selection);
1407
+ if (mode === "add") for (const id of ids) next.add(id);
1408
+ else for (const id of ids) if (next.has(id)) next.delete(id);
1409
+ else next.add(id);
1410
+ set_selection([...next]);
1380
1411
  }
1381
1412
  function deselect() {
1382
1413
  set_selection([]);
@@ -1589,7 +1620,7 @@ function createSvgEditor(opts) {
1589
1620
  function set_text(value) {
1590
1621
  if (selection.length !== 1) return;
1591
1622
  const target = selection[0];
1592
- if (doc.tag_of(target) !== "text") return;
1623
+ if (!doc.is_text_edit_target(target)) return;
1593
1624
  const original = doc.text_of(target);
1594
1625
  if (original === value) return;
1595
1626
  const apply = () => {
@@ -1632,7 +1663,7 @@ function createSvgEditor(opts) {
1632
1663
  function enter_content_edit(target) {
1633
1664
  const id = target ?? (selection.length === 1 ? selection[0] : null);
1634
1665
  if (!id) return false;
1635
- if (doc.tag_of(id) !== "text") return false;
1666
+ if (!doc.is_text_edit_target(id)) return false;
1636
1667
  if (!content_edit_driver) return false;
1637
1668
  return content_edit_driver(id);
1638
1669
  }
@@ -1643,6 +1674,7 @@ function createSvgEditor(opts) {
1643
1674
  mode = "select";
1644
1675
  history.clear();
1645
1676
  baseline_doc_version = doc_version;
1677
+ load_version++;
1646
1678
  emit();
1647
1679
  }
1648
1680
  function serialize_svg() {