@grida/svg-editor 1.0.0-alpha.20 → 1.0.0-alpha.21
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 +1 -1
- package/dist/{dom-CfYDV311.js → dom-CuK0LFUY.js} +342 -84
- package/dist/{dom-Dub-TMoN.mjs → dom-DHaTIObb.mjs} +342 -84
- package/dist/dom.js +1 -1
- package/dist/dom.mjs +1 -1
- package/dist/{editor-4U9LAZ6r.js → editor-BlByfVyF.js} +5 -4
- package/dist/{editor-B1GmFnS9.mjs → editor-CJ3ROm0G.mjs} +5 -4
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{model-DS5MxDrd.mjs → model-C6jCFK_p.mjs} +3 -4
- package/dist/{model-CzL6_zId.js → model-DVwjrVYp.js} +3 -4
- package/dist/presets.js +2 -2
- package/dist/presets.mjs +1 -1
- package/dist/react.js +2 -2
- package/dist/react.mjs +2 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -962,7 +962,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
|
|
|
962
962
|
- **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
|
|
963
963
|
- **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
|
|
964
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/`,
|
|
965
|
+
- **Not a customizable selection policy.** What a pick or a marquee selects (the marquee shadow rule, meta routing, additive, and the group-first targeting that lifts a click to its container) is a fixed product decision, owned by the labeled policy layer (`src/selection/`, specs [`docs/marquee-selection.md`](./docs/marquee-selection.md) and [`docs/group-first-targeting.md`](./docs/group-first-targeting.md)). No host hook, provider, or registry swaps it; the opinion lives above the engine, never inside it, and never as a public knob.
|
|
966
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.)
|
|
967
967
|
- **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
|
|
968
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,4 +1,4 @@
|
|
|
1
|
-
const require_model = require("./model-
|
|
1
|
+
const require_model = require("./model-DVwjrVYp.js");
|
|
2
2
|
let _grida_cmath = require("@grida/cmath");
|
|
3
3
|
_grida_cmath = require_model.__toESM(_grida_cmath);
|
|
4
4
|
let _grida_svg_parse = require("@grida/svg/parse");
|
|
@@ -788,6 +788,139 @@ let marquee_selection;
|
|
|
788
788
|
_marquee_selection.resolve = resolve;
|
|
789
789
|
})(marquee_selection || (marquee_selection = {}));
|
|
790
790
|
//#endregion
|
|
791
|
+
//#region src/selection/targeting.ts
|
|
792
|
+
let targeting;
|
|
793
|
+
(function(_targeting) {
|
|
794
|
+
/** Siblings are treated as closer than parent/child (distance 1) so a tap
|
|
795
|
+
* into a sibling subtree resolves to the sibling at the focus depth without
|
|
796
|
+
* a tie-breaker. Mirrors Grida Canvas `weights: { sibling: 0.9 }`. */
|
|
797
|
+
const SIBLING_WEIGHT = .9;
|
|
798
|
+
function resolve_target(hits, tree, opts) {
|
|
799
|
+
if (hits.length === 0) return null;
|
|
800
|
+
const depth_cache = /* @__PURE__ */ new Map();
|
|
801
|
+
const depth = (id) => {
|
|
802
|
+
const cached = depth_cache.get(id);
|
|
803
|
+
if (cached !== void 0) return cached;
|
|
804
|
+
let d = 0;
|
|
805
|
+
const seen = new Set([id]);
|
|
806
|
+
let cur = tree.parent_of(id);
|
|
807
|
+
while (cur !== null && !seen.has(cur)) {
|
|
808
|
+
d++;
|
|
809
|
+
seen.add(cur);
|
|
810
|
+
cur = tree.parent_of(cur);
|
|
811
|
+
}
|
|
812
|
+
depth_cache.set(id, d);
|
|
813
|
+
return d;
|
|
814
|
+
};
|
|
815
|
+
const ancestors_inclusive = (id) => {
|
|
816
|
+
const set = /* @__PURE__ */ new Set();
|
|
817
|
+
let cur = id;
|
|
818
|
+
while (cur !== null && !set.has(cur)) {
|
|
819
|
+
set.add(cur);
|
|
820
|
+
cur = tree.parent_of(cur);
|
|
821
|
+
}
|
|
822
|
+
return set;
|
|
823
|
+
};
|
|
824
|
+
/** Lowest common ancestor, or `null` if the two ids are not connected
|
|
825
|
+
* (e.g. a stale selection id from a different/old tree). */
|
|
826
|
+
const lca = (a, b) => {
|
|
827
|
+
const a_anc = ancestors_inclusive(a);
|
|
828
|
+
const seen = /* @__PURE__ */ new Set();
|
|
829
|
+
let cur = b;
|
|
830
|
+
while (cur !== null && !seen.has(cur)) {
|
|
831
|
+
if (a_anc.has(cur)) return cur;
|
|
832
|
+
seen.add(cur);
|
|
833
|
+
cur = tree.parent_of(cur);
|
|
834
|
+
}
|
|
835
|
+
return null;
|
|
836
|
+
};
|
|
837
|
+
/** Is `a` a (strict) descendant of `b`? */
|
|
838
|
+
const is_descendant_of = (a, b) => {
|
|
839
|
+
const seen = /* @__PURE__ */ new Set();
|
|
840
|
+
let cur = tree.parent_of(a);
|
|
841
|
+
while (cur !== null && !seen.has(cur)) {
|
|
842
|
+
if (cur === b) return true;
|
|
843
|
+
seen.add(cur);
|
|
844
|
+
cur = tree.parent_of(cur);
|
|
845
|
+
}
|
|
846
|
+
return false;
|
|
847
|
+
};
|
|
848
|
+
const graph_distance = (a, b) => {
|
|
849
|
+
if (a === b) return 0;
|
|
850
|
+
const m = lca(a, b);
|
|
851
|
+
if (m === null) return Infinity;
|
|
852
|
+
return depth(a) + depth(b) - 2 * depth(m);
|
|
853
|
+
};
|
|
854
|
+
const weighted_distance = (a, b) => {
|
|
855
|
+
if (a === b) return 0;
|
|
856
|
+
const pa = tree.parent_of(a);
|
|
857
|
+
if (pa !== null && pa === tree.parent_of(b)) return SIBLING_WEIGHT;
|
|
858
|
+
return graph_distance(a, b);
|
|
859
|
+
};
|
|
860
|
+
if (opts.deepest) {
|
|
861
|
+
let best = null;
|
|
862
|
+
let best_depth = -Infinity;
|
|
863
|
+
for (const id of hits) {
|
|
864
|
+
const d = depth(id);
|
|
865
|
+
if (d > best_depth) {
|
|
866
|
+
best_depth = d;
|
|
867
|
+
best = id;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return best;
|
|
871
|
+
}
|
|
872
|
+
const by_depth = [...hits].sort((a, b) => {
|
|
873
|
+
const dd = depth(a) - depth(b);
|
|
874
|
+
return dd !== 0 ? dd : hits.indexOf(a) - hits.indexOf(b);
|
|
875
|
+
});
|
|
876
|
+
const leaf = hits[0];
|
|
877
|
+
const selection = opts.selection.filter((s) => lca(leaf, s) !== null);
|
|
878
|
+
const find_nearest = (candidates, sel, use_sibling_weight, prefer_children) => {
|
|
879
|
+
if (candidates.length === 0 || sel.length === 0) return null;
|
|
880
|
+
const dist = use_sibling_weight ? weighted_distance : graph_distance;
|
|
881
|
+
const scored = candidates.map((c) => {
|
|
882
|
+
let min = Infinity;
|
|
883
|
+
for (const s of sel) {
|
|
884
|
+
const d = dist(c, s);
|
|
885
|
+
if (d < min) min = d;
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
id: c,
|
|
889
|
+
distance: min,
|
|
890
|
+
is_child: prefer_children ? sel.some((s) => is_descendant_of(c, s)) : false,
|
|
891
|
+
depth: depth(c)
|
|
892
|
+
};
|
|
893
|
+
});
|
|
894
|
+
scored.sort((a, b) => {
|
|
895
|
+
if (a.distance !== b.distance) return a.distance - b.distance;
|
|
896
|
+
if (prefer_children) {
|
|
897
|
+
if (a.is_child && !b.is_child) return -1;
|
|
898
|
+
if (!a.is_child && b.is_child) return 1;
|
|
899
|
+
}
|
|
900
|
+
return a.depth - b.depth;
|
|
901
|
+
});
|
|
902
|
+
return scored[0]?.id ?? null;
|
|
903
|
+
};
|
|
904
|
+
if (selection.length > 0) {
|
|
905
|
+
if (opts.nested_first) {
|
|
906
|
+
const descendants = by_depth.filter((c) => !selection.includes(c) && selection.some((s) => is_descendant_of(c, s)));
|
|
907
|
+
if (descendants.length > 0) {
|
|
908
|
+
const r = find_nearest(descendants, selection, false, false);
|
|
909
|
+
if (r !== null) return r;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const focus_ancestors = /* @__PURE__ */ new Set();
|
|
913
|
+
for (const s of selection) for (const a of ancestors_inclusive(s)) focus_ancestors.add(a);
|
|
914
|
+
for (const s of selection) focus_ancestors.delete(s);
|
|
915
|
+
const lateral = by_depth.filter((c) => !focus_ancestors.has(c));
|
|
916
|
+
const nearest = find_nearest(lateral.length > 0 ? lateral : by_depth, selection, true, opts.nested_first);
|
|
917
|
+
if (nearest !== null) return nearest;
|
|
918
|
+
}
|
|
919
|
+
return by_depth[0] ?? null;
|
|
920
|
+
}
|
|
921
|
+
_targeting.resolve_target = resolve_target;
|
|
922
|
+
})(targeting || (targeting = {}));
|
|
923
|
+
//#endregion
|
|
791
924
|
//#region src/gestures/gestures.ts
|
|
792
925
|
/**
|
|
793
926
|
* Sibling to `Keymap`. Owns a list of installed gesture bindings; each
|
|
@@ -1783,6 +1916,7 @@ var DomSurface = class DomSurface {
|
|
|
1783
1916
|
this.last_pointer_valid = false;
|
|
1784
1917
|
this.resize_observer = null;
|
|
1785
1918
|
this.redraw_raf_id = null;
|
|
1919
|
+
this.hover_repick_raf_id = null;
|
|
1786
1920
|
this._geometry_provider = null;
|
|
1787
1921
|
this._hit_shapes = null;
|
|
1788
1922
|
this._z_order_cache = [];
|
|
@@ -1999,6 +2133,7 @@ var DomSurface = class DomSurface {
|
|
|
1999
2133
|
});
|
|
2000
2134
|
this.teardown.push(unsub);
|
|
2001
2135
|
this.teardown.push(editor.subscribe_geometry(() => this.request_redraw()));
|
|
2136
|
+
this.teardown.push(editor.subscribe_with_selector((s) => s.selection, () => this.request_hover_repick(), require_model.array_shallow_equal));
|
|
2002
2137
|
if (typeof ResizeObserver !== "undefined") {
|
|
2003
2138
|
this.resize_observer = new ResizeObserver(() => this.sync_canvas_size());
|
|
2004
2139
|
this.resize_observer.observe(container);
|
|
@@ -2089,7 +2224,62 @@ var DomSurface = class DomSurface {
|
|
|
2089
2224
|
}
|
|
2090
2225
|
hit_test(x, y) {
|
|
2091
2226
|
if (this.vector_edit) return null;
|
|
2092
|
-
|
|
2227
|
+
const leaf = this.pick_at(x, y, false);
|
|
2228
|
+
if (leaf === null) return null;
|
|
2229
|
+
return this.resolve_group_first(leaf, { nested_first: false });
|
|
2230
|
+
}
|
|
2231
|
+
/** Group-first targeting over the raw leaf's ancestor chain — the host's
|
|
2232
|
+
* selection POLICY (pure rules in `src/selection/targeting.ts`). The
|
|
2233
|
+
* current selection defines the focus depth: a tap moves laterally /
|
|
2234
|
+
* sibling-aware at that depth, meta/ctrl jumps to the leaf, and
|
|
2235
|
+
* `nested_first` (double-click) descends one level. Returns the raw `leaf`
|
|
2236
|
+
* unchanged if the document is momentarily unavailable. */
|
|
2237
|
+
resolve_group_first(leaf, opts) {
|
|
2238
|
+
const doc = (() => {
|
|
2239
|
+
try {
|
|
2240
|
+
return this.editor_internal().doc;
|
|
2241
|
+
} catch {
|
|
2242
|
+
return null;
|
|
2243
|
+
}
|
|
2244
|
+
})();
|
|
2245
|
+
if (!doc) return leaf;
|
|
2246
|
+
const root = doc.root;
|
|
2247
|
+
const hits = [];
|
|
2248
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2249
|
+
let cur = leaf;
|
|
2250
|
+
while (cur !== null && cur !== root && !seen.has(cur)) {
|
|
2251
|
+
hits.push(cur);
|
|
2252
|
+
seen.add(cur);
|
|
2253
|
+
cur = doc.parent_of(cur);
|
|
2254
|
+
}
|
|
2255
|
+
const mods = this.hud.modifiers();
|
|
2256
|
+
return targeting.resolve_target(hits, { parent_of: (id) => doc.parent_of(id) }, {
|
|
2257
|
+
selection: this.editor.state.selection,
|
|
2258
|
+
deepest: mods.meta || mods.ctrl,
|
|
2259
|
+
nested_first: opts.nested_first
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
/** Double-click behaviour, as two distinct phases over the hierarchy:
|
|
2263
|
+
*
|
|
2264
|
+
* 1. **Descend** — resolve one level deeper toward `leaf` (relative to the
|
|
2265
|
+
* current focus, via `resolve_group_first` with `nested_first`). A
|
|
2266
|
+
* double-click that lands on a node deeper than the selection just
|
|
2267
|
+
* SELECTS it.
|
|
2268
|
+
* 2. **Enter content-edit** — only when there is nowhere deeper to go: the
|
|
2269
|
+
* resolved target is already the sole selection. Then (and only then) a
|
|
2270
|
+
* double-click opens content-edit, when the node is editable.
|
|
2271
|
+
*
|
|
2272
|
+
* Descending and editing never collide: progressive double-clicks peel
|
|
2273
|
+
* `G1 → G2 → … → leaf`, and one more double-click on the now-focused leaf
|
|
2274
|
+
* edits it. A top-level editable node (no container to peel) is selected by
|
|
2275
|
+
* the double-click's own first press, so its second press lands in the
|
|
2276
|
+
* edit phase — it still edits on a single double-click. */
|
|
2277
|
+
descend_or_enter_content_edit(leaf) {
|
|
2278
|
+
const target = this.resolve_group_first(leaf, { nested_first: true });
|
|
2279
|
+
if (target === null) return;
|
|
2280
|
+
const selection = this.editor.state.selection;
|
|
2281
|
+
if (selection.length === 1 && selection[0] === target) this.editor.enter_content_edit(target);
|
|
2282
|
+
else this.editor.commands.select(target);
|
|
2093
2283
|
}
|
|
2094
2284
|
/** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
|
|
2095
2285
|
* `allow_root` is `false`, root hits are rejected (returns `null`) so
|
|
@@ -2129,9 +2319,12 @@ var DomSurface = class DomSurface {
|
|
|
2129
2319
|
* `allow_root` controls whether the root `<svg>` may be returned:
|
|
2130
2320
|
* selection HUD passes `false`, measurement HUD passes `true`.
|
|
2131
2321
|
*
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
2322
|
+
* This is the RAW (leaf-first) picker — one source of truth for "what
|
|
2323
|
+
* element is under this point." Used by `pick_at` (HUD hover / measurement)
|
|
2324
|
+
* and `SvgGeometryDriver.node_at_point` (core geometry queries). Selection
|
|
2325
|
+
* targeting policy (group-first / meta-deepest / focus-depth) is applied on
|
|
2326
|
+
* top of the raw leaf by `hit_test` → `resolve_group_first`, NOT here, so
|
|
2327
|
+
* measurement and geometry queries stay leaf-exact. */
|
|
2135
2328
|
_pick_node_at_world(p, allow_root) {
|
|
2136
2329
|
const root_id = this.editor.tree().root;
|
|
2137
2330
|
const tol_px = this.editor.style.hit_tolerance_px;
|
|
@@ -2215,6 +2408,10 @@ var DomSurface = class DomSurface {
|
|
|
2215
2408
|
(this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.redraw_raf_id);
|
|
2216
2409
|
this.redraw_raf_id = null;
|
|
2217
2410
|
}
|
|
2411
|
+
if (this.hover_repick_raf_id !== null) {
|
|
2412
|
+
(this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.hover_repick_raf_id);
|
|
2413
|
+
this.hover_repick_raf_id = null;
|
|
2414
|
+
}
|
|
2218
2415
|
for (const fn of this.teardown) fn();
|
|
2219
2416
|
this.teardown = [];
|
|
2220
2417
|
this.hud.dispose();
|
|
@@ -2392,6 +2589,53 @@ var DomSurface = class DomSurface {
|
|
|
2392
2589
|
});
|
|
2393
2590
|
}
|
|
2394
2591
|
/**
|
|
2592
|
+
* Re-pick the hover / target id at the RESTING pointer and return what
|
|
2593
|
+
* changed. The hover target is a pure function of (pointer, modifiers,
|
|
2594
|
+
* selection, tree) resolved through `hit_test` → group-first targeting;
|
|
2595
|
+
* every input OTHER than the pointer changes off the pointer path, so the
|
|
2596
|
+
* stored hover goes stale until the next real move. Replaying a synthetic
|
|
2597
|
+
* idle pointer-move re-runs `pick` under the current state, keeping the
|
|
2598
|
+
* highlight a faithful preview of what the next click would select.
|
|
2599
|
+
*
|
|
2600
|
+
* Idle-only: a synthetic move opens / cancels no gesture and leaves any tap
|
|
2601
|
+
* candidate / pending press untouched. No-op (all-false) when the pointer
|
|
2602
|
+
* has never been observed, has left the canvas, or a text / vector edit owns
|
|
2603
|
+
* it. Pushes nothing and redraws nothing — the caller reflects the flags.
|
|
2604
|
+
*/
|
|
2605
|
+
repick_hover_at_rest() {
|
|
2606
|
+
if (!this.last_pointer_valid || this.hud.gesture().kind !== "idle" || this.text_edit || this.vector_edit) return {
|
|
2607
|
+
needsRedraw: false,
|
|
2608
|
+
cursorChanged: false,
|
|
2609
|
+
hoverChanged: false
|
|
2610
|
+
};
|
|
2611
|
+
return this.hud.dispatch({
|
|
2612
|
+
kind: "pointer_move",
|
|
2613
|
+
x: this.last_pointer.x,
|
|
2614
|
+
y: this.last_pointer.y,
|
|
2615
|
+
mods: this.hud.modifiers()
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Schedule an idle hover re-pick on the next frame (RAF-coalesced, like
|
|
2620
|
+
* `request_redraw`). DEFERRED on purpose: the trigger — a selection change —
|
|
2621
|
+
* frequently fires from inside `hud.dispatch` (a double-click drill resolves
|
|
2622
|
+
* its new selection via `onIntent`, mid-dispatch), and re-picking there would
|
|
2623
|
+
* re-enter the HUD. By next frame the dispatch has unwound and the gesture
|
|
2624
|
+
* has settled, so `repick_hover_at_rest` runs against stable state. Multiple
|
|
2625
|
+
* changes within a frame fold into one re-pick.
|
|
2626
|
+
*/
|
|
2627
|
+
request_hover_repick() {
|
|
2628
|
+
if (this.hover_repick_raf_id !== null) return;
|
|
2629
|
+
const win = this.container.ownerDocument.defaultView ?? window;
|
|
2630
|
+
this.hover_repick_raf_id = win.requestAnimationFrame(() => {
|
|
2631
|
+
this.hover_repick_raf_id = null;
|
|
2632
|
+
const moved = this.repick_hover_at_rest();
|
|
2633
|
+
if (moved.needsRedraw || moved.hoverChanged) this.redraw();
|
|
2634
|
+
if (moved.cursorChanged) this.sync_cursor();
|
|
2635
|
+
if (moved.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2395
2639
|
* Build the host-fed measurement guide for the current frame, or
|
|
2396
2640
|
* `undefined` if no guide should be drawn.
|
|
2397
2641
|
*
|
|
@@ -2952,9 +3196,16 @@ var DomSurface = class DomSurface {
|
|
|
2952
3196
|
if ((prev.shift !== next.shift || prev.alt !== next.alt) && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
|
|
2953
3197
|
if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
|
|
2954
3198
|
if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
|
|
3199
|
+
let cursor_changed = response.cursorChanged;
|
|
3200
|
+
let hover_changed = response.hoverChanged;
|
|
3201
|
+
if (prev.meta !== next.meta || prev.ctrl !== next.ctrl) {
|
|
3202
|
+
const moved = this.repick_hover_at_rest();
|
|
3203
|
+
if (moved.cursorChanged) cursor_changed = true;
|
|
3204
|
+
if (moved.hoverChanged) hover_changed = true;
|
|
3205
|
+
}
|
|
2955
3206
|
this.redraw();
|
|
2956
|
-
if (
|
|
2957
|
-
if (
|
|
3207
|
+
if (cursor_changed) this.sync_cursor();
|
|
3208
|
+
if (hover_changed) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
|
|
2958
3209
|
}
|
|
2959
3210
|
dispatch_pointer(e, kind) {
|
|
2960
3211
|
if (this.text_edit) {
|
|
@@ -3236,10 +3487,16 @@ var DomSurface = class DomSurface {
|
|
|
3236
3487
|
case "set_endpoint":
|
|
3237
3488
|
this.handle_set_endpoint(intent);
|
|
3238
3489
|
return;
|
|
3239
|
-
case "enter_content_edit":
|
|
3240
|
-
this.
|
|
3241
|
-
|
|
3490
|
+
case "enter_content_edit": {
|
|
3491
|
+
const leaf = this.last_pointer_valid ? this.pick_at(this.last_pointer.x, this.last_pointer.y, false) : null;
|
|
3492
|
+
if (leaf === null) {
|
|
3493
|
+
this.editor.commands.select(intent.id);
|
|
3494
|
+
this.editor.enter_content_edit(intent.id);
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
this.descend_or_enter_content_edit(leaf);
|
|
3242
3498
|
return;
|
|
3499
|
+
}
|
|
3243
3500
|
case "exit_content_edit":
|
|
3244
3501
|
this.exit_vector_edit();
|
|
3245
3502
|
return;
|
|
@@ -3291,10 +3548,12 @@ var DomSurface = class DomSurface {
|
|
|
3291
3548
|
* Shift axis-lock for point-level drags (gridaco/grida#848) — the
|
|
3292
3549
|
* vertex / endpoint counterpart of whole-object translate's
|
|
3293
3550
|
* `axis_lock: "by_dominance"` (see `current_translate_modifiers`).
|
|
3294
|
-
* Collapses the lesser axis of a
|
|
3295
|
-
*
|
|
3296
|
-
*
|
|
3297
|
-
*
|
|
3551
|
+
* Collapses the lesser axis of a delta when Shift is held, via the same
|
|
3552
|
+
* cmath rule the translate pipeline's `axis_lock` stage uses; identity when
|
|
3553
|
+
* Shift is up. Space-agnostic: the point path applies it to the raw screen
|
|
3554
|
+
* delta (before snap), so dominance reflects the user's drag intent. Pull-
|
|
3555
|
+
* at-consume from the HUD modifier store so a mid-drag Shift press/release
|
|
3556
|
+
* reflects on the next frame.
|
|
3298
3557
|
*/
|
|
3299
3558
|
axis_lock_point_delta(dx, dy) {
|
|
3300
3559
|
if (!this.hud.modifiers().shift) return [dx, dy];
|
|
@@ -3307,19 +3566,21 @@ var DomSurface = class DomSurface {
|
|
|
3307
3566
|
* delta so it aligns with (or lands on) a sibling point: a path's other
|
|
3308
3567
|
* vertices, or a `<line>`'s opposite endpoint.
|
|
3309
3568
|
*
|
|
3310
|
-
* Runs
|
|
3311
|
-
* point[s]) and
|
|
3312
|
-
* the
|
|
3313
|
-
*
|
|
3314
|
-
*
|
|
3315
|
-
*
|
|
3316
|
-
*
|
|
3569
|
+
* Runs in **screen space** (container CSS-px, the HUD's identity frame). The
|
|
3570
|
+
* caller projects the moving point[s] (agents) and the static sibling points
|
|
3571
|
+
* (neighbors) through the element CTM first, and feeds the drag delta in the
|
|
3572
|
+
* same space. Screen space is the right frame because the threshold and the
|
|
3573
|
+
* rendered guide are both screen-pixel concepts: the configured
|
|
3574
|
+
* `snap_threshold_px` stays user-visible pixels on BOTH axes for any element
|
|
3575
|
+
* CTM — even a non-uniform / rotated one, which a single area-scale would
|
|
3576
|
+
* distort (gridaco/grida#844 review) — and the guide needs no reprojection.
|
|
3577
|
+
* The caller converts the returned screen delta back to local for the write.
|
|
3317
3578
|
*
|
|
3318
|
-
* Honors the global snap toggle (`style.snap_enabled`) and threshold
|
|
3319
|
-
* (`style.snap_threshold_px`, converted to local units by the CTM scale) —
|
|
3579
|
+
* Honors the global snap toggle (`style.snap_enabled`) and threshold —
|
|
3320
3580
|
* snap off ⇒ free point dragging, per the issue. Bypassed when
|
|
3321
|
-
* `suppress_point_snap` is set (keyboard nudge)
|
|
3322
|
-
*
|
|
3581
|
+
* `suppress_point_snap` is set (keyboard nudge) or the point hasn't moved
|
|
3582
|
+
* (a pure tap / select must never relocate a control). Identity when there
|
|
3583
|
+
* are no neighbors / agents.
|
|
3323
3584
|
*
|
|
3324
3585
|
* The shared `SnapSession` drops 0-area rects (its empty-`<g>` "jerk to
|
|
3325
3586
|
* origin" defense), so each point is modeled as a sub-pixel square centered
|
|
@@ -3327,14 +3588,12 @@ var DomSurface = class DomSurface {
|
|
|
3327
3588
|
* matched same-edge offset carries the same ±eps on both sides), so eps
|
|
3328
3589
|
* magnitude is immaterial as long as it stays far below the threshold.
|
|
3329
3590
|
*/
|
|
3330
|
-
|
|
3591
|
+
snap_point_delta_screen(raw_dx, raw_dy, agents_screen, neighbors_screen) {
|
|
3331
3592
|
this.point_snap_guide = void 0;
|
|
3332
3593
|
const style = this.editor.style;
|
|
3333
|
-
if (this.suppress_point_snap || !style.snap_enabled ||
|
|
3334
|
-
const
|
|
3335
|
-
const
|
|
3336
|
-
const threshold_local = style.snap_threshold_px / scale;
|
|
3337
|
-
const eps = threshold_local * 1e-6 || 1e-9;
|
|
3594
|
+
if (this.suppress_point_snap || !style.snap_enabled || agents_screen.length === 0 || neighbors_screen.length === 0 || raw_dx === 0 && raw_dy === 0) return [raw_dx, raw_dy];
|
|
3595
|
+
const threshold = style.snap_threshold_px;
|
|
3596
|
+
const eps = threshold * 1e-6 || 1e-9;
|
|
3338
3597
|
const to_rect = (p) => ({
|
|
3339
3598
|
x: p[0] - eps / 2,
|
|
3340
3599
|
y: p[1] - eps / 2,
|
|
@@ -3342,23 +3601,23 @@ var DomSurface = class DomSurface {
|
|
|
3342
3601
|
height: eps
|
|
3343
3602
|
});
|
|
3344
3603
|
const session = new SnapSession({
|
|
3345
|
-
agents:
|
|
3346
|
-
neighbors:
|
|
3604
|
+
agents: agents_screen.map(to_rect),
|
|
3605
|
+
neighbors: neighbors_screen.map(to_rect)
|
|
3347
3606
|
});
|
|
3348
3607
|
const { delta, guide } = session.snap({
|
|
3349
3608
|
x: raw_dx,
|
|
3350
3609
|
y: raw_dy
|
|
3351
3610
|
}, {
|
|
3352
3611
|
enabled: true,
|
|
3353
|
-
threshold_px:
|
|
3612
|
+
threshold_px: threshold
|
|
3354
3613
|
});
|
|
3355
3614
|
session.dispose();
|
|
3356
|
-
|
|
3615
|
+
this.point_snap_guide = guide;
|
|
3357
3616
|
return [delta.x, delta.y];
|
|
3358
3617
|
}
|
|
3359
3618
|
/** Split a path's baseline vertices into the snap agents (the moving
|
|
3360
|
-
* sub-selection) and neighbors (everything else) for
|
|
3361
|
-
*
|
|
3619
|
+
* sub-selection) and neighbors (everything else) for the point-snap pass.
|
|
3620
|
+
* Both in path-local space; the caller projects them to screen. */
|
|
3362
3621
|
vertex_snap_points(model, moving) {
|
|
3363
3622
|
const verts = model.snapshot().vertices;
|
|
3364
3623
|
const moving_set = new Set(moving);
|
|
@@ -3370,31 +3629,6 @@ var DomSurface = class DomSurface {
|
|
|
3370
3629
|
neighbors
|
|
3371
3630
|
};
|
|
3372
3631
|
}
|
|
3373
|
-
/** Project a point-snap guide from an element's local frame to screen
|
|
3374
|
-
* CSS-px (the HUD canvas's identity coordinate system), via the element
|
|
3375
|
-
* CTM + container offset — the same projection `vector_of` uses for
|
|
3376
|
-
* vertex chrome. The local-frame analog of `project_guide_to_screen`
|
|
3377
|
-
* (which projects world-space orchestrator guides via the camera). */
|
|
3378
|
-
project_local_guide_to_screen(g, ctm, container_offset) {
|
|
3379
|
-
return {
|
|
3380
|
-
lines: g.lines.map((l) => {
|
|
3381
|
-
const [x1, y1] = project_point_through_ctm(l.x1, l.y1, ctm, container_offset);
|
|
3382
|
-
const [x2, y2] = project_point_through_ctm(l.x2, l.y2, ctm, container_offset);
|
|
3383
|
-
return {
|
|
3384
|
-
...l,
|
|
3385
|
-
x1,
|
|
3386
|
-
y1,
|
|
3387
|
-
x2,
|
|
3388
|
-
y2
|
|
3389
|
-
};
|
|
3390
|
-
}),
|
|
3391
|
-
points: g.points.map(([x, y]) => project_point_through_ctm(x, y, ctm, container_offset)),
|
|
3392
|
-
rules: g.rules.map(([axis, value]) => {
|
|
3393
|
-
const [px, py] = project_point_through_ctm(axis === "x" ? value : 0, axis === "x" ? 0 : value, ctm, container_offset);
|
|
3394
|
-
return [axis, axis === "x" ? px : py];
|
|
3395
|
-
})
|
|
3396
|
-
};
|
|
3397
|
-
}
|
|
3398
3632
|
/** Translation from screen/page CSS-px to the HUD's container-identity
|
|
3399
3633
|
* space: subtract the container's page offset, add its scroll. Used at
|
|
3400
3634
|
* every `getScreenCTM` projection boundary (chrome, marquee, point snap)
|
|
@@ -3404,24 +3638,45 @@ var DomSurface = class DomSurface {
|
|
|
3404
3638
|
return [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
|
|
3405
3639
|
}
|
|
3406
3640
|
/**
|
|
3641
|
+
* Local-frame drag delta for a point sub-selection (vertices or a `<line>`
|
|
3642
|
+
* endpoint), composed exactly like whole-object translate: **axis-lock then
|
|
3643
|
+
* snap** (translate-pipeline stage order `axis_lock → snap`). The raw delta
|
|
3644
|
+
* and the agent / neighbor points are all in screen space (container
|
|
3645
|
+
* CSS-px):
|
|
3646
|
+
*
|
|
3647
|
+
* 1. Shift axis-lock collapses the lesser axis of the RAW screen delta —
|
|
3648
|
+
* dominance is the user's intent, decided before snap can perturb it
|
|
3649
|
+
* (gridaco/grida#844 review: locking the snapped delta could flip the
|
|
3650
|
+
* axis). The lock is screen-aligned, matching the drag and the guides.
|
|
3651
|
+
* 2. Point snap aligns the locked delta to the sibling points.
|
|
3652
|
+
* 3. The result projects back into the element's local frame via the
|
|
3653
|
+
* inverse CTM for `translateVertices` / the endpoint write.
|
|
3654
|
+
*
|
|
3655
|
+
* `agents_local` / `neighbors_local` are projected to screen here so the
|
|
3656
|
+
* snap threshold stays user-visible pixels under any element CTM.
|
|
3657
|
+
*/
|
|
3658
|
+
point_drag_local_delta(ctm, raw_screen_dx, raw_screen_dy, agents_local, neighbors_local) {
|
|
3659
|
+
let [sx, sy] = this.axis_lock_point_delta(raw_screen_dx, raw_screen_dy);
|
|
3660
|
+
const offset = this.container_offset();
|
|
3661
|
+
const to_screen = (p) => project_point_through_ctm(p[0], p[1], ctm, offset);
|
|
3662
|
+
[sx, sy] = this.snap_point_delta_screen(sx, sy, agents_local.map(to_screen), neighbors_local.map(to_screen));
|
|
3663
|
+
return ctm.a * ctm.d - ctm.c * ctm.b !== 0 ? project_delta_inverse_ctm(sx, sy, ctm) : [sx, sy];
|
|
3664
|
+
}
|
|
3665
|
+
/**
|
|
3407
3666
|
* Local-frame drag delta for a vertex sub-selection, from the HUD's
|
|
3408
|
-
* container-space `dx/dy`.
|
|
3409
|
-
*
|
|
3410
|
-
*
|
|
3411
|
-
*
|
|
3412
|
-
*
|
|
3413
|
-
*
|
|
3414
|
-
* the caller applies axis-lock and feeds the result to `translateVertices`.
|
|
3667
|
+
* container-space `dx/dy`. Resolves the element CTM and the snap neighbor
|
|
3668
|
+
* set (the path's other vertices), then defers to `point_drag_local_delta`
|
|
3669
|
+
* for the axis-lock → snap → project composition. Falls back to a plain
|
|
3670
|
+
* axis-lock of the raw delta when the element has no usable CTM. A
|
|
3671
|
+
* tangent-only drag (empty `indices`) has no vertex agents, so snap is a
|
|
3672
|
+
* no-op. Shared by the two vertex-translate handlers.
|
|
3415
3673
|
*/
|
|
3416
3674
|
vertex_drag_local_delta(node_id, baseline_model, indices, dx, dy) {
|
|
3417
3675
|
const el = this.element_index.get(node_id);
|
|
3418
3676
|
const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
|
|
3419
|
-
if (!ctm) return
|
|
3420
|
-
let local_dx = dx;
|
|
3421
|
-
let local_dy = dy;
|
|
3422
|
-
if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(dx, dy, ctm);
|
|
3677
|
+
if (!ctm) return this.axis_lock_point_delta(dx, dy);
|
|
3423
3678
|
const { agents, neighbors } = this.vertex_snap_points(baseline_model, indices);
|
|
3424
|
-
return this.
|
|
3679
|
+
return this.point_drag_local_delta(ctm, dx, dy, agents, neighbors);
|
|
3425
3680
|
}
|
|
3426
3681
|
/** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
|
|
3427
3682
|
* Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
|
|
@@ -3531,17 +3786,21 @@ var DomSurface = class DomSurface {
|
|
|
3531
3786
|
const target_y = pos_own.y;
|
|
3532
3787
|
const base_x = endpoint === "p1" ? initial.x1 : initial.x2;
|
|
3533
3788
|
const base_y = endpoint === "p1" ? initial.y1 : initial.y2;
|
|
3534
|
-
let dx = target_x - base_x;
|
|
3535
|
-
let dy = target_y - base_y;
|
|
3536
3789
|
const el = this.element_index.get(id);
|
|
3537
3790
|
const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
|
|
3791
|
+
let final_x;
|
|
3792
|
+
let final_y;
|
|
3538
3793
|
if (ctm) {
|
|
3794
|
+
const base_screen = project_point_through_ctm(base_x, base_y, ctm, this.container_offset());
|
|
3539
3795
|
const other = endpoint === "p1" ? [initial.x2, initial.y2] : [initial.x1, initial.y1];
|
|
3540
|
-
[
|
|
3796
|
+
const [ldx, ldy] = this.point_drag_local_delta(ctm, intent.pos[0] - base_screen[0], intent.pos[1] - base_screen[1], [[base_x, base_y]], [other]);
|
|
3797
|
+
final_x = base_x + ldx;
|
|
3798
|
+
final_y = base_y + ldy;
|
|
3799
|
+
} else {
|
|
3800
|
+
const [ldx, ldy] = this.axis_lock_point_delta(target_x - base_x, target_y - base_y);
|
|
3801
|
+
final_x = base_x + ldx;
|
|
3802
|
+
final_y = base_y + ldy;
|
|
3541
3803
|
}
|
|
3542
|
-
const [locked_dx, locked_dy] = this.axis_lock_point_delta(dx, dy);
|
|
3543
|
-
const final_x = base_x + locked_dx;
|
|
3544
|
-
const final_y = base_y + locked_dy;
|
|
3545
3804
|
const apply = () => {
|
|
3546
3805
|
if (endpoint === "p1") {
|
|
3547
3806
|
doc.set_attr(id, "x1", String(final_x));
|
|
@@ -4015,6 +4274,7 @@ var DomSurface = class DomSurface {
|
|
|
4015
4274
|
}
|
|
4016
4275
|
this.vector_edit = null;
|
|
4017
4276
|
this.vector_edit_region_baseline = null;
|
|
4277
|
+
this.point_snap_guide = void 0;
|
|
4018
4278
|
this.hud.setVectorSelection(null);
|
|
4019
4279
|
if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
|
|
4020
4280
|
this.editor.commands.set_mode("select");
|
|
@@ -4286,8 +4546,7 @@ var DomSurface = class DomSurface {
|
|
|
4286
4546
|
}
|
|
4287
4547
|
const baseline_d = this.active_preview.initial_d;
|
|
4288
4548
|
const indices = this.active_preview.indices;
|
|
4289
|
-
|
|
4290
|
-
[local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
|
|
4549
|
+
const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
|
|
4291
4550
|
const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
|
|
4292
4551
|
const target_d = preview_model.toSvgPathD();
|
|
4293
4552
|
this.active_preview.preview_model = preview_model;
|
|
@@ -4379,8 +4638,7 @@ var DomSurface = class DomSurface {
|
|
|
4379
4638
|
};
|
|
4380
4639
|
}
|
|
4381
4640
|
const baseline_d = this.active_preview.initial_d;
|
|
4382
|
-
|
|
4383
|
-
[local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
|
|
4641
|
+
const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
|
|
4384
4642
|
const baseline_model = this.active_preview.baseline_model;
|
|
4385
4643
|
const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
|
|
4386
4644
|
let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
|