@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.
@@ -158,6 +158,17 @@ type EditorState = {
158
158
  * `structure_version` so a drag doesn't invalidate the tree view.
159
159
  */
160
160
  readonly structure_version: number;
161
+ /**
162
+ * Bumps once per `editor.load(svg)` call. Distinct from
163
+ * `structure_version` (which bumps on edits too). Starts at 0; the
164
+ * constructor's initial SVG does NOT count as a load. Use this when
165
+ * you want to react to "a new document was loaded" — e.g. refit
166
+ * camera to the new root, reset host-side UI state, clear per-file
167
+ * scratch — without firing on text edits, reorders, or deletes.
168
+ *
169
+ * Monotonic, never resets.
170
+ */
171
+ readonly load_version: number;
161
172
  };
162
173
  type Unsubscribe = () => void;
163
174
  type ReorderDirection = "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back";
@@ -186,6 +197,22 @@ type CameraOptions = {
186
197
  resolve_bounds: BoundsResolver;
187
198
  initial?: cmath.Transform;
188
199
  };
200
+ /**
201
+ * Camera viewport constraint. Discriminated union with `type` so future
202
+ * variants (`'contain'`, `'pan-region'`) can be added without breaking
203
+ * existing call sites — each future variant has its own payload shape.
204
+ *
205
+ * v1.1 ships only `'cover'`. CSS analogy: `object-fit: cover` — the
206
+ * bounds rect covers the viewport edge-to-edge. Zoom is lower-bounded
207
+ * at fit-with-padding; pan is clamped so the bounds always covers the
208
+ * viewport. Use for slide / page / kiosk UX where the user should
209
+ * never see past the artwork.
210
+ */
211
+ type CameraConstraints = {
212
+ /** Bounds cover viewport (viewport ⊆ bounds). Keynote / slide UX. */type: "cover"; /** World-space rect, or `"<root>"` to resolve via BoundsResolver. */
213
+ bounds: Rect | "<root>"; /** Screen-pixel breathing room between bounds and viewport edge. */
214
+ padding?: number;
215
+ };
189
216
  /**
190
217
  * Surface-scoped pan/zoom state.
191
218
  *
@@ -199,7 +226,19 @@ declare class Camera {
199
226
  private viewport_h;
200
227
  private listeners;
201
228
  private resolve_bounds;
229
+ private _constraints;
202
230
  constructor(opts: CameraOptions);
231
+ /**
232
+ * Current viewport constraint, or `null` for free pan/zoom. Set with
233
+ * `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
234
+ * to clamp zoom + pan; assign `null` to clear.
235
+ *
236
+ * Constraints are applied synchronously inside `set_transform` (and
237
+ * `_set_viewport_size`), so every public mutation respects them
238
+ * automatically — the host never needs to subscribe-and-clamp itself.
239
+ */
240
+ get constraints(): CameraConstraints | null;
241
+ set constraints(c: CameraConstraints | null);
203
242
  /** Underlying 2D affine. World→screen. */
204
243
  get transform(): cmath.Transform;
205
244
  /** Uniform scale factor. 1 = 100 %. */
