@grida/svg-editor 1.0.0-alpha.15 → 1.0.0-alpha.17
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 +69 -0
- package/dist/{dom-CsKXTaNw.d.ts → dom-BMzX1CXZ.d.ts} +56 -2
- package/dist/{dom-DILY80j7.mjs → dom-Bjj9xySE.mjs} +171 -13
- package/dist/{dom-Dee6FtgZ.js → dom-CaByuo6C.js} +176 -12
- package/dist/{dom-CK6GlgFF.d.mts → dom-TctdgRnn.d.mts} +56 -2
- package/dist/dom.d.mts +3 -3
- package/dist/dom.d.ts +3 -3
- package/dist/dom.js +2 -1
- package/dist/dom.mjs +2 -2
- package/dist/{editor-CvWpD5mu.mjs → editor-BLsELHSZ.mjs} +769 -866
- package/dist/{editor-BKoo9SPL.d.ts → editor-BSxTUsW_.d.ts} +553 -5
- package/dist/{editor-Dl7c0q5A.d.mts → editor-KqpIW1qm.d.mts} +553 -5
- package/dist/{editor-F8ckj9X1.js → editor-N9af0JD2.js} +769 -866
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{model-B2UWgViT.mjs → model-DMaN5GnH.mjs} +1442 -72
- package/dist/{model-CJ1Ctq14.js → model-GpysNbOv.js} +1459 -71
- package/dist/presets.d.mts +2 -2
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +2 -2
- package/dist/presets.mjs +1 -1
- package/dist/react.d.mts +28 -4
- package/dist/react.d.ts +28 -4
- package/dist/react.js +29 -4
- package/dist/react.mjs +29 -5
- package/package.json +9 -6
package/README.md
CHANGED
|
@@ -304,6 +304,25 @@ editor.subscribe_with_selector<T>(
|
|
|
304
304
|
|
|
305
305
|
`state` is a frozen snapshot. Consumers never destructure into internals; if a view they need isn't here or in the purpose-built views below, that's an API gap.
|
|
306
306
|
|
|
307
|
+
### Observation — pick (tap)
|
|
308
|
+
|
|
309
|
+
A **pick** is a discrete tap on the canvas — a press and release within the drag threshold, no drag. It is observe-only and deliberately **separate from selection**: selection answers "what do commands target," a pick answers "what did the user just click, and where." A primary tap on a node both selects it _and_ emits a pick; a tap on empty canvas emits a pick with `node_id: null` (distinguishable from "nothing is selected," which selection alone cannot express); a secondary (right-button) tap emits a pick and does **not** change selection. This is the seam a click-driven host tool needs — a comment / annotation tool anchors UI at `point` and scopes its action to `node_id`, or to the whole document when `null`.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
type PickEvent = {
|
|
313
|
+
point: Vec2; // document-space — the pointer-DOWN point the tap resolved against
|
|
314
|
+
node_id: NodeId | null; // topmost node under point; null = empty canvas
|
|
315
|
+
button: "primary" | "secondary"; // middle is pan, never taps
|
|
316
|
+
mods: { shift: boolean; alt: boolean; meta: boolean; ctrl: boolean };
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
editor.subscribe_pick(fn: (e: PickEvent) => void): Unsubscribe;
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The point is document-space and always the pointer-**down** point — so it stays correct even for a tap on an already-selected node (whose selection commits on pointer-up). The channel does **not** bump `state.version`. In React, wire it with `useEditorPick(handler)`.
|
|
323
|
+
|
|
324
|
+
> **Status:** `@unstable` — shipped against one consumer; the shape is open until a second click-driven tool exercises it (P6).
|
|
325
|
+
|
|
307
326
|
### Observation — properties
|
|
308
327
|
|
|
309
328
|
This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection ("mixed values") is a separate concern; see the [Multi-selection](#multi-selection-mixed-values) section below. The two are kept apart on purpose: property semantics is defined by the spec; mixed semantics is an aggregation layer the editor adds because it supports multi-select.
|
|
@@ -608,6 +627,8 @@ editor.commands.{
|
|
|
608
627
|
resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
|
|
609
628
|
rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
610
629
|
rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
630
|
+
// `matrix` is SVG `matrix(a b c d e f)` order (the `Matrix2D` tuple).
|
|
631
|
+
transform(matrix: Matrix2D, opts?: { ids?: NodeId[]; pivot?: { x: number; y: number } }): boolean;
|
|
611
632
|
flatten_transform(): void; // bake `transform=` into native attrs where possible
|
|
612
633
|
|
|
613
634
|
// alignment (operates on selection of ≥2 nodes against their union bbox)
|
|
@@ -616,12 +637,57 @@ editor.commands.{
|
|
|
616
637
|
// structure
|
|
617
638
|
reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
|
|
618
639
|
group(): void; // wrap selection in a new <g>
|
|
640
|
+
ungroup(opts?: { id?: NodeId }): boolean; // dissolve a plain structural <g>
|
|
641
|
+
// (clean-structural subset only; refuses
|
|
642
|
+
// groups with visual state — see TODO §10)
|
|
619
643
|
remove(): void;
|
|
620
644
|
|
|
645
|
+
// clipboard — the payload is a STANDALONE SVG DOCUMENT, not a private
|
|
646
|
+
// format (the file is the IR, so the clipboard is the file format).
|
|
647
|
+
// Copy carries the outbound url(#…)/href reference closure in one
|
|
648
|
+
// <defs> block and declares borrowed xmlns prefixes on the payload
|
|
649
|
+
// shell; ancestor transforms / inherited presentation / viewport are
|
|
650
|
+
// deliberately NOT carried (verbatim policy). Cut = copy + remove as
|
|
651
|
+
// ONE history step labeled "cut"; undo restores the document and the
|
|
652
|
+
// clipboard keeps the payload (cut → undo → paste = move). Paste is
|
|
653
|
+
// synchronous over delivered text (`text ?? internal buffer`) and has
|
|
654
|
+
// a gesture-grade refusal table: non-parseable environment input is a
|
|
655
|
+
// no-op `[]`, never a throw (insert_fragment keeps strict semantics).
|
|
656
|
+
// System-clipboard wiring is the DOM surface's native ClipboardEvent
|
|
657
|
+
// transport (text/plain = the markup itself) plus the optional
|
|
658
|
+
// ClipboardProvider seam. Full contract:
|
|
659
|
+
// https://grida.co/docs/wg/feat-svg-editor/clipboard
|
|
660
|
+
copy(): string | null; // payload | null on empty selection; no history
|
|
661
|
+
cut(): string | null; // one undoable step; buffer secured before delete
|
|
662
|
+
paste(text?: string): NodeId[]; // inserted roots (selected); [] = refusal
|
|
663
|
+
|
|
664
|
+
// duplicate — the clipboard FRD's SECOND extraction operation
|
|
665
|
+
// (subtree clone): in-document, so NO defs closure and NO xmlns
|
|
666
|
+
// shell are carried; subtrees and authored ids clone verbatim
|
|
667
|
+
// (colliding ids resolve first-in-document-order; Tidy dedups).
|
|
668
|
+
// Each clone lands as its origin's next sibling (paints above it);
|
|
669
|
+
// selection moves to the clones; ONE history step. Alt-drag
|
|
670
|
+
// translate-with-clone consumes the same operation. Repeating
|
|
671
|
+
// offset: duplicate → move the copy → duplicate repeats the
|
|
672
|
+
// translate delta (an Alt-drag clone commit arms the same memory);
|
|
673
|
+
// still one undo step, degrades to in-place when the preconditions
|
|
674
|
+
// don't hold. Contract:
|
|
675
|
+
// https://grida.co/docs/wg/feat-svg-editor/subtree-clone
|
|
676
|
+
duplicate(): NodeId[]; // clone ids (selected); [] = refusal
|
|
677
|
+
|
|
621
678
|
// insertion — `tag` is an open string (so paste / RPC can create any element,
|
|
622
679
|
// e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
|
|
623
680
|
// draw gesture and default paint.
|
|
624
681
|
insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
|
|
682
|
+
// markup-shaped sibling of `insert` — one or more sibling elements, or a
|
|
683
|
+
// full `<svg>` doc (the shell is discarded; its children are the content).
|
|
684
|
+
// Subtrees adopted verbatim; ONE history step; returns root ids in
|
|
685
|
+
// document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
|
|
686
|
+
// undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
|
|
687
|
+
// in the same step. Position is authored content: wrap the fragment in
|
|
688
|
+
// `<g transform="translate(x y)">` to land it at a point — same single
|
|
689
|
+
// undo step, no placement opt.
|
|
690
|
+
insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
|
|
625
691
|
insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
|
|
626
692
|
|
|
627
693
|
// content
|
|
@@ -643,6 +709,8 @@ editor.commands.{
|
|
|
643
709
|
|
|
644
710
|
All commands operate on `state.selection` unless they take an explicit target. Commands that can't apply (e.g. `set_text` with no text node selected) are no-ops, not errors.
|
|
645
711
|
|
|
712
|
+
`transform` composes a general 2×3 affine onto the selection **relative** and **in world space about a pivot** (default: the selection union-bbox center) — `E = T(pivot) · matrix · T(-pivot)` — so a bare `[-1, 0, 0, 1, 0, 0]` is an in-place horizontal flip and `[1, 0, 0, -1, 0, 0]` a vertical one. The editor owns the round-trip: `E` is folded onto each member's transform list as a **single leading `matrix` op** (existing `rotate`/`translate` tokens are preserved after it; repeated applies collapse into one matrix; a net-identity leading matrix is dropped). It refuses (returns `false`, no history) on empty selection, no attached surface, or any member that isn't rotatable (matrix / scale / skew / `<text rotate>` / CSS-property / animated transforms — same gate as `rotate`; Flatten Transform is the recovery path). Flat-doc only: nested transformed ancestors are out of scope.
|
|
713
|
+
|
|
646
714
|
(Naming convention for the API surface is `snake_case` to match the SVG / CSS property naming the editor already echoes — `set_property("stroke-width", …)` reads cleanly next to `set_paint("fill", …)`. JavaScript identifiers use `snake_case`; user-facing strings that mirror SVG attribute names stay `kebab-case` exactly as the spec writes them.)
|
|
647
715
|
|
|
648
716
|
### Providers
|
|
@@ -880,6 +948,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
|
|
|
880
948
|
- **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
|
|
881
949
|
- **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.)
|
|
882
950
|
- **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
|
|
951
|
+
- **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.
|
|
883
952
|
|
|
884
953
|
If a consumer needs any of the above, the right answer is "this is the wrong tool." Saying yes to any one is the path that turned the Grida main editor into a 6,800-line god-class.
|
|
885
954
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-
|
|
1
|
+
import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-BSxTUsW_.js";
|
|
2
2
|
import cmath from "@grida/cmath";
|
|
3
3
|
//#region src/core/snap/options.d.ts
|
|
4
4
|
type SnapOptions = {
|
|
@@ -11,6 +11,35 @@ type SnapOptions = {
|
|
|
11
11
|
declare const DEFAULT_SNAP_OPTIONS: SnapOptions;
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region src/dom.d.ts
|
|
14
|
+
/**
|
|
15
|
+
* Wire a web-font settle source to the editor's geometry channel.
|
|
16
|
+
*
|
|
17
|
+
* The DOM surface re-serializes the `<svg>` on every editor tick, but a
|
|
18
|
+
* `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
|
|
19
|
+
* finishing load AFTER its `font-family` / `font-size` was already written.
|
|
20
|
+
* The IR never sees that reflow, so nothing bumps `geometry_version` and
|
|
21
|
+
* every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
|
|
22
|
+
* the fallback-face metrics until the next real edit.
|
|
23
|
+
*
|
|
24
|
+
* Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
|
|
25
|
+
* `EventTarget` in tests) and calls `bump` once per settle. COARSE on
|
|
26
|
+
* purpose: one bump clears the WHOLE bounds cache, not just text nodes —
|
|
27
|
+
* consistent with the package's pessimistic-invalidation stance, and far
|
|
28
|
+
* cheaper than scoping the bump to the (possibly many) reflowed runs.
|
|
29
|
+
*
|
|
30
|
+
* Also bumps once when `source.ready` resolves (when present): fonts that
|
|
31
|
+
* settled before attach — a cache hit, or `font-display` resolving the same
|
|
32
|
+
* tick the surface mounts — never re-fire `loadingdone`, so a document
|
|
33
|
+
* mounted post-settle still needs one bump to re-read at the real metrics.
|
|
34
|
+
*
|
|
35
|
+
* Returns a teardown that removes the listener and neutralizes the pending
|
|
36
|
+
* `ready` bump (leak guard) — call it on surface detach.
|
|
37
|
+
*
|
|
38
|
+
* Factored out of the surface so it can be unit-tested with a fake
|
|
39
|
+
* `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
|
|
40
|
+
* incomplete); never a real font / network.
|
|
41
|
+
*/
|
|
42
|
+
declare function install_font_load_geometry_bump(source: EventTarget | null, bump: () => void): () => void;
|
|
14
43
|
type DomSurfaceOptions = {
|
|
15
44
|
/** Mount the SVG inside this container. */container: HTMLElement;
|
|
16
45
|
/**
|
|
@@ -19,6 +48,18 @@ type DomSurfaceOptions = {
|
|
|
19
48
|
* carte via `handle.gestures.bind(...)`.
|
|
20
49
|
*/
|
|
21
50
|
gestures?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Wire native ClipboardEvent transport — `copy` / `cut` / `paste`
|
|
53
|
+
* listeners on the owner document, gated by the clipboard attention
|
|
54
|
+
* discipline. Default `true`. Pass `false` to route ALL clipboard
|
|
55
|
+
* traffic through the `ClipboardProvider` seam instead (the
|
|
56
|
+
* configuration under which a host's paste-time screening governs
|
|
57
|
+
* every path) — see docs/wg/feat-svg-editor/clipboard.md §Transport
|
|
58
|
+
* "Host control over the native path". Focus management (the container
|
|
59
|
+
* focusing on pointerdown) stays either way — it is a general canvas
|
|
60
|
+
* mitigation, not a clipboard feature.
|
|
61
|
+
*/
|
|
62
|
+
clipboard?: boolean;
|
|
22
63
|
/**
|
|
23
64
|
* Auto-fit the document into the viewport on initial attach. Default
|
|
24
65
|
* `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
|
|
@@ -31,6 +72,19 @@ type DomSurfaceOptions = {
|
|
|
31
72
|
* when `fit: true`.
|
|
32
73
|
*/
|
|
33
74
|
initial_camera?: cmath.Transform;
|
|
75
|
+
/**
|
|
76
|
+
* Font-load settle source — the `EventTarget` whose `loadingdone` event
|
|
77
|
+
* signals "web fonts finished loading, text may have reflowed." Defaults
|
|
78
|
+
* to `container.ownerDocument.fonts` (the live `FontFaceSet`). The
|
|
79
|
+
* surface installs a `loadingdone` listener that advances the editor's
|
|
80
|
+
* geometry channel so text bounds re-read at the settled glyph metrics
|
|
81
|
+
* (see ../docs/geometry.md §Limitations "Text bbox depends on font").
|
|
82
|
+
*
|
|
83
|
+
* Injectable as a DOM seam: jsdom's `FontFaceSet` is incomplete, so
|
|
84
|
+
* tests pass a plain `EventTarget` stub and `dispatchEvent(new Event(
|
|
85
|
+
* "loadingdone"))` to simulate a settle without a real font / network.
|
|
86
|
+
*/
|
|
87
|
+
font_load_source?: EventTarget;
|
|
34
88
|
};
|
|
35
89
|
/**
|
|
36
90
|
* Surface handle for the DOM surface. Extends the editor's core
|
|
@@ -109,4 +163,4 @@ declare function inverse_project_rect(rect: {
|
|
|
109
163
|
f: number;
|
|
110
164
|
}, offset: readonly [number, number]): cmath.Rectangle | null;
|
|
111
165
|
//#endregion
|
|
112
|
-
export {
|
|
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 };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { S as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, h as transform, i as TOOL_CURSOR, l as RotateOrchestrator, m as group, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel, x as array_shallow_equal } from "./model-DMaN5GnH.mjs";
|
|
2
2
|
import cmath from "@grida/cmath";
|
|
3
3
|
import { svg_parse } from "@grida/svg/parse";
|
|
4
4
|
import { SVGShapes } from "@grida/svg/pathdata";
|
|
@@ -1005,15 +1005,18 @@ function create_attention_tracker(container) {
|
|
|
1005
1005
|
};
|
|
1006
1006
|
container.addEventListener("pointerenter", on_enter);
|
|
1007
1007
|
container.addEventListener("pointerleave", on_leave);
|
|
1008
|
-
const
|
|
1008
|
+
const is_focus_within = () => {
|
|
1009
1009
|
const owner = container.ownerDocument;
|
|
1010
|
-
if (!owner) return
|
|
1010
|
+
if (!owner) return false;
|
|
1011
1011
|
const active = owner.activeElement;
|
|
1012
|
-
|
|
1013
|
-
|
|
1012
|
+
return !!active && active !== owner.body && container.contains(active);
|
|
1013
|
+
};
|
|
1014
|
+
const is_attended = () => {
|
|
1015
|
+
return is_focus_within() || pointer_over;
|
|
1014
1016
|
};
|
|
1015
1017
|
return {
|
|
1016
1018
|
is_attended,
|
|
1019
|
+
is_focus_within,
|
|
1017
1020
|
dispose: () => {
|
|
1018
1021
|
container.removeEventListener("pointerenter", on_enter);
|
|
1019
1022
|
container.removeEventListener("pointerleave", on_leave);
|
|
@@ -1638,6 +1641,48 @@ const IS_MODIFIER_KEY = {
|
|
|
1638
1641
|
* live `<text>` element out from under the about-to-mount text surface. */
|
|
1639
1642
|
const TEXT_EDIT_PENDING = { __pending: true };
|
|
1640
1643
|
/**
|
|
1644
|
+
* Wire a web-font settle source to the editor's geometry channel.
|
|
1645
|
+
*
|
|
1646
|
+
* The DOM surface re-serializes the `<svg>` on every editor tick, but a
|
|
1647
|
+
* `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
|
|
1648
|
+
* finishing load AFTER its `font-family` / `font-size` was already written.
|
|
1649
|
+
* The IR never sees that reflow, so nothing bumps `geometry_version` and
|
|
1650
|
+
* every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
|
|
1651
|
+
* the fallback-face metrics until the next real edit.
|
|
1652
|
+
*
|
|
1653
|
+
* Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
|
|
1654
|
+
* `EventTarget` in tests) and calls `bump` once per settle. COARSE on
|
|
1655
|
+
* purpose: one bump clears the WHOLE bounds cache, not just text nodes —
|
|
1656
|
+
* consistent with the package's pessimistic-invalidation stance, and far
|
|
1657
|
+
* cheaper than scoping the bump to the (possibly many) reflowed runs.
|
|
1658
|
+
*
|
|
1659
|
+
* Also bumps once when `source.ready` resolves (when present): fonts that
|
|
1660
|
+
* settled before attach — a cache hit, or `font-display` resolving the same
|
|
1661
|
+
* tick the surface mounts — never re-fire `loadingdone`, so a document
|
|
1662
|
+
* mounted post-settle still needs one bump to re-read at the real metrics.
|
|
1663
|
+
*
|
|
1664
|
+
* Returns a teardown that removes the listener and neutralizes the pending
|
|
1665
|
+
* `ready` bump (leak guard) — call it on surface detach.
|
|
1666
|
+
*
|
|
1667
|
+
* Factored out of the surface so it can be unit-tested with a fake
|
|
1668
|
+
* `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
|
|
1669
|
+
* incomplete); never a real font / network.
|
|
1670
|
+
*/
|
|
1671
|
+
function install_font_load_geometry_bump(source, bump) {
|
|
1672
|
+
if (!source) return () => {};
|
|
1673
|
+
const on_fonts_settled = () => bump();
|
|
1674
|
+
source.addEventListener("loadingdone", on_fonts_settled);
|
|
1675
|
+
let alive = true;
|
|
1676
|
+
const ready = source.ready;
|
|
1677
|
+
if (ready && typeof ready.then === "function") ready.then(() => {
|
|
1678
|
+
if (alive) bump();
|
|
1679
|
+
});
|
|
1680
|
+
return () => {
|
|
1681
|
+
alive = false;
|
|
1682
|
+
source.removeEventListener("loadingdone", on_fonts_settled);
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1641
1686
|
* Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
|
|
1642
1687
|
* whose `detach()` is the inverse — DOM cleared, listeners removed,
|
|
1643
1688
|
* gestures uninstalled.
|
|
@@ -1663,6 +1708,7 @@ var DomSurface = class DomSurface {
|
|
|
1663
1708
|
this.svg_root = null;
|
|
1664
1709
|
this.teardown = [];
|
|
1665
1710
|
this.element_index = /* @__PURE__ */ new Map();
|
|
1711
|
+
this.rendered_doc_revision = -1;
|
|
1666
1712
|
this.last_pointer = {
|
|
1667
1713
|
x: 0,
|
|
1668
1714
|
y: 0
|
|
@@ -1687,6 +1733,7 @@ var DomSurface = class DomSurface {
|
|
|
1687
1733
|
this.container = options.container;
|
|
1688
1734
|
const container = this.container;
|
|
1689
1735
|
this.fit_on_attach = options.fit === true;
|
|
1736
|
+
this.clipboard_enabled = options.clipboard !== false;
|
|
1690
1737
|
this.attention = create_attention_tracker(container);
|
|
1691
1738
|
this.teardown.push(() => this.attention.dispose());
|
|
1692
1739
|
if (process.env.NODE_ENV !== "production" && container.children.length > 0) console.warn("@grida/svg-editor: surface container is not empty at attach time. Render chrome (toolbars, layer lists, inspectors) as siblings of the container, not children — otherwise clicks on those children will silently break. See README §Surface.");
|
|
@@ -1694,6 +1741,8 @@ var DomSurface = class DomSurface {
|
|
|
1694
1741
|
container.style.overflow = "hidden";
|
|
1695
1742
|
container.style.userSelect = "none";
|
|
1696
1743
|
container.style.webkitUserSelect = "none";
|
|
1744
|
+
container.tabIndex = -1;
|
|
1745
|
+
container.style.outline = "none";
|
|
1697
1746
|
const translate_options = () => {
|
|
1698
1747
|
const style = this.editor.style;
|
|
1699
1748
|
const zoom = this.camera.zoom || 1;
|
|
@@ -1709,7 +1758,9 @@ var DomSurface = class DomSurface {
|
|
|
1709
1758
|
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1710
1759
|
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1711
1760
|
options: translate_options,
|
|
1712
|
-
project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d
|
|
1761
|
+
project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d,
|
|
1762
|
+
set_selection: (ids) => this.editor.commands.select(ids),
|
|
1763
|
+
on_clone_commit: (record) => this.editor_internal().seed_duplication(record)
|
|
1713
1764
|
});
|
|
1714
1765
|
const resize_options = () => {
|
|
1715
1766
|
const style = this.editor.style;
|
|
@@ -1777,6 +1828,7 @@ var DomSurface = class DomSurface {
|
|
|
1777
1828
|
shapeOf: (id) => this.shape_of(id),
|
|
1778
1829
|
vectorOf: (id) => this.vector_of(id),
|
|
1779
1830
|
onIntent: (i) => this.commit_intent(i),
|
|
1831
|
+
onTap: (t) => this.handle_tap(t),
|
|
1780
1832
|
style: {
|
|
1781
1833
|
chromeColor: editor.style.chrome_color,
|
|
1782
1834
|
showRotationHandles: true
|
|
@@ -1836,7 +1888,7 @@ var DomSurface = class DomSurface {
|
|
|
1836
1888
|
this.current_tool = editor.state.tool;
|
|
1837
1889
|
this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
|
|
1838
1890
|
this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
|
|
1839
|
-
this.
|
|
1891
|
+
this.flush_dom();
|
|
1840
1892
|
this.sync_surface_selection();
|
|
1841
1893
|
this.hud.setPixelGrid({
|
|
1842
1894
|
enabled: editor.style.pixel_grid,
|
|
@@ -1883,6 +1935,8 @@ var DomSurface = class DomSurface {
|
|
|
1883
1935
|
win.addEventListener("resize", fn);
|
|
1884
1936
|
this.teardown.push(() => win.removeEventListener("resize", fn));
|
|
1885
1937
|
}
|
|
1938
|
+
const detach_font_listener = install_font_load_geometry_bump(options.font_load_source ?? container.ownerDocument.fonts ?? null, () => editor._internal.bump_geometry());
|
|
1939
|
+
this.teardown.push(detach_font_listener);
|
|
1886
1940
|
this.wire_events();
|
|
1887
1941
|
const internal = editor._internal;
|
|
1888
1942
|
this.editor_hover_internal = internal;
|
|
@@ -1890,12 +1944,14 @@ var DomSurface = class DomSurface {
|
|
|
1890
1944
|
this.teardown.push(() => internal.set_content_edit_driver(null));
|
|
1891
1945
|
internal.set_computed_resolver({
|
|
1892
1946
|
computed_property: (id, name) => {
|
|
1947
|
+
this.flush_dom();
|
|
1893
1948
|
const el = this.element_index.get(id);
|
|
1894
1949
|
if (!el) return null;
|
|
1895
1950
|
const value = getComputedStyle(el).getPropertyValue(name);
|
|
1896
1951
|
return value === "" ? null : value;
|
|
1897
1952
|
},
|
|
1898
1953
|
computed_paint: (id, channel) => {
|
|
1954
|
+
this.flush_dom();
|
|
1899
1955
|
const el = this.element_index.get(id);
|
|
1900
1956
|
if (!el) return null;
|
|
1901
1957
|
const computed = getComputedStyle(el).getPropertyValue(channel);
|
|
@@ -1912,7 +1968,8 @@ var DomSurface = class DomSurface {
|
|
|
1912
1968
|
root: () => this.svg_root,
|
|
1913
1969
|
camera: () => this.camera,
|
|
1914
1970
|
container: () => this.container,
|
|
1915
|
-
pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root)
|
|
1971
|
+
pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root),
|
|
1972
|
+
flush: () => this.flush_dom()
|
|
1916
1973
|
}), {
|
|
1917
1974
|
subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
|
|
1918
1975
|
subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
|
|
@@ -2094,6 +2151,25 @@ var DomSurface = class DomSurface {
|
|
|
2094
2151
|
detach_gestures() {
|
|
2095
2152
|
this.gestures._dispose();
|
|
2096
2153
|
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Bring the live DOM up to date with the doc IR iff it is stale.
|
|
2156
|
+
*
|
|
2157
|
+
* Staleness contract: anything that reads the LIVE DOM as a proxy for
|
|
2158
|
+
* document state — the geometry driver (`getBBox` / `getCTM`), the
|
|
2159
|
+
* computed-style resolver — MUST call this first. Doc listeners (the
|
|
2160
|
+
* geometry channel, editor `subscribe`) fire synchronously inside the
|
|
2161
|
+
* mutation, BEFORE the surface's render listener has projected the new
|
|
2162
|
+
* attrs into the DOM; a read in that window returns the PREVIOUS
|
|
2163
|
+
* geometry, and through `MemoizedGeometryProvider` it would be cached as
|
|
2164
|
+
* if current — every later consumer (align, resize_to, snap) then plans
|
|
2165
|
+
* against one-mutation-stale bounds. Same model as CSS layout: reading
|
|
2166
|
+
* `offsetWidth` flushes pending layout; reading `bounds_of` flushes the
|
|
2167
|
+
* pending render.
|
|
2168
|
+
*/
|
|
2169
|
+
flush_dom() {
|
|
2170
|
+
if (this.rendered_doc_revision === this.editor._internal.doc.revision) return;
|
|
2171
|
+
this.render();
|
|
2172
|
+
}
|
|
2097
2173
|
render() {
|
|
2098
2174
|
if (this.text_edit) return;
|
|
2099
2175
|
const owner_doc = this.container.ownerDocument;
|
|
@@ -2120,6 +2196,7 @@ var DomSurface = class DomSurface {
|
|
|
2120
2196
|
for (let c = el.firstElementChild; c; c = c.nextElementSibling) if (c instanceof SVGElement) tag_walk(c);
|
|
2121
2197
|
};
|
|
2122
2198
|
tag_walk(new_svg);
|
|
2199
|
+
this.rendered_doc_revision = doc.revision;
|
|
2123
2200
|
}
|
|
2124
2201
|
sync_canvas_size() {
|
|
2125
2202
|
const cr = this.container.getBoundingClientRect();
|
|
@@ -2714,6 +2791,60 @@ var DomSurface = class DomSurface {
|
|
|
2714
2791
|
});
|
|
2715
2792
|
on(win, "blur", () => this.sync_modifiers(null));
|
|
2716
2793
|
on(this.container, "contextmenu", (e) => e.preventDefault());
|
|
2794
|
+
if (this.clipboard_enabled) {
|
|
2795
|
+
on(owner_doc, "copy", (e) => this.on_copy_or_cut(e, "copy"));
|
|
2796
|
+
on(owner_doc, "cut", (e) => this.on_copy_or_cut(e, "cut"));
|
|
2797
|
+
on(owner_doc, "paste", (e) => this.on_paste(e));
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
/**
|
|
2801
|
+
* Gate for claiming a native clipboard gesture. Deliberately STRICTER
|
|
2802
|
+
* than the keyboard attention gate: focus-based only — pointer-over is
|
|
2803
|
+
* a sufficient signal for a keystroke (worst case: a stolen scroll) but
|
|
2804
|
+
* not for clipboard (worst case: destroying what the user believed they
|
|
2805
|
+
* copied, or routing a paste meant for a host text field into the
|
|
2806
|
+
* document). A user with text selected in a sibling panel and the
|
|
2807
|
+
* pointer idly over the canvas must get their text copy.
|
|
2808
|
+
*/
|
|
2809
|
+
claims_clipboard(kind) {
|
|
2810
|
+
if (this.text_edit) return false;
|
|
2811
|
+
if (this.editor.state.mode !== "select") return false;
|
|
2812
|
+
if (!this.attention.is_focus_within()) return false;
|
|
2813
|
+
if (is_text_input_focused()) return false;
|
|
2814
|
+
if (kind !== "paste") {
|
|
2815
|
+
const sel = this.container.ownerDocument.getSelection();
|
|
2816
|
+
if (sel && !sel.isCollapsed) return false;
|
|
2817
|
+
}
|
|
2818
|
+
return true;
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* Act-then-claim: an empty selection returns without `preventDefault()`,
|
|
2822
|
+
* leaving the browser default (and the OS clipboard) untouched. The
|
|
2823
|
+
* buffer-only `_internal.clipboard` variants are used here — the event's
|
|
2824
|
+
* DataTransfer is this gesture's ONE external channel (the public
|
|
2825
|
+
* commands would additionally write the provider; one gesture, one
|
|
2826
|
+
* external write — FRD §Transport).
|
|
2827
|
+
*/
|
|
2828
|
+
on_copy_or_cut(e, kind) {
|
|
2829
|
+
if (!this.claims_clipboard(kind)) return;
|
|
2830
|
+
if (!e.clipboardData) return;
|
|
2831
|
+
const internal = this.editor_internal();
|
|
2832
|
+
const payload = kind === "copy" ? internal.clipboard.copy() : internal.clipboard.cut();
|
|
2833
|
+
if (payload === null) return;
|
|
2834
|
+
e.clipboardData.setData("text/plain", payload);
|
|
2835
|
+
e.preventDefault();
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Claim-then-act (mirrors the keydown claim doctrine: swallow when the
|
|
2839
|
+
* gesture is aimed at the editor, not just when a handler consumed):
|
|
2840
|
+
* a refused paste — junk text — still claims; the suppressed default is
|
|
2841
|
+
* a no-op on a div anyway.
|
|
2842
|
+
*/
|
|
2843
|
+
on_paste(e) {
|
|
2844
|
+
if (!this.claims_clipboard("paste")) return;
|
|
2845
|
+
e.preventDefault();
|
|
2846
|
+
const text = e.clipboardData?.getData("text/plain");
|
|
2847
|
+
if (text) this.editor.commands.paste(text);
|
|
2717
2848
|
}
|
|
2718
2849
|
/**
|
|
2719
2850
|
* Master signal for modifier-driven UX consumers (measurement, future
|
|
@@ -2735,7 +2866,7 @@ var DomSurface = class DomSurface {
|
|
|
2735
2866
|
kind: "modifiers",
|
|
2736
2867
|
mods: next
|
|
2737
2868
|
});
|
|
2738
|
-
if (prev.shift !== next.shift && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
|
|
2869
|
+
if ((prev.shift !== next.shift || prev.alt !== next.alt) && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
|
|
2739
2870
|
if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
|
|
2740
2871
|
if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
|
|
2741
2872
|
this.redraw();
|
|
@@ -2756,6 +2887,7 @@ var DomSurface = class DomSurface {
|
|
|
2756
2887
|
} else if (kind === "pointer_up") this.text_edit.pointerUp();
|
|
2757
2888
|
return;
|
|
2758
2889
|
}
|
|
2890
|
+
if (kind === "pointer_down") this.container.focus({ preventScroll: true });
|
|
2759
2891
|
const cr = this.container.getBoundingClientRect();
|
|
2760
2892
|
const x = e.clientX - cr.left;
|
|
2761
2893
|
const y = e.clientY - cr.top;
|
|
@@ -2966,6 +3098,26 @@ var DomSurface = class DomSurface {
|
|
|
2966
3098
|
if (this.editor.keymap.claims(e)) e.preventDefault();
|
|
2967
3099
|
this.editor.keymap.dispatch(e);
|
|
2968
3100
|
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Re-express a HUD tap as an editor {@link PickEvent} and fan it out on the
|
|
3103
|
+
* editor's pick channel. The HUD already resolved everything that matters —
|
|
3104
|
+
* the pointer-down point, the hit node, and click-vs-drag — so this is a
|
|
3105
|
+
* pure translation (HUD `[x, y]` tuple → editor `{ x, y }` doc-space point)
|
|
3106
|
+
* with NO re-hit-testing. Taking the hit from the HUD (not a fresh
|
|
3107
|
+
* `node_at_point`) guarantees the pick and the selection it accompanies can
|
|
3108
|
+
* never disagree. Observe-only: this mutates no editor state.
|
|
3109
|
+
*/
|
|
3110
|
+
handle_tap(tap) {
|
|
3111
|
+
this.editor._internal.push_pick({
|
|
3112
|
+
point: {
|
|
3113
|
+
x: tap.point[0],
|
|
3114
|
+
y: tap.point[1]
|
|
3115
|
+
},
|
|
3116
|
+
node_id: tap.hit,
|
|
3117
|
+
button: tap.button,
|
|
3118
|
+
mods: tap.mods
|
|
3119
|
+
});
|
|
3120
|
+
}
|
|
2969
3121
|
commit_intent(intent) {
|
|
2970
3122
|
switch (intent.kind) {
|
|
2971
3123
|
case "select":
|
|
@@ -3052,13 +3204,15 @@ var DomSurface = class DomSurface {
|
|
|
3052
3204
|
});
|
|
3053
3205
|
if (intent.phase === "commit") this.request_redraw();
|
|
3054
3206
|
}
|
|
3055
|
-
/** Snapshot of HUD modifier state mapped to
|
|
3207
|
+
/** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
|
|
3056
3208
|
* Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
|
|
3057
3209
|
* read live so mid-drag Shift press/release reflects on the next pass. */
|
|
3058
3210
|
current_translate_modifiers() {
|
|
3211
|
+
const mods = this.hud.modifiers();
|
|
3059
3212
|
return {
|
|
3060
|
-
axis_lock:
|
|
3061
|
-
force_disable_snap: false
|
|
3213
|
+
axis_lock: mods.shift ? "by_dominance" : "off",
|
|
3214
|
+
force_disable_snap: false,
|
|
3215
|
+
clone: mods.alt
|
|
3062
3216
|
};
|
|
3063
3217
|
}
|
|
3064
3218
|
/** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
|
|
@@ -4397,6 +4551,7 @@ var SvgGeometryDriver = class {
|
|
|
4397
4551
|
this.accessors = accessors;
|
|
4398
4552
|
}
|
|
4399
4553
|
bounds_of(id) {
|
|
4554
|
+
this.accessors.flush();
|
|
4400
4555
|
const el = this.accessors.element_for(id);
|
|
4401
4556
|
if (!el) return null;
|
|
4402
4557
|
if (el instanceof SVGSVGElement) return svg_viewport_bounds(el);
|
|
@@ -4448,6 +4603,7 @@ var SvgGeometryDriver = class {
|
|
|
4448
4603
|
return out;
|
|
4449
4604
|
}
|
|
4450
4605
|
nodes_in_rect(rect) {
|
|
4606
|
+
this.accessors.flush();
|
|
4451
4607
|
const root = this.accessors.root();
|
|
4452
4608
|
if (!root) return [];
|
|
4453
4609
|
const hits = [];
|
|
@@ -4460,6 +4616,7 @@ var SvgGeometryDriver = class {
|
|
|
4460
4616
|
return hits;
|
|
4461
4617
|
}
|
|
4462
4618
|
node_at_point(p) {
|
|
4619
|
+
this.accessors.flush();
|
|
4463
4620
|
return this.accessors.pick_at_world(p, true);
|
|
4464
4621
|
}
|
|
4465
4622
|
/** World→local delta projection. The frame an element's position is
|
|
@@ -4475,6 +4632,7 @@ var SvgGeometryDriver = class {
|
|
|
4475
4632
|
* the local delta. Identity (→ delta unchanged) for flat frames,
|
|
4476
4633
|
* top-level nodes, and any degenerate / unavailable matrix. */
|
|
4477
4634
|
world_delta_to_local(id, delta) {
|
|
4635
|
+
this.accessors.flush();
|
|
4478
4636
|
const parent = this.accessors.element_for(id)?.parentNode;
|
|
4479
4637
|
const root = this.accessors.root();
|
|
4480
4638
|
if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
|
|
@@ -4515,4 +4673,4 @@ var SvgHitShapeDriver = class {
|
|
|
4515
4673
|
}
|
|
4516
4674
|
};
|
|
4517
4675
|
//#endregion
|
|
4518
|
-
export {
|
|
4676
|
+
export { project_point_through_ctm as a, MemoizedGeometryProvider as c, project_delta_inverse_ctm as i, Camera as l, install_font_load_geometry_bump as n, Gestures as o, inverse_project_rect as r, DEFAULT_SNAP_OPTIONS as s, attach_dom_surface as t };
|