@grida/svg-editor 1.0.0-alpha.18 → 1.0.0-alpha.20

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.
package/README.md CHANGED
@@ -228,11 +228,21 @@ Each will become a public seam when a second surface implementation arrives and
228
228
 
229
229
  Geometry (world-space bboxes, screen ↔ local projection) is exposed via `editor.geometry`, not the `Surface` itself — the DOM surface registers a `MemoizedGeometryProvider` with the editor on attach so headless callers can query bounds without going through the surface.
230
230
 
231
- `@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `MemoizedGeometryProvider`, `DomComputedResolver`) that callers writing alternative surfaces or advanced integrations may need. It mounts the SVG into the container, wires pointer / keyboard listeners scoped to that container, and uses native `getBBox` / `getScreenCTM` for geometry. It is the only place in this package that imports DOM types.
231
+ `@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `AttentionScope`, `MemoizedGeometryProvider`, `DomComputedResolver`) that callers writing alternative surfaces or advanced integrations may need. It mounts the SVG into the container, wires pointer / keyboard listeners scoped to that container, and uses native `getBBox` / `getScreenCTM` for geometry. It is the only place in this package that imports DOM types.
232
232
 
233
- The container is **exclusively owned** by the surface. Render toolbars, layer lists, inspectors, and any other interactive chrome as **siblings** of the container, not children. Children of the container interfere with pointer routing (capture redirects, hit-test ordering) and produce silent click breakage. The shipped `SvgEditorCanvas` React component enforces this by creating its own internal div; hosts using `domSurface` / `keynote.attach` directly are responsible for the same discipline. In development, the surface emits a `console.warn` at attach time when the container is non-empty.
233
+ The container is **exclusively owned** by the surface. Render toolbars, layer lists, inspectors, and any other interactive chrome as **siblings** of the container, not children. Children of the container interfere with pointer routing (capture redirects, hit-test ordering) and produce silent click breakage. The shipped `SvgEditorCanvas` React component enforces this by creating its own internal div; hosts using `domSurface` / `keynote.attach` directly are responsible for the same discipline. In development, the surface emits a `console.warn` at attach time when the container is non-empty. Sibling chrome that drives editor commands should be registered into the attention scope (`handle.attention`, below) so the keymap stays live while the user works in it.
234
234
 
235
- **Attention gate.** The DOM surface installs document- and window-level keydown listeners (so a user with focus on a side-panel button can still hit editor shortcuts while the pointer is on the canvas). Those listeners are gated by an internal attention predicate: a key is claimed (and `preventDefault()`-ed) only when **focus is inside the container's subtree OR the pointer is over the container**. Body-focus alone — the natural state when the surface is embedded as a block in a longer document — is not attended, so the editor stays out of the way of page-level shortcuts (Space / arrows to scroll, Cmd+= to zoom, etc.). Passive observation listeners (modifier mirrors, blur resets) are not gated — they don't call `preventDefault()` and need to stay live across focus boundaries.
235
+ **Attention gate.** The DOM surface installs document- and window-level keydown listeners (so a user with focus on a side-panel button can still hit editor shortcuts while the pointer is on the canvas). Those listeners are gated by an internal attention predicate: a key is claimed (and `preventDefault()`-ed) only when **focus is inside the attention scope OR the pointer is over it**. Body-focus alone — the natural state when the surface is embedded as a block in a longer document — is not attended, so the editor stays out of the way of page-level shortcuts (Space / arrows to scroll, Cmd+= to zoom, etc.). Passive observation listeners (modifier mirrors, blur resets) are not gated — they don't call `preventDefault()` and need to stay live across focus boundaries.
236
+
237
+ The scope starts as the container alone. Editor-adjacent host chrome — an inspector, a toolbar, a zoom menu; anything that drives `commands.*` — is a DOM sibling of the container (per the exclusive-ownership rule above), so by default the gate cannot tell it from unrelated app surface: clicking its buttons moves focus out of the container, hovering it leaves the container, and the whole keymap (undo / delete / tool keys) goes dark until the user re-attends the canvas. Register such chrome into the scope via the handle:
238
+
239
+ ```ts
240
+ const handle = attach_dom_surface(editor, { container });
241
+ handle.attention.add(inspectorEl); // counts for focus-within + pointer-over
242
+ handle.attention.remove(inspectorEl); // e.g. on unmount
243
+ ```
244
+
245
+ Registered elements get the full keymap — with text inputs inside them still excluded by the keymap's own guard. The native clipboard gate (deliberately stricter: focus-only, never pointer-over) honors the registered set's focus arm the same way. `add` is idempotent; registrations live until removed or the surface detaches.
236
246
 
237
247
  ### Lifecycle
238
248
 
@@ -380,6 +390,7 @@ Write — selection-scoped. Writing the same value to every selected node has no
380
390
  editor.commands.set_property(name: string, value: string | null): void;
381
391
 