@@ -227,6 +266,10 @@ declare class Camera {
227
266
  * makes external constraint loops (e.g. "subscribe → compute clamped →
228
267
  * set_transform") terminate: the clamp re-emits the same transform on
229
268
  * the second pass, set_transform short-circuits, no recursion.
269
+ *
270
+ * When `camera.constraints` is non-null, the input transform is clamped
271
+ * synchronously before being stored — every public mutation respects the
272
+ * constraint automatically.
230
273
  */
231
274
  set_transform(t: cmath.Transform): void;
232
275
  /** Viewport size in screen pixels. Read by host code computing constraints. */
@@ -262,114 +305,20 @@ declare class Camera {
262
305
  screen_to_world(p: Vec2): Vec2;
263
306
  /** Convert a world-space point to screen-space. */
264
307
  world_to_screen(p: Vec2): Vec2;
265
- private notify;
266
- }
267
- //#endregion
268
- //#region src/commands/registry.d.ts
269
- /**
270
- * Command registry.
271
- *
272
- * A passive id-keyed registry of handlers. Built so that:
273
- *
274
- * - keybindings (in `src/keymap`) can address commands by stable id;
275
- * - new commands can be added in ONE place (`src/commands/defaults.ts`)
276
- * without growing the public surface of the editor;
277
- * - "one key, many meanings" can be expressed via chain semantics: a
278
- * handler returns `true` if it consumed, `false`/`void` otherwise,
279
- * and the dispatcher tries the next candidate in the chain.
280
- *
281
- * Handlers are plain closures — they capture whatever editor reference
282
- * they need. The registry itself stays unaware of the editor's type,
283
- * which avoids a circular type dependency between editor and registry.
284
- */
285
- /** Stable, dotted id for a command, e.g. `"history.undo"`. */
286
- type CommandId = string;
287
- /**
288
- * A command handler.
289
- *
290
- * Return `true` if the handler consumed the invocation. Return `false`
291
- * or `undefined` to signal "did not apply" — the dispatcher will try
292
- * the next candidate registered for the same key.
293
- *
294
- * Handlers are closures: they capture their editor reference. No
295
- * editor parameter is passed — keep handlers self-contained.
296
- */
297
- type CommandHandler = (args?: unknown) => boolean | void;
298
- declare class CommandRegistry {
299
- private readonly map;
300
308
  /**
301
- * Register a command. Returns an unregister function. Re-registering
302
- * the same id replaces the previous handler (last writer wins).
309
+ * Apply the current constraint (if any) to a candidate transform.
310
+ * Pure: returns the clamped result, never mutates state. Returns the
311
+ * input unchanged when constraints are null / bounds are unresolvable /
312
+ * viewport is 0.
303
313
  */
304
- register(id: CommandId, handler: CommandHandler): () => void;
314
+ private apply_constraints;
305
315
  /**
306
- * Invoke a command by id. Returns `true` if the handler consumed,
307
- * `false` otherwise (including unknown ids and handlers that returned
308
- * `false`/`undefined`).
316
+ * Re-clamp the stored transform against the current constraint. Called
317
+ * from the `constraints` setter; `_set_viewport_size` has its own
318
+ * notify-inclusive path.
309
319
  */
310
- invoke(id: CommandId, args?: unknown): boolean;
311
- has(id: CommandId): boolean;
312
- /** All registered ids, for debugging / introspection. */
313
- ids(): readonly CommandId[];
314
- }
315
- //#endregion
316
- //#region src/keymap/keymap.d.ts
317
- type KeymapBinding = {
318
- /** Declarative key combination. Build with `kb()` / `c()` / `seq()`. */keybinding: Keybinding; /** Command id to invoke on match. */
319
- command: CommandId; /** Forwarded as the `args` parameter to the command handler. */
320
- args?: unknown; /** Higher priorities run first in the chain. Default 0. */
321
- priority?: number;
322
- /**
323
- * Reserved for V2; not honored by the V1 dispatcher. When added, this
324
- * will be evaluated before the handler runs; if false, the binding is
325
- * skipped without invoking the handler.
326
- */
327
- when?: (ctx: unknown) => boolean;
328
- };
329
- declare class Keymap {
330
- private readonly commands;
331
- private readonly platformGetter;
332
- /**
333
- * Bindings bucketed by canonical chunk-key hash, computed per
334
- * `@grida/keybinding`'s `chunkKey`. Each list is the chain for that
335
- * key, sorted in dispatch order (priority desc, then registration
336
- * order).
337
- */
338
- private readonly buckets;
339
- /** Insert order, so ties on priority are deterministic. */
340
- private seq;
341
- constructor(commands: CommandRegistry, platformGetter?: () => Platform);
342
- /**
343
- * Bind a key combination to a command. Returns an unbind function.
344
- * The same `Keybinding` can be bound to multiple commands — they will
345
- * all be tried in chain order on dispatch.
346
- */
347
- bind(binding: KeymapBinding): () => void;
348
- /**
349
- * Remove bindings matching the spec. If both filters are passed, only
350
- * bindings that match BOTH are removed.
351
- */
352
- unbind(spec: {
353
- keybinding?: Keybinding;
354
- command?: CommandId;
355
- }): void;
356
- /** All registered bindings, for introspection. Order is not guaranteed. */
357
- bindings(): readonly KeymapBinding[];
358
- /**
359
- * Match the event against bound chunks, then run candidates in chain
360
- * order. Returns `true` and calls `preventDefault()` on the first
361
- * handler that consumes; returns `false` if nothing matched or all
362
- * matches fell through.
363
- */
364
- dispatch(event: KeyboardEvent): boolean;
365
- /**
366
- * Compute the set of canonical hashes a `Keybinding` lights up. A
367
- * binding with aliases (multiple sequences) contributes one hash per
368
- * single-chunk alias; multi-chunk sequences (chords) are skipped in
369
- * V1.
370
- */
371
- private chunkKeysFor;
372
- private has_safe_mod;
320
+ private reenforce;
321
+ private notify;
373
322
  }
374
323
  //#endregion
375
324
  //#region src/core/parser.d.ts
@@ -501,6 +450,19 @@ declare class SvgDocument implements DocumentEvents {
501
450
  property: string;
502
451
  value: string;
503
452
  }>;
453
+ /**
454
+ * Whether `id` can be opened in the flat-string text editor.
455
+ *
456
+ * v1 contract: the editor only operates on a *single flat text run*. That
457
+ * means the target must be a `<text>` or `<tspan>` whose direct children
458
+ * are all text nodes (or it has no children). A `<text>` containing a
459
+ * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
460
+ * content from the editor's view, and a flat-text write would leave the
461
+ * tspan dangling. Tspan-as-target is fine and well-defined when it's a
462
+ * leaf; only the host decides whether to route double-click to a tspan
463
+ * or its parent text.
464
+ */
465
+ is_text_edit_target(id: NodeId): boolean;
504
466
  text_of(id: NodeId): string;
505
467
  /** Replace all direct text children with a single text node carrying `value`. */
506
468
  set_text(id: NodeId, value: string): void;
@@ -530,6 +492,113 @@ type Defs = {
530
492
  gradients: GradientsApi;
531
493
  };
532
494
  //#endregion
495
+ //#region src/commands/registry.d.ts
496
+ /**
497
+ * Command registry.
498
+ *
499
+ * A passive id-keyed registry of handlers. Built so that:
500
+ *
501
+ * - keybindings (in `src/keymap`) can address commands by stable id;
502
+ * - new commands can be added in ONE place (`src/commands/defaults.ts`)
503
+ * without growing the public surface of the editor;
504
+ * - "one key, many meanings" can be expressed via chain semantics: a
505
+ * handler returns `true` if it consumed, `false`/`void` otherwise,
506
+ * and the dispatcher tries the next candidate in the chain.
507
+ *
508
+ * Handlers are plain closures — they capture whatever editor reference
509
+ * they need. The registry itself stays unaware of the editor's type,
510
+ * which avoids a circular type dependency between editor and registry.
511
+ */
512
+ /** Stable, dotted id for a command, e.g. `"history.undo"`. */
513
+ type CommandId = string;
514
+ /**
515
+ * A command handler.
516
+ *
517
+ * Return `true` if the handler consumed the invocation. Return `false`
518
+ * or `undefined` to signal "did not apply" — the dispatcher will try
519
+ * the next candidate registered for the same key.
520
+ *
521
+ * Handlers are closures: they capture their editor reference. No
522
+ * editor parameter is passed — keep handlers self-contained.
523
+ */
524
+ type CommandHandler = (args?: unknown) => boolean | void;
525
+ declare class CommandRegistry {
526
+ private readonly map;
527
+ /**
528
+ * Register a command. Returns an unregister function. Re-registering
529
+ * the same id replaces the previous handler (last writer wins).
530
+ */
531
+ register(id: CommandId, handler: CommandHandler): () => void;
532
+ /**
533
+ * Invoke a command by id. Returns `true` if the handler consumed,
534
+ * `false` otherwise (including unknown ids and handlers that returned
535
+ * `false`/`undefined`).
536
+ */
537
+ invoke(id: CommandId, args?: unknown): boolean;
538
+ has(id: CommandId): boolean;
539
+ /** All registered ids, for debugging / introspection. */
540
+ ids(): readonly CommandId[];
541
+ }
542
+ //#endregion
543
+ //#region src/keymap/keymap.d.ts
544
+ type KeymapBinding = {
545
+ /** Declarative key combination. Build with `kb()` / `c()` / `seq()`. */keybinding: Keybinding; /** Command id to invoke on match. */
546
+ command: CommandId; /** Forwarded as the `args` parameter to the command handler. */
547
+ args?: unknown; /** Higher priorities run first in the chain. Default 0. */
548
+ priority?: number;
549
+ /**
550
+ * Reserved for V2; not honored by the V1 dispatcher. When added, this
551
+ * will be evaluated before the handler runs; if false, the binding is
552
+ * skipped without invoking the handler.
553
+ */
554
+ when?: (ctx: unknown) => boolean;
555
+ };
556
+ declare class Keymap {
557
+ private readonly commands;
558
+ private readonly platformGetter;
559
+ /**
560
+ * Bindings bucketed by canonical chunk-key hash, computed per
561
+ * `@grida/keybinding`'s `chunkKey`. Each list is the chain for that
562
+ * key, sorted in dispatch order (priority desc, then registration
563
+ * order).
564
+ */
565
+ private readonly buckets;
566
+ /** Insert order, so ties on priority are deterministic. */
567
+ private seq;
568
+ constructor(commands: CommandRegistry, platformGetter?: () => Platform);
569
+ /**
570
+ * Bind a key combination to a command. Returns an unbind function.
571
+ * The same `Keybinding` can be bound to multiple commands — they will
572
+ * all be tried in chain order on dispatch.
573
+ */
574
+ bind(binding: KeymapBinding): () => void;
575
+ /**
576
+ * Remove bindings matching the spec. If both filters are passed, only
577
+ * bindings that match BOTH are removed.
578
+ */
579
+ unbind(spec: {
580
+ keybinding?: Keybinding;
581
+ command?: CommandId;
582
+ }): void;
583
+ /** All registered bindings, for introspection. Order is not guaranteed. */
584
+ bindings(): readonly KeymapBinding[];
585
+ /**
586
+ * Match the event against bound chunks, then run candidates in chain
587
+ * order. Returns `true` and calls `preventDefault()` on the first
588
+ * handler that consumes; returns `false` if nothing matched or all
589
+ * matches fell through.
590
+ */
591
+ dispatch(event: KeyboardEvent): boolean;
592
+ /**
593
+ * Compute the set of canonical hashes a `Keybinding` lights up. A
594
+ * binding with aliases (multiple sequences) contributes one hash per
595
+ * single-chunk alias; multi-chunk sequences (chords) are skipped in
596
+ * V1.
597
+ */
598
+ private chunkKeysFor;
599
+ private has_safe_mod;
600
+ }
601
+ //#endregion
533
602
  //#region src/core/properties.d.ts
534
603
  declare function read_property(doc: SvgDocument, id: NodeId, property: string): PropertyValue;
535
604
  //#endregion
@@ -619,9 +688,18 @@ type Surface = {
619
688
  type SurfaceHandle = {
620
689
  detach(): void;
621
690
  };
691
+ /**
692
+ * Mode for `commands.select`. Matches the HUD's `SelectMode` vocabulary so
693
+ * intents can be threaded through without lossy collapsing.
694
+ *
695
+ * - `"replace"` (default) — set selection to `ids`, discarding the previous set.
696
+ * - `"add"` — union: each id in `ids` is added; existing members stay.
697
+ * - `"toggle"` — flip each id's membership (present → removed; absent → added).
698
+ */
699
+ type SelectMode = "replace" | "add" | "toggle";
622
700
  type Commands = {
623
701
  select(target: NodeId | ReadonlyArray<NodeId>, opts?: {
624
- additive?: boolean;
702
+ mode?: SelectMode;
625
703
  }): void;
626
704
  deselect(): void;
627
705
  enter_scope(group: NodeId): void;
@@ -759,4 +837,4 @@ declare function createSvgEditor(opts: CreateSvgEditorOptions): {
759
837
  keymap: Keymap;
760
838
  };
761
839
  //#endregion
762
- export { Mode as A, RadialGradientDefinition as B, FileIOProvider as C, GradientStop as D, GradientEntry as E, PaintValue as F, ReorderDirection as H, PreviewSession as I, PropertyValue as L, Paint as M, PaintFallback as N, InvalidComputedValue as O, PaintPreviewSession as P, Provenance as R, EditorStyle as S, GradientDefinition as T, Unsubscribe as U, Rect as V, Vec2 as W, CameraOptions as _, SurfaceHandle as a, DEFAULT_STYLE as b, GestureBinding as c, Gestures as d, KeymapBinding as f, Camera as g, BoundsResolver as h, DomComputedResolver as i, NodeId as j, LinearGradientDefinition as k, GestureContext as l, CommandId as m, CreateSvgEditorOptions as n, SvgEditor as o, CommandHandler as p, DomComputedPaint as r, createSvgEditor as s, Commands as t, GestureId as u, ClipboardProvider as v, FontResolver as w, EditorState as x, Color as y, Providers as z };
840
+ export { InvalidComputedValue as A, Provenance as B, EditorState as C, GradientDefinition as D, FontResolver as E, PaintFallback as F, Unsubscribe as G, RadialGradientDefinition as H, PaintPreviewSession as I, Vec2 as K, PaintValue as L, Mode as M, NodeId as N, GradientEntry as O, Paint as P, PreviewSession as R, DEFAULT_STYLE as S, FileIOProvider as T, Rect as U, Providers as V, ReorderDirection as W, Camera as _, SelectMode as a, ClipboardProvider as b, createSvgEditor as c, GestureId as d, Gestures as f, BoundsResolver as g, CommandId as h, DomComputedResolver as i, LinearGradientDefinition as j, GradientStop as k, GestureBinding as l, CommandHandler as m, CreateSvgEditorOptions as n, SurfaceHandle as o, KeymapBinding as p, DomComputedPaint as r, SvgEditor as s, Commands as t, GestureContext as u, CameraConstraints as v, EditorStyle as w, Color as x, CameraOptions as y, PropertyValue as z };
@@ -1,5 +1,5 @@
1
- require("./dom-BlJZWpR_.js");
2
- const require_paint = require("./paint-CVLZazOa.js");
1
+ require("./dom-DyJy1H6Q.js");
2
+ const require_paint = require("./paint-cTjePy5e.js");
3
3
  let _grida_history = require("@grida/history");
4
4
  let _grida_keybinding = require("@grida/keybinding");
5
5
  //#region src/commands/registry.ts
@@ -490,7 +490,7 @@ var GradientsRegistry = class {
490
490
  };
491
491
  }
492
492
  write_gradient(node, def) {
493
- for (const c of [...this.doc.children_of(node)]) this.doc.remove(c);
493
+ for (const c of this.doc.children_of(node).slice()) this.doc.remove(c);
494
494
  const set_num = (name, v) => {
495
495
  this.doc.set_attr(node, name, v === void 0 ? null : String(v));
496
496
  };
@@ -731,7 +731,6 @@ function parse_attrs(src, from) {
731
731
  const c = src[i];
732
732
  if (c === "/") {
733
733
  if (src[i + 1] !== ">") throw new Error("expected '/>' at " + i);
734
- pre + "";
735
734
  return {
736
735
  attrs,
737
736
  end_index: i + 2,
@@ -1013,6 +1012,25 @@ var SvgDocument = class SvgDocument {
1013
1012
  if (!style) return [];
1014
1013
  return parse_inline_style(style);
1015
1014
  }
1015
+ /**
1016
+ * Whether `id` can be opened in the flat-string text editor.
1017
+ *
1018
+ * v1 contract: the editor only operates on a *single flat text run*. That
1019
+ * means the target must be a `<text>` or `<tspan>` whose direct children
1020
+ * are all text nodes (or it has no children). A `<text>` containing a
1021
+ * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
1022
+ * content from the editor's view, and a flat-text write would leave the
1023
+ * tspan dangling. Tspan-as-target is fine and well-defined when it's a
1024
+ * leaf; only the host decides whether to route double-click to a tspan
1025
+ * or its parent text.
1026
+ */
1027
+ is_text_edit_target(id) {
1028
+ const n = this.nodes.get(id);
1029
+ if (!n || n.kind !== "element") return false;
1030
+ if (n.local !== "text" && n.local !== "tspan") return false;
1031
+ for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
1032
+ return true;
1033
+ }
1016
1034
  text_of(id) {
1017
1035
  const n = this.nodes.get(id);
1018
1036
  if (!n || n.kind !== "element") return "";
@@ -1319,9 +1337,16 @@ function createSvgEditor(opts) {
1319
1337
  let doc_version = 0;
1320
1338
  /** doc_version at the last load()/serialize(); compared to derive `dirty`. */
1321
1339
  let baseline_doc_version = 0;
1340
+ /**
1341
+ * Bumps once per `editor.load(svg)` call. The constructor's initial parse
1342
+ * does NOT count — it's the "factory" state. Hosts subscribe via
1343
+ * `subscribe_with_selector(s => s.load_version, ...)` to react to fresh
1344
+ * document loads without firing on every edit.
1345
+ */
1346
+ let load_version = 0;
1322
1347
  let style = {
1323
1348
  ...DEFAULT_STYLE,
1324
- ...opts.style ?? {}
1349
+ ...opts.style
1325
1350
  };
1326
1351
  const providers = opts.providers ?? {};
1327
1352
  const listeners = /* @__PURE__ */ new Set();
@@ -1336,7 +1361,8 @@ function createSvgEditor(opts) {
1336
1361
  can_undo: history.stack.canUndo,
1337
1362
  can_redo: history.stack.canRedo,
1338
1363
  version,
1339
- structure_version: doc.structure_version
1364
+ structure_version: doc.structure_version,
1365
+ load_version
1340
1366
  });
1341
1367
  }
1342
1368
  function emit() {
@@ -1373,11 +1399,16 @@ function createSvgEditor(opts) {
1373
1399
  }
1374
1400
  function select(target, opts) {
1375
1401
  const ids = typeof target === "string" ? [target] : [...target];
1376
- if (opts?.additive) {
1377
- const merged = new Set(selection);
1378
- for (const id of ids) merged.add(id);
1379
- set_selection([...merged]);
1380
- } else set_selection(ids);
1402
+ const mode = opts?.mode ?? "replace";
1403
+ if (mode === "replace") {
1404
+ set_selection(ids);
1405
+ return;
1406
+ }
1407
+ const next = new Set(selection);
1408
+ if (mode === "add") for (const id of ids) next.add(id);
1409
+ else for (const id of ids) if (next.has(id)) next.delete(id);
1410
+ else next.add(id);
1411
+ set_selection([...next]);
1381
1412
  }
1382
1413
  function deselect() {
1383
1414
  set_selection([]);
@@ -1590,7 +1621,7 @@ function createSvgEditor(opts) {
1590
1621
  function set_text(value) {
1591
1622
  if (selection.length !== 1) return;
1592
1623
  const target = selection[0];
1593
- if (doc.tag_of(target) !== "text") return;
1624
+ if (!doc.is_text_edit_target(target)) return;
1594
1625
  const original = doc.text_of(target);
1595
1626
  if (original === value) return;
1596
1627
  const apply = () => {
@@ -1633,7 +1664,7 @@ function createSvgEditor(opts) {
1633
1664
  function enter_content_edit(target) {
1634
1665
  const id = target ?? (selection.length === 1 ? selection[0] : null);
1635
1666
  if (!id) return false;
1636
- if (doc.tag_of(id) !== "text") return false;
1667
+ if (!doc.is_text_edit_target(id)) return false;
1637
1668
  if (!content_edit_driver) return false;
1638
1669
  return content_edit_driver(id);
1639
1670
  }
@@ -1644,6 +1675,7 @@ function createSvgEditor(opts) {
1644
1675
  mode = "select";
1645
1676
  history.clear();
1646
1677
  baseline_doc_version = doc_version;
1678
+ load_version++;
1647
1679
  emit();
1648
1680
  }
1649
1681
  function serialize_svg() {