382
392
  editor.commands.preview_property(name: string): {
393
+ readonly live: boolean; // false once the session has ended, for any reason
383
394
  update(value: string): void;
384
395
  commit(): void;
385
396
  discard(): void;
@@ -388,6 +399,8 @@ editor.commands.preview_property(name: string): {
388
399
 
389
400
  The editor decides whether to write a presentation attribute vs. inline style for each selected node based on whichever wins the cascade for that element (P1). The preview session is what a number-input scrub or color-picker drag uses: many `update()` calls during drag, one `commit()` on pointer-up.
390
401
 
402
+ A preview session ends as soon as its result can no longer become the document's next state — and after it ends, every method is a no-op and `session.live` is `false`. A discrete write to the same property (`set_property(name, …)`, or `set_paint` / `set_paint_from_gradient` on the same channel) **supersedes** an open session on that name, so a UI that mixes a picker drag with preset buttons cannot replay the stale dragged value when it commits on close; sessions on other property names are unaffected by discrete writes. Operations that detach the session's targets (`remove` / `cut`, `ungroup`), a document swap (`load` / `reset`), and `undo` / `redo` end open sessions on every name. Hosts that cache a session should consult `live` and lazily reopen — the bundled React hooks do.
403
+
391
404
  ### Observation — paint (`fill` / `stroke`)
392
405
 
393
406
  `fill` and `stroke` are common enough — and shape-different enough from a plain string — that they get a dedicated typed API. A solid color, a paint-server reference, and `currentColor` are not interchangeable strings; pretending they are is what produces editors that round-trip badly.
@@ -415,7 +428,9 @@ type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
415
428
  // Color preserves currentColor as a keyword at computed time (CSS Color 4 §4.4); the
416
429
  // rgb resolution happens at *used* value, which requires the surface's painting context.
417
430
  type Color =
418
- | { kind: "rgb"; value: string } // any resolvable CSS color, normalized to rgb-ish
431
+ | { kind: "rgb"; value: string } // canonical lowercase hex (#rrggbb / #rrggbbaa) for any
432
+ // literal resolvable without a rendering context (named / hex / rgb() / hsl() / hwb());
433
+ // unresolved spaces (lab() / oklch() / color()) pass through as authored
419
434
  | { kind: "current_color" }; // unresolved keyword; surface dereferences at paint time
420
435
 
421
436
  type PaintValue = {
@@ -443,6 +458,7 @@ Write — selection-scoped (same reasoning as for generic properties):
443
458
  editor.commands.set_paint(channel: "fill" | "stroke", paint: Paint): void;
444
459
 
445
460
  editor.commands.preview_paint(channel: "fill" | "stroke"): {
461
+ readonly live: boolean; // false once the session has ended, for any reason
446
462
  update(paint: Paint): void;
447
463
  commit(): void;
448
464
  discard(): void;
@@ -946,6 +962,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
946
962
  - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
947
963
  - **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
948
964
  - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
965
+ - **Not a customizable selection policy.** What a pick or a marquee selects (the marquee shadow rule, meta routing, additive) is a fixed product decision, owned by the labeled policy layer (`src/selection/`, spec [`docs/marquee-selection.md`](./docs/marquee-selection.md)). No host hook, provider, or registry swaps it; the opinion lives above the engine, never inside it, and never as a public knob.
949
966
  - **Not a private IR.** SVG is the source of truth. The editor does not maintain an alternative on-disk format, and the bytes are not projected from any in-memory canonical store. (The internal typed element IR described under [Paradigm § Element IR (internal)](#element-ir-internal) is a typed view over the parsed AST, not a store the file is derived from — the AST and the file are the source of truth, and the IR is rebuilt from them on each load.)
950
967
  - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
951
968
  - **Not an input-interception hook.** The pick/tap observation (`subscribe_pick`) reports a click that already happened; it cannot prevent, delay, or replace the editor's own selection and gesture handling. A host that needs to intercept input owns the container and splices its own layer in (the DOM escape hatch) — it does not get a veto through the observation surface.
@@ -1,5 +1,54 @@
1
- import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-BSxTUsW_.js";
1
+ import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-CxqRhhzP.js";
2
2
  import cmath from "@grida/cmath";
3
+ //#region src/util/attention.d.ts
4
+ /** The runtime handle returned by `create_attention_tracker`. */
5
+ interface AttentionTracker {
6
+ /**
7
+ * `true` iff focus is inside the attention scope (the container's
8
+ * subtree or a registered element's subtree) OR the pointer is
9
+ * currently over any element of the scope. See module doc for the
10
+ * rationale.
11
+ *
12
+ * Pure read; no DOM mutation. Cheap enough to call once per keydown.
13
+ */
14
+ is_attended(): boolean;
15
+ /**
16
+ * `true` iff focus is inside the attention scope — the focus arm of
17
+ * {@link is_attended} alone, WITHOUT the pointer-over arm.
18
+ *
19
+ * Exists for the native clipboard gate (`dom.ts`): pointer-over is a
20
+ * sufficient signal to claim a keystroke (worst case: a stolen scroll)
21
+ * but NOT a copy/cut/paste gesture (worst case: destroying what the
22
+ * user believed they copied, or routing a paste meant for a host text
23
+ * field into the document). See docs/wg/feat-svg-editor/clipboard.md
24
+ * §Transport "Gating the native events".
25
+ */
26
+ is_focus_within(): boolean;
27
+ /**
28
+ * Register a host-chrome element into the attention scope. Editor-
29
+ * adjacent chrome — an inspector, a toolbar, a zoom menu; anything that
30
+ * drives `commands.*` — is a DOM *sibling* of the container (the
31
+ * container is exclusively surface-owned), so without registration the
32
+ * tracker cannot tell it apart from unrelated page surface: clicking
33
+ * its buttons moves focus out of the container, and hovering it fires
34
+ * the container's `pointerleave`, blacking out the whole keymap.
35
+ * Registered elements count for both arms — focus-within and
36
+ * pointer-over. Idempotent; re-adding a registered element is a no-op.
37
+ */
38
+ add(element: Element): void;
39
+ /**
40
+ * Unregister an element added via {@link add}. Also clears any
41
+ * still-latched pointer-over contribution from it (the element may be
42
+ * unmounted mid-hover — e.g. a popover closing under the cursor).
43
+ * Unknown elements are a no-op. The container itself cannot be
44
+ * removed; it is the scope's fixed root, not a registered extra.
45
+ */
46
+ remove(element: Element): void;
47
+ /** Detach the internal pointer-tracking listeners (container and every
48
+ * registered element). */
49
+ dispose(): void;
50
+ }
51
+ //#endregion
3
52
  //#region src/core/snap/options.d.ts
4
53
  type SnapOptions = {
5
54
  /** When false, snap behavior and snap-guide rendering are both off. */enabled: boolean;
@@ -86,17 +135,39 @@ type DomSurfaceOptions = {
86
135
  */
87
136
  font_load_source?: EventTarget;
88
137
  };
138
+ /**
139
+ * Host-extendable attention scope — public via `handle.attention`.
140
+ *
141
+ * The document-level keymap (undo / redo / delete / tool keys) is gated on
142
+ * attention: focus inside the scope, or pointer over it. The scope starts
143
+ * as the container alone, which makes editor-adjacent host chrome — an
144
+ * inspector, a toolbar, a zoom menu; anything that drives `commands.*` —
145
+ * indistinguishable from unrelated page surface (chrome must be a DOM
146
+ * *sibling* of the container, never a child). Registering a chrome element
147
+ * keeps the full keymap live while the user works in it, with
148
+ * text-input focus still excluded by the keymap's own guard. The native
149
+ * clipboard gate (deliberately stricter: focus-only, never pointer-over)
150
+ * honors the registered set's focus arm the same way.
151
+ *
152
+ * `add` is idempotent; `remove` of an unregistered element is a no-op.
153
+ * Registrations live for the surface's lifetime — `detach()` drops them
154
+ * with the tracker. Hosts with mounting/unmounting chrome (popovers,
155
+ * panels) pair `add` on mount with `remove` on unmount.
156
+ */
157
+ type AttentionScope = Pick<AttentionTracker, "add" | "remove">;
89
158
  /**
90
159
  * Surface handle for the DOM surface. Extends the editor's core
91
- * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
92
- * and pointer/wheel/keyboard gesture bindings (`gestures`).
160
+ * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`),
161
+ * pointer/wheel/keyboard gesture bindings (`gestures`), and the
162
+ * host-extendable attention scope (`attention`).
93
163
  *
94
- * Camera + gestures are **surface-scoped**: detaching the surface drops
95
- * both. They never appear on the headless `SvgEditor`.
164
+ * Camera, gestures, and attention are **surface-scoped**: detaching the
165
+ * surface drops all three. They never appear on the headless `SvgEditor`.
96
166
  */
97
167
  type DomSurfaceHandle = SurfaceHandle & {
98
168
  camera: Camera;
99
169
  gestures: Gestures;
170
+ attention: AttentionScope;
100
171
  };
101
172
  /**
102
173
  * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
@@ -163,4 +234,4 @@ declare function inverse_project_rect(rect: {
163
234
  f: number;
164
235
  }, offset: readonly [number, number]): cmath.Rectangle | null;
165
236
  //#endregion
166
- export { inverse_project_rect as a, DEFAULT_SNAP_OPTIONS as c, install_font_load_geometry_bump as i, SnapOptions as l, DomSurfaceOptions as n, project_delta_inverse_ctm as o, attach_dom_surface as r, project_point_through_ctm as s, DomSurfaceHandle as t };
237
+ export { install_font_load_geometry_bump as a, project_point_through_ctm as c, attach_dom_surface as i, DEFAULT_SNAP_OPTIONS as l, DomSurfaceHandle as n, inverse_project_rect as o, DomSurfaceOptions as r, project_delta_inverse_ctm as s, AttentionScope as t, SnapOptions as u };